diff --git a/roboco/services/llm.py b/roboco/services/llm.py index 6e5fde25..98512d0e 100644 --- a/roboco/services/llm.py +++ b/roboco/services/llm.py @@ -116,6 +116,12 @@ class _ResolvedAssignment: model_name: str +# The two human-facing interactive roles (held-open chat sessions). Kept as a +# literal here — not imported from runtime.orchestrator — to avoid a service → +# orchestrator import cycle; the slugs are stable seed identities. +_INTERACTIVE_AGENT_SLUGS = frozenset({"intake-1", "secretary-1"}) + + class ModelRoutingService(BaseService): """Resolves per-agent routes from `model_assignments` + legacy fallback.""" @@ -134,9 +140,34 @@ class ModelRoutingService(BaseService): if resolved is not None and resolved.provider.enabled: route = await self._route_from_resolved(resolved, agent_slug) if route is not None: - return route + return self._guard_interactive(route, agent_slug, role) return self._legacy_route(role) + def _guard_interactive( + self, route: AgentRoute, agent_slug: str, role: str + ) -> AgentRoute: + """Keep the human-facing interactive roles off GROK for now. + + intake (prompter) and secretary run a held-open chat the human types + into, driven by the Claude Agent SDK. GROK has no interactive runtime + yet, and the interactive spawn injects the route's creds as + ``ANTHROPIC_*`` against ``api.x.ai/v1`` — the wrong protocol — yielding a + silent, empty reply. Until the opencode interactive driver lands, + downgrade a GROK route for these slugs to the Anthropic default. (The + one-shot delivery roles route to GROK unchanged.) + """ + if ( + route.provider_type == ModelProvider.GROK + and agent_slug in _INTERACTIVE_AGENT_SLUGS + ): + self.log.warning( + "GROK route for an interactive role downgraded to Anthropic " + "(no Grok interactive runtime yet)", + agent_slug=agent_slug, + ) + return self._legacy_route(role) + return route + async def _resolve_assignment( self, agent_slug: str, role: str ) -> _ResolvedAssignment | None: diff --git a/tests/unit/services/test_llm_routing_interactive_guard.py b/tests/unit/services/test_llm_routing_interactive_guard.py new file mode 100644 index 00000000..994cfeab --- /dev/null +++ b/tests/unit/services/test_llm_routing_interactive_guard.py @@ -0,0 +1,60 @@ +"""The interactive-role routing guard keeps intake/secretary off GROK. + +GROK has no interactive runtime yet, and the interactive spawn would inject the +route's creds as ANTHROPIC_* against api.x.ai/v1 (wrong protocol → silent empty +reply). Until the opencode interactive driver lands, a GROK route for those +slugs is downgraded to the Anthropic default. The one-shot delivery roles route +to GROK unchanged. DB-free: exercises `_guard_interactive` directly. +""" + +from __future__ import annotations + +import structlog +from roboco.models.base import ModelProvider +from roboco.services.llm import AgentRoute, ModelRoutingService + + +def _svc() -> ModelRoutingService: + svc = ModelRoutingService.__new__(ModelRoutingService) + svc.log = structlog.get_logger() + return svc + + +def _grok_route() -> AgentRoute: + return AgentRoute( + provider_id=None, + provider_type=ModelProvider.GROK, + base_url="https://api.x.ai/v1", + auth_token="xai-key", + model_name="grok-build-0.1", + ) + + +def test_grok_interactive_role_downgrades_to_anthropic() -> None: + svc = _svc() + for slug in ("intake-1", "secretary-1"): + route = svc._guard_interactive(_grok_route(), slug, "prompter") + assert route.provider_type == ModelProvider.ANTHROPIC + # No xAI creds leak onto the Claude SDK path. + assert route.base_url is None + assert route.auth_token is None + + +def test_grok_delivery_role_is_left_on_grok() -> None: + svc = _svc() + route = svc._guard_interactive(_grok_route(), "be-dev-1", "developer") + assert route.provider_type == ModelProvider.GROK + assert route.base_url == "https://api.x.ai/v1" + + +def test_non_grok_interactive_route_is_unchanged() -> None: + svc = _svc() + anthropic = AgentRoute( + provider_id=None, + provider_type=ModelProvider.ANTHROPIC, + base_url=None, + auth_token=None, + model_name="claude-opus-4-6", + ) + route = svc._guard_interactive(anthropic, "intake-1", "prompter") + assert route is anthropic