Finish A28: Subagents and role registry implementation
This commit is contained in:
parent
1a21c8b1a8
commit
b149a6ab73
26 changed files with 510 additions and 275 deletions
|
|
@ -15,6 +15,7 @@ from pathlib import Path
|
|||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
from antigravity_provider.router.router_config import (
|
||||
RolePolicy,
|
||||
RouterConfig,
|
||||
|
|
@ -25,19 +26,7 @@ from antigravity_provider.router.router_config import (
|
|||
|
||||
logger = logging.getLogger("hermes.router.auto_assigner")
|
||||
|
||||
HUMAN_ROLE_LABELS = {
|
||||
"orchestrator_primary": "Главный оркестратор",
|
||||
"orchestrator_fallback": "Резервный оркестратор",
|
||||
"coder_1": "Кодер 1",
|
||||
"coder_2": "Кодер 2",
|
||||
"reviewer": "Ревьюер",
|
||||
"researcher": "Исследователь",
|
||||
"fast_agent": "Быстрый агент",
|
||||
"universal_subagent": "Универсальный субагент",
|
||||
"spare_1": "Резерв 1",
|
||||
"spare_2": "Резерв 2",
|
||||
"cold_spare": "Холодный резерв",
|
||||
}
|
||||
HUMAN_ROLE_LABELS = RoleRegistry.get_human_role_labels()
|
||||
|
||||
DEFAULT_SLOT_ROLES = {
|
||||
"codex-orch": ("Главный оркестратор", "orchestrator", "primary"),
|
||||
|
|
@ -67,35 +56,7 @@ DEFAULT_SLOT_ROLES = {
|
|||
}
|
||||
|
||||
|
||||
CANONICAL_ROLE_MAP = {
|
||||
"orchestrator": "orchestrator",
|
||||
"orchestrator_primary": "orchestrator",
|
||||
"orchestrator_fallback": "orchestrator",
|
||||
"главный оркестратор": "orchestrator",
|
||||
"резервный оркестратор": "orchestrator",
|
||||
"coder": "coder-primary",
|
||||
"coder_1": "coder-primary",
|
||||
"coder-1": "coder-primary",
|
||||
"coder-primary": "coder-primary",
|
||||
"кодер 1": "coder-primary",
|
||||
"coder_2": "coder-secondary",
|
||||
"coder-2": "coder-secondary",
|
||||
"coder-secondary": "coder-secondary",
|
||||
"кодер 2": "coder-secondary",
|
||||
"reviewer": "reviewer",
|
||||
"ревьюер": "reviewer",
|
||||
"research": "research",
|
||||
"researcher": "research",
|
||||
"исследователь": "research",
|
||||
"fast": "fast",
|
||||
"fast_agent": "fast",
|
||||
"быстрый агент": "fast",
|
||||
"general": "fast",
|
||||
"universal_subagent": "fast",
|
||||
"универсальный субагент": "fast",
|
||||
"tester": "fast",
|
||||
"тестировщик": "fast",
|
||||
}
|
||||
CANONICAL_ROLE_MAP = RoleRegistry.get_canonical_role_map()
|
||||
|
||||
|
||||
class AutoAssigner:
|
||||
|
|
@ -172,7 +133,7 @@ class AutoAssigner:
|
|||
candidates = list(provider_slots.get(provider_norm, []))
|
||||
|
||||
# Priority based on requested role
|
||||
if requested_role == "orchestrator":
|
||||
if requested_role == "manager":
|
||||
if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates:
|
||||
candidates.remove("codex-orch")
|
||||
candidates.insert(0, "codex-orch")
|
||||
|
|
@ -368,7 +329,7 @@ class AutoAssigner:
|
|||
"changes": [],
|
||||
}
|
||||
|
||||
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
|
||||
canonical_roles = [r for r in RoleRegistry.get_role_ids() if RoleRegistry.is_role_implemented(r)]
|
||||
changes: List[Dict[str, Any]] = []
|
||||
|
||||
# Ensure canonical roles exist in config
|
||||
|
|
@ -418,12 +379,12 @@ class AutoAssigner:
|
|||
else:
|
||||
# Case 3: Multiple providers connected -> distribute by role provider preferences
|
||||
role_provider_preferences = {
|
||||
"orchestrator": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"coder-primary": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"coder-secondary": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"],
|
||||
"research": ["opencode", "antigravity", "grok", "claude", "codex", "local"],
|
||||
"fast": ["opencode", "antigravity", "local", "grok", "codex", "claude"],
|
||||
"manager": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"developer-1": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"developer-2": ["codex", "antigravity", "opencode", "claude", "grok", "local"],
|
||||
"code-reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"],
|
||||
"researcher": ["opencode", "antigravity", "grok", "claude", "codex", "local"],
|
||||
"tester": ["opencode", "antigravity", "local", "grok", "codex", "claude"],
|
||||
}
|
||||
|
||||
by_prov: Dict[str, List[str]] = {}
|
||||
|
|
@ -503,7 +464,7 @@ class AutoAssigner:
|
|||
main_codex = ProfileAuthManager.get_main_profile("openai-codex")
|
||||
|
||||
team = {
|
||||
"orchestrator": [],
|
||||
"manager": [],
|
||||
"subagents": [],
|
||||
"spares": [],
|
||||
"summary": {
|
||||
|
|
|
|||
303
src/antigravity_provider/router/role_registry.py
Normal file
303
src/antigravity_provider/router/role_registry.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"""Hermes Multi-Provider Account Router — Unified Role Registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
|
||||
STATUS_DECLARED_UNIMPLEMENTED = "DECLARED_UNIMPLEMENTED"
|
||||
STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU = "Роль объявлена, исполнение не реализовано"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleDefinition:
|
||||
role_id: str
|
||||
display_name_ru: str
|
||||
short_name_ru: str
|
||||
description_ru: str
|
||||
is_implemented: bool = True
|
||||
unimplemented_reason_ru: Optional[str] = None
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
fallback_capabilities: List[str] = field(default_factory=list)
|
||||
default_preferred_chain: List[str] = field(default_factory=list)
|
||||
default_model: Optional[str] = None
|
||||
max_failover_attempts: int = 3
|
||||
session_affinity_enabled: bool = True
|
||||
is_orchestrator: bool = False
|
||||
tier: str = "core"
|
||||
|
||||
CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
||||
"manager": RoleDefinition(
|
||||
role_id="manager",
|
||||
display_name_ru="Менеджер проекта (Оркестратор)",
|
||||
short_name_ru="Менеджер",
|
||||
description_ru="Координация работы субагентов, планирование, контроль исполнения и распределение задач.",
|
||||
is_implemented=True,
|
||||
is_orchestrator=True,
|
||||
capabilities=["orchestrator", "reasoning", "management", "planning"],
|
||||
fallback_capabilities=["orchestrator", "reasoning"],
|
||||
default_preferred_chain=["codex-orch", "ag-orch-fallback", "claude-orch", "grok-orch", "opengo-3"],
|
||||
default_model="gemini-3.7-flash",
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
),
|
||||
"developer-1": RoleDefinition(
|
||||
role_id="developer-1",
|
||||
display_name_ru="Ведущий разработчик (Кодер 1)",
|
||||
short_name_ru="Кодер 1",
|
||||
description_ru="Пишет код, реализует функционал, исправляет ошибки.",
|
||||
is_implemented=True,
|
||||
capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
|
||||
fallback_capabilities=["coding"],
|
||||
default_preferred_chain=["codex-worker-1", "claude-worker-1", "ag-w1", "opengo-1"],
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
),
|
||||
"developer-2": RoleDefinition(
|
||||
role_id="developer-2",
|
||||
display_name_ru="Вспомогательный разработчик (Кодер 2)",
|
||||
short_name_ru="Кодер 2",
|
||||
description_ru="Проверяет код Разработчика 1 и выдаёт ему задание на исправление.",
|
||||
is_implemented=True,
|
||||
capabilities=["coding", "developer-2", "reviewer", "tools"],
|
||||
fallback_capabilities=["coding", "reviewer"],
|
||||
default_preferred_chain=["ag-w1", "grok-worker-1", "codex-worker-2", "opengo-3"],
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
),
|
||||
"code-reviewer": RoleDefinition(
|
||||
role_id="code-reviewer",
|
||||
display_name_ru="Ревьюер кода",
|
||||
short_name_ru="Код-ревьювер",
|
||||
description_ru="Анализирует код на ошибки, проблемы безопасности и соответствие стандартам. Работает после Разработчика 2.",
|
||||
is_implemented=True,
|
||||
capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
|
||||
fallback_capabilities=["reviewer", "coding"],
|
||||
default_preferred_chain=["claude-worker-2", "codex-worker-2", "ag-w2", "opengo-2"],
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
),
|
||||
"researcher": RoleDefinition(
|
||||
role_id="researcher",
|
||||
display_name_ru="Исследователь",
|
||||
short_name_ru="Исследователь",
|
||||
description_ru="Изучает данные, кодовую базу, документацию и внешние источники, чтобы собрать информацию для решения задачи.",
|
||||
is_implemented=True,
|
||||
capabilities=["researcher", "research", "search", "long_context"],
|
||||
fallback_capabilities=["research", "search"],
|
||||
default_preferred_chain=["ag-w3", "grok-worker-2", "opengo-2", "opengo-1"],
|
||||
max_failover_attempts=3,
|
||||
tier="core",
|
||||
),
|
||||
"tester": RoleDefinition(
|
||||
role_id="tester",
|
||||
display_name_ru="Тестировщик (QA)",
|
||||
short_name_ru="Тестировщик",
|
||||
description_ru="Создаёт тесты, проверяет корректность работы кода, находит дефекты.",
|
||||
is_implemented=True,
|
||||
capabilities=["tester", "testing", "fast", "automation"],
|
||||
fallback_capabilities=["fast", "testing"],
|
||||
default_preferred_chain=["ag-w4", "opengo-1", "ag-spare-1"],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
"tech-writer": RoleDefinition(
|
||||
role_id="tech-writer",
|
||||
display_name_ru="Технический писатель",
|
||||
short_name_ru="Техписатель",
|
||||
description_ru="Создаёт документацию, инструкции, README.",
|
||||
is_implemented=True,
|
||||
capabilities=["tech-writer", "documentation", "reasoning", "structured_output"],
|
||||
fallback_capabilities=["documentation", "reasoning"],
|
||||
default_preferred_chain=["claude-worker-2", "ag-w3", "ag-w2"],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
"analyst": RoleDefinition(
|
||||
role_id="analyst",
|
||||
display_name_ru="Системный аналитик",
|
||||
short_name_ru="Аналитик",
|
||||
description_ru="Проводит глубокий анализ данных, выявляет тренды, строит прогнозы.",
|
||||
is_implemented=True,
|
||||
capabilities=["analyst", "reasoning", "research", "planning"],
|
||||
fallback_capabilities=["reasoning", "research"],
|
||||
default_preferred_chain=["grok-worker-2", "opengo-2", "ag-w3"],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
"guardian": RoleDefinition(
|
||||
role_id="guardian",
|
||||
display_name_ru="Надзиратель (агент безопасности)",
|
||||
short_name_ru="Надзиратель",
|
||||
description_ru="Проверяет входящие инструкции на промпт-инъекции, анализирует планы и вызовы инструментов, блокирует обход системных правил, не допускает утечки секретов, следит за границами песочницы.",
|
||||
is_implemented=False,
|
||||
unimplemented_reason_ru=STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU,
|
||||
capabilities=["guardian", "security", "sandboxing"],
|
||||
fallback_capabilities=["guardian", "security"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=0,
|
||||
tier="governance",
|
||||
),
|
||||
"cost-controller": RoleDefinition(
|
||||
role_id="cost-controller",
|
||||
display_name_ru="Агент контроля затрат",
|
||||
short_name_ru="Контроль затрат",
|
||||
description_ru="Оценивает планируемый расход токенов, сравнивает с остатком бюджета, предлагает упрощения, сверяет факт с прогнозом, останавливает цепочку при исчерпании лимита.",
|
||||
is_implemented=False,
|
||||
unimplemented_reason_ru=STATUS_DECLARED_UNIMPLEMENTED_LABEL_RU,
|
||||
capabilities=["cost-controller", "budget", "analytics"],
|
||||
fallback_capabilities=["cost-controller", "budget"],
|
||||
default_preferred_chain=[],
|
||||
max_failover_attempts=0,
|
||||
tier="governance",
|
||||
),
|
||||
"integration-expert": RoleDefinition(
|
||||
role_id="integration-expert",
|
||||
display_name_ru="Специалист по интеграции",
|
||||
short_name_ru="Интегратор",
|
||||
description_ru="Работает с API и внешними сервисами, отправляет вебхуки.",
|
||||
is_implemented=True,
|
||||
capabilities=["integration-expert", "integration", "coding", "networking"],
|
||||
fallback_capabilities=["integration", "coding"],
|
||||
default_preferred_chain=["opengo-3", "codex-worker-1", "ag-w4"],
|
||||
max_failover_attempts=3,
|
||||
tier="expert",
|
||||
),
|
||||
"security-expert": RoleDefinition(
|
||||
role_id="security-expert",
|
||||
display_name_ru="Юрист / специалист по безопасности",
|
||||
short_name_ru="Безопасник",
|
||||
description_ru="Проверяет код и данные на уязвимости.",
|
||||
is_implemented=True,
|
||||
capabilities=["security-expert", "security", "code-reviewer", "audit"],
|
||||
fallback_capabilities=["security", "code-reviewer"],
|
||||
default_preferred_chain=["claude-worker-1", "codex-worker-2", "ag-w2"],
|
||||
max_failover_attempts=3,
|
||||
tier="expert",
|
||||
),
|
||||
}
|
||||
|
||||
_CANONICAL_ROLE_ALIASES: Dict[str, str] = {
|
||||
"orchestrator": "manager",
|
||||
"coder-primary": "developer-1",
|
||||
"coder-secondary": "developer-2",
|
||||
"reviewer": "code-reviewer",
|
||||
"research": "researcher",
|
||||
"fast": "tester",
|
||||
}
|
||||
|
||||
class RoleRegistry:
|
||||
@classmethod
|
||||
def get_all_roles(cls) -> Dict[str, RoleDefinition]:
|
||||
return dict(CANONICAL_ROLES)
|
||||
|
||||
@classmethod
|
||||
def get_role(cls, role_id: str) -> Optional[RoleDefinition]:
|
||||
canonical_id = cls.resolve_canonical_role(role_id)
|
||||
return CANONICAL_ROLES.get(canonical_id)
|
||||
|
||||
@classmethod
|
||||
def get_role_ids(cls) -> List[str]:
|
||||
return list(CANONICAL_ROLES.keys())
|
||||
|
||||
@classmethod
|
||||
def get_executable_role_ids(cls) -> List[str]:
|
||||
return [r_id for r_id, r_def in CANONICAL_ROLES.items() if r_def.is_implemented]
|
||||
|
||||
@classmethod
|
||||
def get_unimplemented_role_ids(cls) -> List[str]:
|
||||
return [r_id for r_id, r_def in CANONICAL_ROLES.items() if not r_def.is_implemented]
|
||||
|
||||
@classmethod
|
||||
def is_role_implemented(cls, role_id: str) -> bool:
|
||||
r_def = cls.get_role(role_id)
|
||||
return r_def.is_implemented if r_def else False
|
||||
|
||||
@classmethod
|
||||
def get_role_name_ru(cls, role_id: str, default: Optional[str] = None) -> str:
|
||||
r_def = cls.get_role(role_id)
|
||||
if r_def:
|
||||
return r_def.display_name_ru
|
||||
return default or role_id
|
||||
|
||||
@classmethod
|
||||
def get_role_short_name_ru(cls, role_id: str, default: Optional[str] = None) -> str:
|
||||
r_def = cls.get_role(role_id)
|
||||
if r_def:
|
||||
return r_def.short_name_ru
|
||||
return default or role_id
|
||||
|
||||
@classmethod
|
||||
def get_role_description_ru(cls, role_id: str) -> str:
|
||||
r_def = cls.get_role(role_id)
|
||||
if r_def:
|
||||
return r_def.description_ru
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def resolve_canonical_role(cls, name_or_alias: str) -> str:
|
||||
if not name_or_alias:
|
||||
return "manager"
|
||||
clean = name_or_alias.strip().lower()
|
||||
return _CANONICAL_ROLE_ALIASES.get(clean, clean)
|
||||
|
||||
@classmethod
|
||||
def get_canonical_role_map(cls) -> Dict[str, str]:
|
||||
return dict(_CANONICAL_ROLE_ALIASES)
|
||||
|
||||
@classmethod
|
||||
def get_human_role_labels(cls) -> Dict[str, str]:
|
||||
labels = {}
|
||||
for r_id, r_def in CANONICAL_ROLES.items():
|
||||
labels[r_id] = r_def.short_name_ru
|
||||
labels.update({
|
||||
"spare_1": "Резерв 1",
|
||||
"spare_2": "Резерв 2",
|
||||
"cold_spare": "Холодный резерв",
|
||||
})
|
||||
return labels
|
||||
|
||||
@classmethod
|
||||
def get_default_role_policies(cls) -> Dict[str, "RolePolicy"]:
|
||||
from antigravity_provider.router.router_config import RolePolicy
|
||||
policies: Dict[str, "RolePolicy"] = {}
|
||||
for r_id, r_def in CANONICAL_ROLES.items():
|
||||
policies[r_id] = RolePolicy(
|
||||
role_name=r_id,
|
||||
preferred_chain=list(r_def.default_preferred_chain),
|
||||
fallback_capabilities=list(r_def.fallback_capabilities),
|
||||
max_failover_attempts=r_def.max_failover_attempts,
|
||||
session_affinity_enabled=r_def.session_affinity_enabled,
|
||||
default_model=r_def.default_model,
|
||||
)
|
||||
return policies
|
||||
|
||||
@classmethod
|
||||
def migrate_legacy_roles(cls, current_roles: Dict[str, "RolePolicy"]) -> Tuple[Dict[str, "RolePolicy"], bool]:
|
||||
from antigravity_provider.router.router_config import RolePolicy
|
||||
migrated: Dict[str, "RolePolicy"] = {}
|
||||
was_modified = False
|
||||
default_policies = cls.get_default_role_policies()
|
||||
|
||||
for rname, rpol in current_roles.items():
|
||||
canonical_id = cls.resolve_canonical_role(rname)
|
||||
if canonical_id != rname:
|
||||
was_modified = True
|
||||
if canonical_id not in migrated:
|
||||
migrated[canonical_id] = RolePolicy(
|
||||
role_name=canonical_id,
|
||||
preferred_chain=list(rpol.preferred_chain),
|
||||
fallback_capabilities=list(rpol.fallback_capabilities) or list(default_policies[canonical_id].fallback_capabilities),
|
||||
max_failover_attempts=rpol.max_failover_attempts,
|
||||
session_affinity_enabled=rpol.session_affinity_enabled,
|
||||
default_model=rpol.default_model or default_policies[canonical_id].default_model,
|
||||
)
|
||||
else:
|
||||
migrated[rname] = rpol
|
||||
|
||||
for canon_id, def_policy in default_policies.items():
|
||||
if canon_id not in migrated:
|
||||
migrated[canon_id] = def_policy
|
||||
was_modified = True
|
||||
|
||||
return migrated, was_modified
|
||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
|
|
@ -272,51 +273,7 @@ def get_default_router_config() -> RouterConfig:
|
|||
),
|
||||
}
|
||||
|
||||
roles: dict[str, RolePolicy] = {
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"],
|
||||
fallback_capabilities=["orchestrator", "reasoning"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
default_model="gemini-3.7-flash",
|
||||
),
|
||||
"coder-primary": RolePolicy(
|
||||
role_name="coder-primary",
|
||||
preferred_chain=["codex-worker-1", "ag-w1", "opengo-3"],
|
||||
fallback_capabilities=["coding"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"coder-secondary": RolePolicy(
|
||||
role_name="coder-secondary",
|
||||
preferred_chain=["codex-worker-2", "ag-w2", "opengo-2"],
|
||||
fallback_capabilities=["coding", "reviewer"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"reviewer": RolePolicy(
|
||||
role_name="reviewer",
|
||||
preferred_chain=["codex-worker-2", "opengo-2", "ag-w2"],
|
||||
fallback_capabilities=["reviewer", "coding"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"research": RolePolicy(
|
||||
role_name="research",
|
||||
preferred_chain=["opengo-1", "ag-w3", "ag-w4"],
|
||||
fallback_capabilities=["research", "search"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"fast": RolePolicy(
|
||||
role_name="fast",
|
||||
preferred_chain=["opengo-1", "ag-w4", "ag-spare-1"],
|
||||
fallback_capabilities=["fast"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
}
|
||||
roles = RoleRegistry.get_default_role_policies()
|
||||
|
||||
return RouterConfig(
|
||||
enabled=True,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Captures, persists, and computes honest empirical measurements for all router ca
|
|||
- Ring Buffer & File Rotation (bounded memory and disk footprint)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
|
||||
import collections
|
||||
import datetime
|
||||
|
|
@ -399,7 +400,7 @@ class TelemetryService:
|
|||
global_aggs = self.get_aggregates(window_seconds=window_seconds)
|
||||
|
||||
providers = set(known_providers or ["antigravity", "openai-codex", "opencode-go"])
|
||||
roles = set(known_roles or ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"])
|
||||
roles = set(known_roles or RoleRegistry.get_role_ids())
|
||||
|
||||
with self._lock:
|
||||
for r in self._buffer:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Single Source of Truth for:
|
|||
- EventLogService
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
|
@ -644,21 +645,38 @@ class UnifiedHealthService:
|
|||
config = load_router_config()
|
||||
self.scan_all(force=False)
|
||||
|
||||
ROLE_META = {
|
||||
"orchestrator": ("Главный оркестратор", "Управление командой, планирование, контроль исполнения"),
|
||||
"coder-primary": ("Кодер 1", "Основная разработка кода и исправление дефектов"),
|
||||
"coder-secondary": ("Кодер 2", "Параллельная разработка и вспомогательные модули"),
|
||||
"reviewer": ("Ревьюер", "Независимое fail-closed ревью и валидация diff"),
|
||||
"research": ("Исследователь", "Read-only поиск в кодовой базе и сбор фактов"),
|
||||
"fast": ("Быстрый агент", "Оперативные вызовы, вспомогательные проверки"),
|
||||
"universal": ("Универсальный агент", "Широкий спектр общих задач"),
|
||||
}
|
||||
|
||||
agents: List[AgentViewModel] = []
|
||||
|
||||
for rname, rpol in config.roles.items():
|
||||
rname_ru, rdesc_ru = ROLE_META.get(rname, (rname, ""))
|
||||
rname_ru = RoleRegistry.get_role_name_ru(rname, rname)
|
||||
rdesc_ru = RoleRegistry.get_role_description_ru(rname)
|
||||
is_implemented = RoleRegistry.is_role_implemented(rname)
|
||||
|
||||
chain = rpol.preferred_chain
|
||||
|
||||
if not is_implemented:
|
||||
agents.append(AgentViewModel(
|
||||
role_id=rname,
|
||||
role_name_ru=rname_ru,
|
||||
role_description_ru=rdesc_ru or "Роль объявлена, исполнение не реализовано",
|
||||
assigned_profile_id=None,
|
||||
assigned_display_name=None,
|
||||
provider="N/A",
|
||||
provider_display_name="N/A",
|
||||
model="-",
|
||||
account_identity="Роль объявлена, исполнение не реализовано",
|
||||
routing_position="Отключено",
|
||||
status="unimplemented",
|
||||
status_label_ru="Не реализовано",
|
||||
is_active=False,
|
||||
is_main_orchestrator=False,
|
||||
cooldown_remaining_sec=0,
|
||||
session_id=None,
|
||||
active_quota_status="unavailable",
|
||||
active_quota_label="Не применяется (роль не активна)",
|
||||
))
|
||||
continue
|
||||
|
||||
if not chain:
|
||||
continue
|
||||
|
||||
|
|
@ -703,7 +721,7 @@ class UnifiedHealthService:
|
|||
status=active_pvm.health_state,
|
||||
status_label_ru=active_pvm.health_label_ru,
|
||||
is_active=(active_pvm.health_state == STATUS_HEALTHY),
|
||||
is_main_orchestrator=(rname == "orchestrator"),
|
||||
is_main_orchestrator=(rname == "manager"),
|
||||
cooldown_remaining_sec=active_pvm.cooldown_remaining_sec,
|
||||
session_id=None,
|
||||
active_quota_status=active_quota_st,
|
||||
|
|
@ -772,15 +790,7 @@ class UnifiedHealthService:
|
|||
self.scan_all(force=False)
|
||||
pipelines: Dict[str, RolePipeline] = {}
|
||||
|
||||
ROLE_NAMES = {
|
||||
"orchestrator": "Главный оркестратор",
|
||||
"coder-primary": "Кодер 1 (Primary)",
|
||||
"coder-secondary": "Кодер 2 (Secondary)",
|
||||
"reviewer": "Ревьюер",
|
||||
"research": "Исследователь",
|
||||
"fast": "Быстрый агент",
|
||||
"universal": "Универсальный агент",
|
||||
}
|
||||
|
||||
|
||||
for rname, rpol in config.roles.items():
|
||||
nodes: List[PipelineNode] = []
|
||||
|
|
@ -834,7 +844,7 @@ class UnifiedHealthService:
|
|||
|
||||
pipelines[rname] = RolePipeline(
|
||||
role_id=rname,
|
||||
role_name_ru=ROLE_NAMES.get(rname, rname),
|
||||
role_name_ru=RoleRegistry.get_role_name_ru(rname, rname),
|
||||
default_model=rpol.default_model or "auto",
|
||||
max_failover=rpol.max_failover_attempts,
|
||||
session_affinity=rpol.session_affinity_enabled,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class TestA9ConfigMigration(unittest.TestCase):
|
|||
|
||||
legacy_cfg = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="orchestrator",
|
||||
default_role="manager",
|
||||
roles=get_default_router_config().roles,
|
||||
profiles=legacy_profiles,
|
||||
)
|
||||
|
|
@ -316,9 +316,9 @@ class TestA9HermesPluginBoundary(unittest.TestCase):
|
|||
def test_resolve_role_respects_explicit_or_metadata_role(self):
|
||||
from antigravity_provider.router import get_router_engine
|
||||
engine = get_router_engine()
|
||||
self.assertEqual(engine.resolve_role({}, explicit_role="coder-primary"), "coder-primary")
|
||||
self.assertEqual(engine.resolve_role({"role": "reviewer"}), "reviewer")
|
||||
self.assertEqual(engine.resolve_role({"metadata": {"role": "research"}}), "research")
|
||||
self.assertEqual(engine.resolve_role({}, explicit_role="developer-1"), "developer-1")
|
||||
self.assertEqual(engine.resolve_role({"role": "code-reviewer"}), "code-reviewer")
|
||||
self.assertEqual(engine.resolve_role({"metadata": {"role": "researcher"}}), "researcher")
|
||||
|
||||
|
||||
class TestA9CodexOAuthTokenRefreshAndSwitching(unittest.TestCase):
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ def test_single_account_assigned_to_all_six_roles_p0_3():
|
|||
"""P0-3: A single account can be assigned as primary across all 6 canonical roles without error."""
|
||||
ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}})
|
||||
|
||||
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
canonical_roles = list(RoleRegistry.get_role_ids())
|
||||
|
||||
for rname in canonical_roles:
|
||||
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", rname, is_primary=True)
|
||||
|
|
@ -101,7 +102,7 @@ def test_single_account_assigned_to_all_six_roles_p0_3():
|
|||
assert cfg.roles[rname].preferred_chain[0] == "ag-w1"
|
||||
|
||||
# Verify action executor handles assign_role cleanly
|
||||
res = ActionExecutor.execute("assign_role", {"role_id": "orchestrator", "profile_id": "ag-w1", "is_primary": True})
|
||||
res = ActionExecutor.execute("assign_role", {"role_id": "manager", "profile_id": "ag-w1", "is_primary": True})
|
||||
assert res.get("ok") is True
|
||||
|
||||
|
||||
|
|
@ -115,9 +116,11 @@ def test_auto_assign_all_single_account_p0_4():
|
|||
assert result["total_authenticated"] == 1
|
||||
|
||||
cfg = load_router_config()
|
||||
canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
|
||||
from antigravity_provider.router.role_registry import RoleRegistry
|
||||
canonical_roles = list(RoleRegistry.get_role_ids())
|
||||
for rname in canonical_roles:
|
||||
assert cfg.roles[rname].preferred_chain == ["ag-w1"]
|
||||
if RoleRegistry.is_role_implemented(rname):
|
||||
assert cfg.roles[rname].preferred_chain == ["ag-w1"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -132,11 +135,11 @@ def test_auto_assign_all_two_accounts_same_provider_p0_4():
|
|||
|
||||
cfg = load_router_config()
|
||||
# Primary accounts should alternate between ag-w1 and ag-w2
|
||||
primaries = [cfg.roles[r].preferred_chain[0] for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]]
|
||||
primaries = [cfg.roles[r].preferred_chain[0] for r in ["manager", "developer-1", "developer-2", "code-reviewer", "researcher", "tester"]]
|
||||
assert "ag-w1" in primaries
|
||||
assert "ag-w2" in primaries
|
||||
# Fallback chains should contain the alternate account
|
||||
for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]:
|
||||
for r in ["manager", "developer-1", "developer-2", "code-reviewer", "researcher", "tester"]:
|
||||
chain = cfg.roles[r].preferred_chain
|
||||
assert len(chain) == 2
|
||||
assert set(chain) == {"ag-w1", "ag-w2"}
|
||||
|
|
@ -155,10 +158,10 @@ def test_auto_assign_all_multi_provider_preferences_p0_4():
|
|||
|
||||
cfg = load_router_config()
|
||||
# Orchestrator prefers codex
|
||||
assert cfg.roles["orchestrator"].preferred_chain[0] == "codex-orch"
|
||||
assert cfg.roles["manager"].preferred_chain[0] == "codex-orch"
|
||||
# Research / fast prefers opencode
|
||||
assert cfg.roles["research"].preferred_chain[0] == "opengo-1"
|
||||
assert cfg.roles["fast"].preferred_chain[0] == "opengo-1"
|
||||
assert cfg.roles["researcher"].preferred_chain[0] == "opengo-1"
|
||||
assert cfg.roles["tester"].preferred_chain[0] == "opengo-1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ def test_provider_selection_explanation_and_candidate_matrix(tmp_path, monkeypat
|
|||
"ag-healthy": RouterProfileConfig(profile_id="ag-healthy", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-disabled", "ag-unauth", "ag-healthy"],
|
||||
max_failover_attempts=3,
|
||||
)
|
||||
|
|
@ -188,7 +188,7 @@ def test_provider_selection_explanation_and_candidate_matrix(tmp_path, monkeypat
|
|||
|
||||
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke):
|
||||
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "explain"}]}, role="orchestrator")
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "explain"}]}, role="manager")
|
||||
|
||||
assert "router_metadata" in res
|
||||
meta = res["router_metadata"]
|
||||
|
|
@ -227,8 +227,8 @@ def test_failover_exhaustion_includes_selection_trace(tmp_path, monkeypatch):
|
|||
"ag-w1": RouterProfileConfig(profile_id="ag-w1", provider="antigravity", enabled=True, preferred_models=["gemini-2.5-pro"]),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-w1"],
|
||||
max_failover_attempts=1,
|
||||
)
|
||||
|
|
@ -246,7 +246,7 @@ def test_failover_exhaustion_includes_selection_trace(tmp_path, monkeypatch):
|
|||
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": True, "auth_state": "AUTHENTICATED"}), \
|
||||
patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke_fail):
|
||||
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator")
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager")
|
||||
|
||||
assert res.get("router_error") is True
|
||||
assert "selection_trace" in res
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ def test_provider_call_share_calculation(clean_services):
|
|||
# 2. Record 45 antigravity calls, 35 codex calls, 20 opencode calls (Total 100)
|
||||
for _ in range(45):
|
||||
ts.record_call(
|
||||
role="orchestrator",
|
||||
role="manager",
|
||||
profile_id="ag-orch",
|
||||
provider="antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
|
|
@ -81,7 +81,7 @@ def test_provider_call_share_calculation(clean_services):
|
|||
|
||||
for _ in range(35):
|
||||
ts.record_call(
|
||||
role="coder-primary",
|
||||
role="developer-1",
|
||||
profile_id="codex-w1",
|
||||
provider="openai-codex",
|
||||
model="gpt-4o",
|
||||
|
|
@ -93,7 +93,7 @@ def test_provider_call_share_calculation(clean_services):
|
|||
|
||||
for _ in range(20):
|
||||
ts.record_call(
|
||||
role="fast",
|
||||
role="tester",
|
||||
profile_id="opengo-1",
|
||||
provider="opencode-go",
|
||||
model="deepseek-v4-flash",
|
||||
|
|
@ -122,9 +122,9 @@ def test_provider_call_share_calculation(clean_services):
|
|||
assert breakdown["by_provider"]["antigravity"]["call_share"] == 0.45
|
||||
assert breakdown["by_provider"]["openai-codex"]["call_share"] == 0.35
|
||||
assert breakdown["by_provider"]["opencode-go"]["call_share"] == 0.20
|
||||
assert breakdown["by_role"]["orchestrator"]["total_calls"] == 45
|
||||
assert breakdown["by_role"]["coder-primary"]["total_calls"] == 35
|
||||
assert breakdown["by_role"]["fast"]["total_calls"] == 20
|
||||
assert breakdown["by_role"]["manager"]["total_calls"] == 45
|
||||
assert breakdown["by_role"]["developer-1"]["total_calls"] == 35
|
||||
assert breakdown["by_role"]["tester"]["total_calls"] == 20
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -211,8 +211,8 @@ def test_active_calls_and_hub_snapshot_integration(clean_services, tmp_path, mon
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-w1"],
|
||||
)
|
||||
}
|
||||
|
|
@ -227,7 +227,7 @@ def test_active_calls_and_hub_snapshot_integration(clean_services, tmp_path, mon
|
|||
|
||||
# Record 1 call
|
||||
ts.record_call(
|
||||
role="orchestrator",
|
||||
role="manager",
|
||||
profile_id="ag-w1",
|
||||
provider="antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def isolated_env(tmp_path, monkeypatch):
|
|||
def test_auth_required_skipped_in_routing(isolated_env):
|
||||
tmp_path, ht = isolated_env
|
||||
|
||||
# Configure 2 profiles for role 'orchestrator'
|
||||
# Configure 2 profiles for role 'manager'
|
||||
cfg = RouterConfig(
|
||||
profiles={
|
||||
"ag-orch-1": RouterProfileConfig(
|
||||
|
|
@ -49,12 +49,12 @@ def test_auth_required_skipped_in_routing(isolated_env):
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-orch-1", "ag-orch-2"],
|
||||
),
|
||||
},
|
||||
default_role="orchestrator",
|
||||
default_role="manager",
|
||||
)
|
||||
|
||||
engine = RouterEngine(config=cfg, health=ht)
|
||||
|
|
@ -64,7 +64,7 @@ def test_auth_required_skipped_in_routing(isolated_env):
|
|||
|
||||
# Initial state: ag-orch-1 is healthy and selected
|
||||
with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke):
|
||||
res = engine.route_request({"prompt": "hi"}, role="orchestrator")
|
||||
res = engine.route_request({"prompt": "hi"}, role="manager")
|
||||
assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1"
|
||||
|
||||
# Mark ag-orch-1 as AUTH_REQUIRED
|
||||
|
|
@ -73,7 +73,7 @@ def test_auth_required_skipped_in_routing(isolated_env):
|
|||
|
||||
# Routing should skip ag-orch-1 and route to ag-orch-2
|
||||
with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke):
|
||||
res = engine.route_request({"prompt": "hi"}, role="orchestrator")
|
||||
res = engine.route_request({"prompt": "hi"}, role="manager")
|
||||
assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-2"
|
||||
|
||||
|
||||
|
|
@ -94,12 +94,12 @@ def test_save_profile_auth_auto_recovery(isolated_env):
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-orch-1", "ag-orch-2"],
|
||||
),
|
||||
},
|
||||
default_role="orchestrator",
|
||||
default_role="manager",
|
||||
)
|
||||
engine = RouterEngine(config=cfg, health=ht)
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ def test_save_profile_auth_auto_recovery(isolated_env):
|
|||
|
||||
# Router now routes back to primary profile ag-orch-1
|
||||
with patch.object(AntigravityAdapter, "invoke", side_effect=_mock_invoke):
|
||||
res = engine.route_request({"prompt": "hi"}, role="orchestrator")
|
||||
res = engine.route_request({"prompt": "hi"}, role="manager")
|
||||
assert res.get("router_metadata", {}).get("profile_id") == "ag-orch-1"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -254,14 +254,14 @@ class TestLocalLLMConfigAndAutoAssigner:
|
|||
|
||||
p1 = cfg.profiles["local-1"]
|
||||
assert p1.provider == "local"
|
||||
assert "reviewer" in p1.capabilities
|
||||
assert "code-reviewer" in p1.capabilities
|
||||
assert "coding" in p1.capabilities
|
||||
assert "Qwen3.8-27B-Q4_K_M.gguf" in p1.preferred_models
|
||||
assert p1.max_concurrency == 1
|
||||
|
||||
p2 = cfg.profiles["local-2"]
|
||||
assert p2.provider == "local"
|
||||
assert "fast" in p2.capabilities
|
||||
assert "tester" in p2.capabilities
|
||||
assert "Qwen3-4B-Instruct-2507-Q4_K_M.gguf" in p2.preferred_models
|
||||
assert p2.max_concurrency == 1
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ class TestLocalLLMConfigAndAutoAssigner:
|
|||
}
|
||||
legacy_cfg = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="orchestrator",
|
||||
default_role="manager",
|
||||
roles=get_default_router_config().roles,
|
||||
profiles=legacy_profiles,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ def test_set_model_success_and_config_persistence(temp_models_cache):
|
|||
res = ActionExecutor.execute("set_model", {
|
||||
"profile_id": profile_id,
|
||||
"model": target_model,
|
||||
"role_id": "coder-primary",
|
||||
"role_id": "developer-1",
|
||||
})
|
||||
|
||||
assert res["ok"] is True
|
||||
|
|
@ -63,7 +63,7 @@ def test_set_model_success_and_config_persistence(temp_models_cache):
|
|||
# Verify persistence on disk
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.profiles[profile_id].preferred_models[0] == target_model
|
||||
assert reloaded.roles["coder-primary"].default_model == target_model
|
||||
assert reloaded.roles["developer-1"].default_model == target_model
|
||||
|
||||
|
||||
def test_set_model_rejects_nonexistent_model(temp_models_cache):
|
||||
|
|
|
|||
|
|
@ -45,12 +45,12 @@ def isolated_hub(tmp_path, monkeypatch):
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-orch-1"],
|
||||
),
|
||||
},
|
||||
default_role="orchestrator",
|
||||
default_role="manager",
|
||||
)
|
||||
save_router_config(cfg)
|
||||
return tmp_path
|
||||
|
|
|
|||
|
|
@ -77,17 +77,17 @@ class TestRouterConfig:
|
|||
|
||||
def test_role_policies_chains(self):
|
||||
config = get_default_router_config()
|
||||
assert "orchestrator" in config.roles
|
||||
orch = config.roles["orchestrator"]
|
||||
assert "manager" in config.roles
|
||||
orch = config.roles["manager"]
|
||||
assert orch.preferred_chain == ["codex-orch", "ag-orch-fallback", "opengo-3"]
|
||||
|
||||
coder = config.roles["coder-primary"]
|
||||
coder = config.roles["developer-1"]
|
||||
assert coder.preferred_chain == ["codex-worker-1", "ag-w1", "opengo-3"]
|
||||
|
||||
reviewer = config.roles["reviewer"]
|
||||
reviewer = config.roles["code-reviewer"]
|
||||
assert reviewer.preferred_chain == ["codex-worker-2", "opengo-2", "ag-w2"]
|
||||
|
||||
research = config.roles["research"]
|
||||
research = config.roles["researcher"]
|
||||
assert research.preferred_chain == ["opengo-1", "ag-w3", "ag-w4"]
|
||||
|
||||
|
||||
|
|
@ -141,14 +141,14 @@ class TestSessionAffinityAndLeases:
|
|||
affinity = SessionAffinityTracker()
|
||||
assert affinity.get_affinity("sess-1") is None
|
||||
|
||||
affinity.set_affinity("sess-1", "orchestrator", "codex-orch", "gpt-4o")
|
||||
affinity.set_affinity("sess-1", "manager", "codex-orch", "gpt-4o")
|
||||
rec = affinity.get_affinity("sess-1")
|
||||
assert rec is not None
|
||||
assert rec.profile_id == "codex-orch"
|
||||
assert rec.role == "orchestrator"
|
||||
assert rec.role == "manager"
|
||||
|
||||
# Update on failover
|
||||
affinity.set_affinity("sess-1", "orchestrator", "ag-orch-fallback", "gemini-3.7-flash")
|
||||
affinity.set_affinity("sess-1", "manager", "ag-orch-fallback", "gemini-3.7-flash")
|
||||
rec2 = affinity.get_affinity("sess-1")
|
||||
assert rec2.profile_id == "ag-orch-fallback"
|
||||
|
||||
|
|
@ -182,14 +182,14 @@ class TestRouterEngineFailover:
|
|||
|
||||
# 1. Normal state: codex-orch succeeds
|
||||
with patch.object(CodexAdapter, "invoke", return_value=mock_codex_response):
|
||||
resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator", session_id="sess-orch-1")
|
||||
resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager", session_id="sess-orch-1")
|
||||
assert resp["choices"][0]["message"]["content"] == "from-codex"
|
||||
assert resp["router_metadata"]["profile_id"] == "codex-orch"
|
||||
|
||||
# 2. Simulate quota on codex-orch: should auto-failover to ag-orch-fallback
|
||||
engine.health.simulate_quota("codex-orch", duration=600)
|
||||
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag_response):
|
||||
resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="orchestrator", session_id="sess-orch-2")
|
||||
resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="manager", session_id="sess-orch-2")
|
||||
assert resp2["choices"][0]["message"]["content"] == "from-antigravity"
|
||||
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
|
||||
assert resp2["router_metadata"]["failover_count"] == 0 # picked directly because codex-orch was marked unhealthy
|
||||
|
|
@ -197,7 +197,7 @@ class TestRouterEngineFailover:
|
|||
# 3. Simulate quota on both codex-orch and ag-orch-fallback: should failover to opengo-3
|
||||
engine.health.simulate_quota("ag-orch-fallback", duration=600)
|
||||
with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo_response):
|
||||
resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="orchestrator", session_id="sess-orch-3")
|
||||
resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="manager", session_id="sess-orch-3")
|
||||
assert resp3["choices"][0]["message"]["content"] == "from-opencode"
|
||||
assert resp3["router_metadata"]["profile_id"] == "opengo-3"
|
||||
|
||||
|
|
@ -220,13 +220,13 @@ class TestRouterEngineFailover:
|
|||
# Turn 1: codex-orch fails with quota exhaustion -> failover to ag-orch-fallback
|
||||
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Quota limit reached")):
|
||||
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
|
||||
resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="orchestrator", session_id="session-user-123")
|
||||
resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="manager", session_id="session-user-123")
|
||||
assert resp["choices"][0]["message"]["content"] == "a1"
|
||||
assert resp["router_metadata"]["profile_id"] == "ag-orch-fallback"
|
||||
|
||||
# Turn 2: same session continues directly on ag-orch-fallback
|
||||
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
|
||||
resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="orchestrator", session_id="session-user-123")
|
||||
resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="manager", session_id="session-user-123")
|
||||
assert resp2["choices"][0]["message"]["content"] == "a1"
|
||||
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
|
||||
|
||||
|
|
@ -256,9 +256,9 @@ class TestRouterCLI:
|
|||
rc = print_routing_policy()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "orchestrator" in out
|
||||
assert "coder-primary" in out
|
||||
assert "reviewer" in out
|
||||
assert "manager" in out
|
||||
assert "developer-1" in out
|
||||
assert "code-reviewer" in out
|
||||
|
||||
def test_simulate_quota_cli(self, capsys):
|
||||
rc = simulate_quota_cli("codex-orch", duration=300)
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ def test_p0_4_auto_assign_all(tmp_path, monkeypatch):
|
|||
# Verify only canonical roles exist in config
|
||||
cfg = load_router_config()
|
||||
for rname in cfg.roles:
|
||||
assert rname in {"orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"}
|
||||
assert rname in {"manager", "developer-1", "developer-2", "code-reviewer", "researcher", "tester"}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -148,8 +148,8 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch):
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-orch-primary", "codex-orch-fallback"],
|
||||
max_failover_attempts=2,
|
||||
)
|
||||
|
|
@ -175,7 +175,7 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch):
|
|||
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_invoke), \
|
||||
patch.object(CodexAdapter, "invoke", side_effect=mock_codex_invoke):
|
||||
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator")
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager")
|
||||
|
||||
# Must receive fallback response, NOT error text as message content!
|
||||
assert "choices" in res
|
||||
|
|
@ -236,18 +236,18 @@ def test_p0_7_assign_role_action(tmp_path, monkeypatch):
|
|||
config = get_default_router_config()
|
||||
save_router_config(config)
|
||||
|
||||
# 1. Assign "coder" -> must update "coder-primary"
|
||||
# 1. Assign "coder" -> must update "developer-1"
|
||||
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "coder", is_primary=True)
|
||||
assert ok is True
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.roles["coder-primary"].preferred_chain[0] == "ag-w1"
|
||||
assert reloaded.roles["developer-1"].preferred_chain[0] == "ag-w1"
|
||||
assert "coder" not in reloaded.roles # Must NOT create a non-canonical role
|
||||
|
||||
# 2. Assign "researcher" -> must update "research"
|
||||
# 2. Assign "researcher" -> must update "researcher"
|
||||
ok, msg = AutoAssigner.assign_profile_to_role("ag-w2", "researcher", is_primary=True)
|
||||
assert ok is True
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.roles["research"].preferred_chain[0] == "ag-w2"
|
||||
assert reloaded.roles["researcher"].preferred_chain[0] == "ag-w2"
|
||||
|
||||
# 3. Unknown role -> must return False and reject
|
||||
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "completely_unknown_role_xyz")
|
||||
|
|
@ -262,7 +262,7 @@ def test_n1_spare_assignment_mode(tmp_path, monkeypatch):
|
|||
|
||||
config = get_default_router_config()
|
||||
# Put ag-w1 in coder-primary
|
||||
config.roles["coder-primary"].preferred_chain = ["ag-w1", "ag-w2"]
|
||||
config.roles["developer-1"].preferred_chain = ["ag-w1", "ag-w2"]
|
||||
save_router_config(config)
|
||||
|
||||
# Assign ag-w1 to spare
|
||||
|
|
@ -271,7 +271,7 @@ def test_n1_spare_assignment_mode(tmp_path, monkeypatch):
|
|||
assert "резерв" in msg.lower() or "spare" in msg.lower()
|
||||
|
||||
reloaded = load_router_config()
|
||||
assert "ag-w1" not in reloaded.roles["coder-primary"].preferred_chain
|
||||
assert "ag-w1" not in reloaded.roles["developer-1"].preferred_chain
|
||||
assert "spare" not in reloaded.roles # Canonical role set unchanged
|
||||
assert reloaded.profiles["ag-w1"].enabled is True
|
||||
|
||||
|
|
@ -303,12 +303,12 @@ def test_p0_8_wizard_role_application(tmp_path, monkeypatch):
|
|||
save_router_config(config)
|
||||
|
||||
# Apply role
|
||||
ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "reviewer", is_primary=True)
|
||||
ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "code-reviewer", is_primary=True)
|
||||
assert ok is True
|
||||
|
||||
reloaded = load_router_config()
|
||||
assert "codex-worker-1" in reloaded.roles["reviewer"].preferred_chain
|
||||
assert reloaded.roles["reviewer"].preferred_chain[0] == "codex-worker-1"
|
||||
assert "codex-worker-1" in reloaded.roles["code-reviewer"].preferred_chain
|
||||
assert reloaded.roles["code-reviewer"].preferred_chain[0] == "codex-worker-1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -392,8 +392,8 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch):
|
|||
"codex-orch-fallback": RouterProfileConfig(profile_id="codex-orch-fallback", provider="openai-codex", enabled=True),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-orch-primary", "codex-orch-fallback"],
|
||||
max_failover_attempts=2,
|
||||
)
|
||||
|
|
@ -413,7 +413,7 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch):
|
|||
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \
|
||||
patch.object(CodexAdapter, "invoke", mock_codex):
|
||||
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator")
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager")
|
||||
assert "error" in res or "choices" in res
|
||||
# Codex fallback must NOT have been called because auto_failover was False!
|
||||
assert mock_codex.call_count == 0
|
||||
|
|
@ -425,7 +425,7 @@ def test_r4_settings_runtime_influence(tmp_path, monkeypatch):
|
|||
with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \
|
||||
patch.object(CodexAdapter, "invoke", mock_codex):
|
||||
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator")
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="manager")
|
||||
assert res["choices"][0]["message"]["content"] == "Fallback OK"
|
||||
assert mock_codex.call_count == 1
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ def test_event_bus_pub_sub():
|
|||
|
||||
# UI thread dispatch with root.after mock
|
||||
mock_root = MagicMock()
|
||||
bus.publish_to_ui(mock_root, EVENT_ROUTING_UPDATED, {"role": "coder-primary"})
|
||||
bus.publish_to_ui(mock_root, EVENT_ROUTING_UPDATED, {"role": "developer-1"})
|
||||
assert mock_root.after.called
|
||||
|
||||
|
||||
|
|
@ -124,16 +124,16 @@ def test_refresh_scheduler_dedup_and_delays():
|
|||
def test_session_affinity_ttl_and_lru():
|
||||
tracker = SessionAffinityTracker(ttl_seconds=2, max_entries=3)
|
||||
|
||||
tracker.set_affinity("s1", "coder-primary", "ag-w1", "gemini-2.5-pro")
|
||||
tracker.set_affinity("s2", "coder-primary", "ag-w2", "gemini-2.5-pro")
|
||||
tracker.set_affinity("s3", "reviewer", "codex-w1", "gpt-4o")
|
||||
tracker.set_affinity("s1", "developer-1", "ag-w1", "gemini-2.5-pro")
|
||||
tracker.set_affinity("s2", "developer-1", "ag-w2", "gemini-2.5-pro")
|
||||
tracker.set_affinity("s3", "code-reviewer", "codex-w1", "gpt-4o")
|
||||
|
||||
assert tracker.get_affinity("s1") is not None
|
||||
assert tracker.get_affinity("s2") is not None
|
||||
assert tracker.get_affinity("s3") is not None
|
||||
|
||||
# Exceed capacity -> triggers LRU eviction
|
||||
tracker.set_affinity("s4", "fast", "ag-w3", "gemini-2.5-flash")
|
||||
tracker.set_affinity("s4", "tester", "ag-w3", "gemini-2.5-flash")
|
||||
assert len(tracker._sessions) <= 3
|
||||
|
||||
# Test TTL expiration
|
||||
|
|
@ -154,7 +154,7 @@ def test_model_registry_capability_filtering():
|
|||
assert m_gemini_flash is not None
|
||||
|
||||
# Reviewer requires security_analysis and coding
|
||||
req_reviewer = reg.get_role_requirements("reviewer")
|
||||
req_reviewer = reg.get_role_requirements("code-reviewer")
|
||||
ok_pro, score_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_reviewer)
|
||||
ok_flash, score_flash, reason_flash = reg.evaluate_model_score(m_gemini_flash, req_reviewer)
|
||||
|
||||
|
|
@ -163,7 +163,7 @@ def test_model_registry_capability_filtering():
|
|||
assert ok_flash is False
|
||||
|
||||
# Fast role prioritizes latency
|
||||
req_fast = reg.get_role_requirements("fast")
|
||||
req_fast = reg.get_role_requirements("tester")
|
||||
ok_f_flash, score_f_flash, _ = reg.evaluate_model_score(m_gemini_flash, req_fast)
|
||||
ok_f_pro, score_f_pro, _ = reg.evaluate_model_score(m_gemini_pro, req_fast)
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ def test_model_registry_capability_filtering():
|
|||
# ── TEST 6: RouterEngine Dynamic Selection Trace & Same-Account Fallback ──
|
||||
def test_router_engine_selection_trace_and_same_account_fallback():
|
||||
cfg = RouterConfig(
|
||||
default_role="coder-primary",
|
||||
default_role="developer-1",
|
||||
profiles={
|
||||
"ag-w1": RouterProfileConfig(
|
||||
profile_id="ag-w1",
|
||||
|
|
@ -190,8 +190,8 @@ def test_router_engine_selection_trace_and_same_account_fallback():
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"coder-primary": RolePolicy(
|
||||
role_name="coder-primary",
|
||||
"developer-1": RolePolicy(
|
||||
role_name="developer-1",
|
||||
preferred_chain=["ag-w1", "codex-w1"],
|
||||
),
|
||||
},
|
||||
|
|
@ -205,10 +205,10 @@ def test_router_engine_selection_trace_and_same_account_fallback():
|
|||
mock_adapter.invoke.return_value = {"content": "code generated", "model": "google-antigravity/gemini-2.5-pro"}
|
||||
mock_adapter_getter.return_value = mock_adapter
|
||||
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="coder-primary")
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="developer-1")
|
||||
assert "router_metadata" in res
|
||||
meta = res["router_metadata"]
|
||||
assert meta["role"] == "coder-primary"
|
||||
assert meta["role"] == "developer-1"
|
||||
assert meta["profile_id"] == "ag-w1"
|
||||
assert "selection_trace" in meta
|
||||
assert meta["selection_trace"]["selected_model"] == "google-antigravity/gemini-2.5-pro"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ def test_telemetry_recording_latency_tokens_outcome(temp_telemetry_service):
|
|||
svc = temp_telemetry_service
|
||||
|
||||
rec = svc.record_call(
|
||||
role="orchestrator",
|
||||
role="manager",
|
||||
profile_id="ag-orch",
|
||||
provider="antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
|
|
@ -51,7 +51,7 @@ def test_telemetry_recording_latency_tokens_outcome(temp_telemetry_service):
|
|||
failover_count=0,
|
||||
)
|
||||
|
||||
assert rec.role == "orchestrator"
|
||||
assert rec.role == "manager"
|
||||
assert rec.profile_id == "ag-orch"
|
||||
assert rec.provider == "antigravity"
|
||||
assert rec.model == "gemini-2.5-pro"
|
||||
|
|
@ -82,7 +82,7 @@ def test_telemetry_aggregates_calculation(temp_telemetry_service):
|
|||
outcome = "success" if i <= 8 else "error"
|
||||
error_cat = None if outcome == "success" else "quota_exhausted"
|
||||
svc.record_call(
|
||||
role="coder-primary",
|
||||
role="developer-1",
|
||||
profile_id="codex-w1",
|
||||
provider="openai-codex",
|
||||
model="gpt-4o",
|
||||
|
|
@ -141,7 +141,7 @@ def test_telemetry_no_tokens_when_usage_missing(temp_telemetry_service):
|
|||
svc = temp_telemetry_service
|
||||
|
||||
rec = svc.record_call(
|
||||
role="fast",
|
||||
role="tester",
|
||||
profile_id="opengo-1",
|
||||
provider="opencode-go",
|
||||
model="deepseek-v4-flash",
|
||||
|
|
@ -170,7 +170,7 @@ def test_telemetry_no_secrets_and_no_request_response_content(temp_telemetry_ser
|
|||
|
||||
# Record a normal call
|
||||
rec = svc.record_call(
|
||||
role="orchestrator",
|
||||
role="manager",
|
||||
profile_id="ag-w1",
|
||||
provider="antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
|
|
@ -228,7 +228,7 @@ def test_telemetry_cost_calculation_with_and_without_pricing(temp_telemetry_serv
|
|||
|
||||
# 1. Without pricing -> cost_usd is None
|
||||
rec1 = svc.record_call(
|
||||
role="coder-primary",
|
||||
role="developer-1",
|
||||
profile_id="ag-w1",
|
||||
provider="antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
|
|
@ -245,7 +245,7 @@ def test_telemetry_cost_calculation_with_and_without_pricing(temp_telemetry_serv
|
|||
})
|
||||
|
||||
rec2 = svc.record_call(
|
||||
role="coder-primary",
|
||||
role="developer-1",
|
||||
profile_id="ag-w1",
|
||||
provider="antigravity",
|
||||
model="gemini-2.5-pro",
|
||||
|
|
@ -279,8 +279,8 @@ def test_router_engine_records_telemetry_end_to_end(tmp_path, monkeypatch):
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
"manager": RolePolicy(
|
||||
role_name="manager",
|
||||
preferred_chain=["ag-w1"],
|
||||
)
|
||||
}
|
||||
|
|
@ -304,7 +304,7 @@ def test_router_engine_records_telemetry_end_to_end(tmp_path, monkeypatch):
|
|||
with patch.object(TelemetryService, "get", return_value=svc), \
|
||||
patch.object(AntigravityAdapter, "invoke", side_effect=mock_invoke):
|
||||
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator")
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="manager")
|
||||
|
||||
assert "router_metadata" in res
|
||||
aggs = svc.get_aggregates()
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|||
# Initialize sample profiles and roles
|
||||
config = RouterConfig(
|
||||
enabled=True,
|
||||
default_role="orchestrator",
|
||||
default_role="manager",
|
||||
profiles={
|
||||
"ag-w1": RouterProfileConfig(
|
||||
profile_id="ag-w1",
|
||||
|
|
@ -60,13 +60,13 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|||
),
|
||||
},
|
||||
roles={
|
||||
"coder-primary": RolePolicy(
|
||||
role_name="coder-primary",
|
||||
"developer-1": RolePolicy(
|
||||
role_name="developer-1",
|
||||
preferred_chain=["ag-w1", "ag-w2", "op-1"],
|
||||
default_model="gemini-2.5-pro",
|
||||
),
|
||||
"coder-secondary": RolePolicy(
|
||||
role_name="coder-secondary",
|
||||
"developer-2": RolePolicy(
|
||||
role_name="developer-2",
|
||||
preferred_chain=["ag-w2", "op-1"],
|
||||
default_model="gemini-2.5-flash",
|
||||
),
|
||||
|
|
@ -79,14 +79,14 @@ def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|||
def test_persist_role_chain_reorder_and_persistence(temp_router_config: Path):
|
||||
"""Verify persist_role_chain updates preferred_chain and primary_profile_id and survives reload."""
|
||||
new_chain: List[str] = ["op-1", "ag-w2", "ag-w1"]
|
||||
ok, msg = AutoAssigner.persist_role_chain("coder-primary", new_chain)
|
||||
ok, msg = AutoAssigner.persist_role_chain("developer-1", new_chain)
|
||||
|
||||
assert ok is True
|
||||
assert "успешно сохранена" in msg
|
||||
|
||||
# Reload from disk
|
||||
reloaded = load_router_config()
|
||||
role_cfg = reloaded.roles["coder-primary"]
|
||||
role_cfg = reloaded.roles["developer-1"]
|
||||
assert role_cfg.preferred_chain == new_chain
|
||||
assert role_cfg.preferred_chain[0] == "op-1"
|
||||
|
||||
|
|
@ -98,18 +98,18 @@ def test_persist_role_chain_canonical_name_mapping(temp_router_config: Path):
|
|||
|
||||
assert ok is True
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.roles["coder-primary"].preferred_chain == new_chain
|
||||
assert reloaded.roles["developer-1"].preferred_chain == new_chain
|
||||
|
||||
|
||||
def test_persist_role_chain_validation_errors(temp_router_config: Path):
|
||||
"""Verify persist_role_chain rejects invalid profiles and duplicates."""
|
||||
# Unknown profile
|
||||
ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "non-existent-profile"])
|
||||
ok, msg = AutoAssigner.persist_role_chain("developer-1", ["ag-w1", "non-existent-profile"])
|
||||
assert ok is False
|
||||
assert "не найден" in msg
|
||||
|
||||
# Duplicate profile in chain
|
||||
ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "ag-w1"])
|
||||
ok, msg = AutoAssigner.persist_role_chain("developer-1", ["ag-w1", "ag-w1"])
|
||||
assert ok is False
|
||||
assert "повторяться" in msg or "дублир" in msg
|
||||
|
||||
|
|
@ -124,20 +124,20 @@ def test_action_executor_save_chain(temp_router_config: Path):
|
|||
executor = ActionExecutor()
|
||||
|
||||
# save_chain
|
||||
res1 = executor.execute("save_chain", {"role_id": "coder-primary", "chain": ["ag-w2", "ag-w1"]})
|
||||
res1 = executor.execute("save_chain", {"role_id": "developer-1", "chain": ["ag-w2", "ag-w1"]})
|
||||
assert res1["ok"] is True
|
||||
|
||||
# reorder_chain
|
||||
res2 = executor.execute("reorder_chain", {"role_id": "coder-primary", "desired_chain": ["op-1", "ag-w1"]})
|
||||
res2 = executor.execute("reorder_chain", {"role_id": "developer-1", "desired_chain": ["op-1", "ag-w1"]})
|
||||
assert res2["ok"] is True
|
||||
|
||||
# edit_route
|
||||
res3 = executor.execute("edit_route", {"role_id": "coder-secondary", "chain": ["op-1", "ag-w2"]})
|
||||
res3 = executor.execute("edit_route", {"role_id": "developer-2", "chain": ["op-1", "ag-w2"]})
|
||||
assert res3["ok"] is True
|
||||
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.roles["coder-primary"].preferred_chain == ["op-1", "ag-w1"]
|
||||
assert reloaded.roles["coder-secondary"].preferred_chain == ["op-1", "ag-w2"]
|
||||
assert reloaded.roles["developer-1"].preferred_chain == ["op-1", "ag-w1"]
|
||||
assert reloaded.roles["developer-2"].preferred_chain == ["op-1", "ag-w2"]
|
||||
|
||||
|
||||
def test_action_executor_set_model_updates_profile_and_role(temp_router_config: Path):
|
||||
|
|
@ -147,13 +147,13 @@ def test_action_executor_set_model_updates_profile_and_role(temp_router_config:
|
|||
res = executor.execute("set_model", {
|
||||
"profile_id": "ag-w1",
|
||||
"model": "gemini-3.1-pro",
|
||||
"role_id": "coder-primary",
|
||||
"role_id": "developer-1",
|
||||
})
|
||||
assert res["ok"] is True
|
||||
|
||||
reloaded = load_router_config()
|
||||
assert reloaded.profiles["ag-w1"].preferred_models[0] == "gemini-3.1-pro"
|
||||
assert reloaded.roles["coder-primary"].default_model == "gemini-3.1-pro"
|
||||
assert reloaded.roles["developer-1"].default_model == "gemini-3.1-pro"
|
||||
|
||||
|
||||
def test_web_client_7_views_exact_order_and_no_redundant_views():
|
||||
|
|
|
|||
|
|
@ -177,16 +177,16 @@ def test_route_and_agent_delta_events():
|
|||
bus.subscribe("AGENT_UPDATED", _on_agent)
|
||||
|
||||
try:
|
||||
store.apply_delta_route_changed("coder-primary", "ag-w1", failover_reason="Testing failover")
|
||||
store.apply_delta_route_changed("developer-1", "ag-w1", failover_reason="Testing failover")
|
||||
assert len(route_events) >= 1
|
||||
assert route_events[-1]["role_id"] == "coder-primary"
|
||||
assert route_events[-1]["role_id"] == "developer-1"
|
||||
assert route_events[-1]["failover_reason"] == "Testing failover"
|
||||
assert "generation" in route_events[-1]
|
||||
assert "seq" in route_events[-1]
|
||||
|
||||
assert len(agent_events) >= 1
|
||||
assert agent_events[-1]["role_id"] == "coder-primary"
|
||||
assert agent_events[-1]["agent"].role_id == "coder-primary"
|
||||
assert agent_events[-1]["role_id"] == "developer-1"
|
||||
assert agent_events[-1]["agent"].role_id == "developer-1"
|
||||
finally:
|
||||
bus.unsubscribe(EVENT_ROUTING_UPDATED, _on_route)
|
||||
bus.unsubscribe("AGENT_UPDATED", _on_agent)
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ def test_single_scheduler_refresh_waits_for_quota_before_delta(monkeypatch: pyte
|
|||
def test_model_score_rejects_exhausted_pool_and_prefers_more_quota() -> None:
|
||||
registry = ModelRegistry.get()
|
||||
descriptor = registry.get_model("gemini-2.5-pro")
|
||||
requirements = registry.get_role_requirements("research")
|
||||
requirements = registry.get_role_requirements("researcher")
|
||||
assert descriptor is not None
|
||||
|
||||
ok_empty, _, reason = registry.evaluate_model_score(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def test_grok_wizard_definition_and_routing_flow():
|
|||
"""Verify Grok profile definition and routing pipeline addition."""
|
||||
config = RouterConfig(
|
||||
profiles={},
|
||||
roles={"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=[])},
|
||||
roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])},
|
||||
)
|
||||
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
|
||||
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
|
||||
|
|
@ -47,7 +47,7 @@ def test_grok_wizard_definition_and_routing_flow():
|
|||
|
||||
ok_route, msg_route = ensure_profile_in_routing("grok-worker-1")
|
||||
assert ok_route, f"Routing failed: {msg_route}"
|
||||
assert "grok-worker-1" in config.roles["coder-primary"].preferred_chain
|
||||
assert "grok-worker-1" in config.roles["developer-1"].preferred_chain
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -55,7 +55,7 @@ def test_claude_wizard_definition_and_routing_flow():
|
|||
"""Verify Claude profile definition and routing pipeline addition."""
|
||||
config = RouterConfig(
|
||||
profiles={},
|
||||
roles={"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=[])},
|
||||
roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])},
|
||||
)
|
||||
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
|
||||
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
|
||||
|
|
@ -68,7 +68,7 @@ def test_claude_wizard_definition_and_routing_flow():
|
|||
|
||||
ok_route, msg_route = ensure_profile_in_routing("claude-orch")
|
||||
assert ok_route, f"Routing failed: {msg_route}"
|
||||
assert "claude-orch" in config.roles["orchestrator"].preferred_chain
|
||||
assert "claude-orch" in config.roles["manager"].preferred_chain
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ def _profile(plan_code: str = "PRO", plan_source: str = "provider_api") -> Profi
|
|||
account_identity="user@example.test",
|
||||
provider="antigravity",
|
||||
provider_display_name="Google Antigravity",
|
||||
assigned_roles=["coder-primary"],
|
||||
primary_role="coder-primary",
|
||||
assigned_roles=["developer-1"],
|
||||
primary_role="developer-1",
|
||||
is_main_account=True,
|
||||
is_main_orchestrator=False,
|
||||
auth_state="AUTHENTICATED",
|
||||
|
|
@ -143,7 +143,7 @@ def test_quota_missing_is_not_rendered_as_zero_and_reason_is_visible(ui_root) ->
|
|||
@pytest.mark.ui
|
||||
def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> None:
|
||||
agent = AgentViewModel(
|
||||
role_id="coder-primary",
|
||||
role_id="developer-1",
|
||||
role_name_ru="Кодер 1",
|
||||
role_description_ru="Основной кодер",
|
||||
assigned_profile_id="account-2",
|
||||
|
|
@ -163,7 +163,7 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N
|
|||
)
|
||||
team_card = AgentCardWidget(ui_root)
|
||||
pipeline = RolePipeline(
|
||||
role_id="coder-primary",
|
||||
role_id="developer-1",
|
||||
role_name_ru="Кодер 1",
|
||||
default_model="gpt-5",
|
||||
max_failover=2,
|
||||
|
|
@ -214,7 +214,7 @@ def test_agent_quota_and_failover_reason_are_bound_to_their_models(ui_root) -> N
|
|||
|
||||
def test_dashboard_agent_quota_measurement_drives_progress_percent() -> None:
|
||||
agent = AgentViewModel(
|
||||
role_id="coder-primary",
|
||||
role_id="developer-1",
|
||||
role_name_ru="Кодер 1",
|
||||
role_description_ru="Основной кодер",
|
||||
assigned_profile_id="ag-w1",
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
|
|||
@pytest.mark.ui
|
||||
def test_assign_role_error_stays_visible_in_open_modal(ui_root, monkeypatch) -> None:
|
||||
policy = SimpleNamespace(preferred_chain=[])
|
||||
monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"orchestrator": policy}))
|
||||
monkeypatch.setattr(app_module, "load_router_config", lambda: SimpleNamespace(roles={"manager": policy}))
|
||||
monkeypatch.setattr(
|
||||
app_module.AutoAssigner,
|
||||
"assign_profile_to_role",
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ def _profile(index: int) -> ProfileViewModel:
|
|||
account_identity=f"user-{index}@example.test",
|
||||
provider="antigravity",
|
||||
provider_display_name="Google Antigravity",
|
||||
assigned_roles=["coder" if index else "orchestrator"],
|
||||
primary_role="coder" if index else "orchestrator",
|
||||
assigned_roles=["coder" if index else "manager"],
|
||||
primary_role="coder" if index else "manager",
|
||||
is_main_account=index == 0,
|
||||
is_main_orchestrator=index == 0,
|
||||
auth_state="AUTHENTICATED",
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ def _config():
|
|||
"coder": SimpleNamespace(provider="antigravity", preferred_models=["gemini"]),
|
||||
}
|
||||
roles = {
|
||||
"orchestrator": SimpleNamespace(preferred_chain=["orch"]),
|
||||
"coder-primary": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"coder-secondary": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"reviewer": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"research": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"fast": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"manager": SimpleNamespace(preferred_chain=["orch"]),
|
||||
"developer-1": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"developer-2": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"code-reviewer": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"researcher": SimpleNamespace(preferred_chain=["coder"]),
|
||||
"tester": SimpleNamespace(preferred_chain=["coder"]),
|
||||
}
|
||||
return SimpleNamespace(roles=roles, profiles=profiles)
|
||||
|
||||
|
|
@ -83,16 +83,16 @@ def test_graph_layout_zoom_and_viewport_survive_restart(tmp_path):
|
|||
|
||||
def test_validation_finds_cycle_unreachable_and_missing_profile():
|
||||
config = _config()
|
||||
config.roles["reviewer"].preferred_chain = ["ghost"]
|
||||
config.roles["code-reviewer"].preferred_chain = ["ghost"]
|
||||
graph = RoutingGraph(
|
||||
nodes=[
|
||||
GraphNode("orchestrator", 0, 0),
|
||||
GraphNode("coder-primary", 1, 0),
|
||||
GraphNode("reviewer", 2, 0),
|
||||
GraphNode("manager", 0, 0),
|
||||
GraphNode("developer-1", 1, 0),
|
||||
GraphNode("code-reviewer", 2, 0),
|
||||
],
|
||||
edges=[
|
||||
GraphEdge("orchestrator", "coder-primary"),
|
||||
GraphEdge("coder-primary", "orchestrator"),
|
||||
GraphEdge("manager", "developer-1"),
|
||||
GraphEdge("developer-1", "manager"),
|
||||
],
|
||||
)
|
||||
codes = {issue.code for issue in validate_graph(graph, config)}
|
||||
|
|
@ -108,16 +108,16 @@ def test_profile_edge_updates_yaml_via_auto_assigner(monkeypatch, tmp_path):
|
|||
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
|
||||
)
|
||||
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
|
||||
ok, _message = controller.add_edge("orchestrator", "coder-primary", "FALLBACK", "orch")
|
||||
ok, _message = controller.add_edge("manager", "developer-1", "FALLBACK", "orch")
|
||||
assert ok
|
||||
assert calls == [("orch", "coder-primary", False)]
|
||||
assert calls == [("orch", "developer-1", False)]
|
||||
|
||||
|
||||
def test_undo_redo_and_dirty_state(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(graph_module, "load_router_config", _config)
|
||||
controller = RoutingGraphController(RoutingGraphStore(tmp_path / "graph.json"))
|
||||
original = controller.graph.nodes[0].x
|
||||
controller.move_node("orchestrator", original + 100, 50)
|
||||
controller.move_node("manager", original + 100, 50)
|
||||
assert controller.dirty
|
||||
assert controller.undo()
|
||||
assert controller.graph.nodes[0].x == original
|
||||
|
|
@ -255,8 +255,8 @@ def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch)
|
|||
config = SimpleNamespace(
|
||||
profiles={key: SimpleNamespace() for key in ("a", "b", "c")},
|
||||
roles={
|
||||
"orchestrator": SimpleNamespace(preferred_chain=["a", "b", "c"]),
|
||||
"reviewer": SimpleNamespace(preferred_chain=["b"]),
|
||||
"manager": SimpleNamespace(preferred_chain=["a", "b", "c"]),
|
||||
"code-reviewer": SimpleNamespace(preferred_chain=["b"]),
|
||||
},
|
||||
)
|
||||
calls = []
|
||||
|
|
@ -279,11 +279,11 @@ def test_role_chain_order_and_removal_persist_through_auto_assigner(monkeypatch)
|
|||
monkeypatch.setattr(team_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(team_module.AutoAssigner, "assign_profile_to_role", assign)
|
||||
|
||||
ok, _message = team_module.persist_role_chain("orchestrator", ["c", "a"])
|
||||
ok, _message = team_module.persist_role_chain("manager", ["c", "a"])
|
||||
|
||||
assert ok
|
||||
assert config.roles["orchestrator"].preferred_chain == ["c", "a"]
|
||||
assert config.roles["reviewer"].preferred_chain == ["b"]
|
||||
assert config.roles["manager"].preferred_chain == ["c", "a"]
|
||||
assert config.roles["code-reviewer"].preferred_chain == ["b"]
|
||||
assert ("b", "spare", False) in calls
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ def test_main_account_vs_orchestrator_separation():
|
|||
service.scan_all()
|
||||
agents = service.get_agent_view_models()
|
||||
|
||||
orch_agent = next((a for a in agents if a.role_id == "orchestrator"), None)
|
||||
orch_agent = next((a for a in agents if a.role_id == "manager"), None)
|
||||
assert orch_agent is not None
|
||||
assert orch_agent.is_main_orchestrator is True
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue