feat(grok): route interactive intake/secretary to opencode-serve images

Wire the GROK interactive path the in-place way (matching how the interactive
roles already choose ANTHROPIC_* per route), so a GROK route launches the
Grok-native opencode-serve image instead of the Claude SDK-driver image:

- _spawn_intake_container / _spawn_secretary_container pick the
  grok-prompter / grok-secretary image (ensuring the base→grok→interactive
  build chain) when the route is GROK, and stamp provider_type on the spec +
  AgentConfig so finalize routes usage to the opencode store.
- _build_intake_run_cmd / _build_secretary_run_cmd inject OPENAI_* + the
  opencode store mount + system-prompt env for GROK via a shared
  _append_interactive_provider_env, keeping ANTHROPIC_* for every other
  provider. The intake's minimal mounts (no gateway MCP) are preserved, so
  Grok intake matches the Claude intake's tool surface (the spec).
- Add a per-agent opencode store mount to the interactive host paths so
  interactive Grok usage/cost is captured like the one-shot path.

Removes the interim Phase-0 routing guard (the real path supersedes it) and
retires the unused AgentProvider.spawn_interactive/InteractiveSpawnSpec seam —
the interactive roles have a bespoke assembly that the one-shot provider
surface doesn't fit, so the fork lives in their own builders.

UNVERIFIED-LIVE: end-to-end intake/secretary chat on Grok needs the stack up +
opencode serve confirmed against grok-build-0.1.
This commit is contained in:
Renn F
2026-06-18 12:32:41 +02:00
parent 3753fbccc4
commit 0d76ebf586
7 changed files with 211 additions and 205 deletions
-55
View File
@@ -1,55 +0,0 @@
"""AgentProvider interactive scaffolding: opt-in, declines by default."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.llm.providers.base import (
AgentProvider,
InteractiveSpawnSpec,
ProviderError,
SpawnResult,
)
if TYPE_CHECKING:
from pathlib import Path
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
class _OneShotOnly(AgentProvider):
"""A minimal provider that implements only the one-shot lifecycle."""
async def spawn(
self,
config: AgentConfig,
initial_prompt: str | None = None,
agent_settings_path: Path | None = None,
) -> SpawnResult:
return SpawnResult(instance_id="x")
async def stop(self, instance_id: str, graceful: bool = True) -> None: ...
async def health_check(self, instance_id: str) -> bool:
return True
async def remove(self, instance_id: str) -> None: ...
def _spec() -> InteractiveSpawnSpec:
cfg = type("C", (), {"agent_id": "intake-1"})()
return InteractiveSpawnSpec(
config=cfg, image="img", session_id="s1", role="prompter"
)
def test_provider_declines_interactive_by_default() -> None:
assert _OneShotOnly.supports_interactive is False
@pytest.mark.asyncio
async def test_spawn_interactive_default_raises() -> None:
# A one-shot-only provider declines interactive spawns rather than crashing.
with pytest.raises(ProviderError):
await _OneShotOnly().spawn_interactive(_spec())
@@ -0,0 +1,92 @@
"""Interactive intake/secretary builders fork a GROK route onto opencode.
A GROK route swaps the Claude SDK-driver image for the opencode-serve image and
the ANTHROPIC_* env for OPENAI_* + the opencode store mount; every other
provider keeps the Claude path's ANTHROPIC_* behaviour.
"""
from __future__ import annotations
from roboco.runtime.orchestrator import (
GROK_PROMPTER_IMAGE,
GROK_SECRETARY_IMAGE,
AgentOrchestrator,
_IntakeRunSpec,
_SecretaryRunSpec,
)
_HOSTS = {
"claude": "/h/.claude",
"prompt": "/h/p.md",
"workspaces": "/h/ws",
"opencode": "/h/oc/intake-1",
}
def _intake_spec(
provider_type: str, *, base_url: str | None, token: str | None
) -> _IntakeRunSpec:
return _IntakeRunSpec(
container_name="roboco-agent-intake-1",
image=GROK_PROMPTER_IMAGE
if provider_type == "grok"
else "roboco-agent-prompter",
hosts=_HOSTS,
session_id="sess-1",
cwd="/data/workspace",
cli_model="grok-build-0.1",
api_url="http://roboco-orchestrator:8000",
provider_base_url=base_url,
provider_auth_token=token,
provider_type=provider_type,
model="grok-build-0.1",
)
def test_intake_grok_uses_openai_env_and_opencode_mount() -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
)
assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd
assert "OPENAI_API_KEY=xai-key" in cmd
assert "ROBOCO_AGENT_MODEL=grok-build-0.1" in cmd
assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd
assert "/h/oc/intake-1:/home/agent/.local/share/opencode" in cmd
assert cmd[-1] == GROK_PROMPTER_IMAGE
# The xAI endpoint is never mislabelled as Anthropic.
assert not any(c.startswith("ANTHROPIC_") for c in cmd)
def test_intake_anthropic_keeps_anthropic_env() -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(
_intake_spec("anthropic", base_url="https://api.anthropic.com", token="sk-ant")
)
assert "ANTHROPIC_BASE_URL=https://api.anthropic.com" in cmd
assert "ANTHROPIC_AUTH_TOKEN=sk-ant" in cmd
assert not any(c.startswith("OPENAI_") for c in cmd)
assert cmd[-1] == "roboco-agent-prompter"
def test_secretary_grok_uses_openai_env_and_grok_image() -> None:
spec = _SecretaryRunSpec(
container_name="roboco-agent-secretary-1",
image=GROK_SECRETARY_IMAGE,
hosts={"claude": "/h/.claude", "prompt": "/h/p.md", "opencode": "/h/oc/sec-1"},
session_id="sess-2",
cwd="/app",
cli_model="grok-build-0.1",
api_url="http://roboco-orchestrator:8000",
agent_uuid="uuid-sec",
agent_token="hmac-secretary",
provider_base_url="https://api.x.ai/v1",
provider_auth_token="xai-key",
provider_type="grok",
model="grok-build-0.1",
)
cmd = AgentOrchestrator._build_secretary_run_cmd(spec)
assert "OPENAI_API_KEY=xai-key" in cmd
assert "/h/oc/sec-1:/home/agent/.local/share/opencode" in cmd
# The HMAC identity the directive tools authenticate with survives.
assert "ROBOCO_AGENT_TOKEN=hmac-secretary" in cmd
assert cmd[-1] == GROK_SECRETARY_IMAGE
assert not any(c.startswith("ANTHROPIC_") for c in cmd)
@@ -1,60 +0,0 @@
"""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