From 6f60d180e90ae1cc34380d4c7a3a4ddf0aad4d2b Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 18 Jun 2026 11:57:42 +0200 Subject: [PATCH] feat(grok): make interactive spawns first-class on AgentProvider (additive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentProvider ABC modelled only the one-shot lifecycle (spawn/stop/ health_check/remove), so the interactive intake/secretary roles could never route through a provider. Add an opt-in interactive surface: - supports_interactive class flag (default False). - InteractiveSpawnSpec: the resolved AgentConfig + session id + role-specific image + optional HMAC token — everything a provider needs without importing orchestrator internals. - spawn_interactive(spec): a non-abstract default that declines via ProviderError, so every existing one-shot provider is unchanged. Pure scaffolding — no provider opts in yet (GrokProvider flips the flag when its interactive driver lands). Zero behavioural change. --- pyproject.toml | 2 + roboco/llm/providers/base.py | 44 +++++++++++++++++++++- tests/unit/llm/test_provider_base.py | 55 ++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 tests/unit/llm/test_provider_base.py diff --git a/pyproject.toml b/pyproject.toml index da6cf836..c2d9f631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,6 +170,8 @@ select = [ # signature for keyword-argument compatibility (mypy override check), but the # stub bodies are empty — ARG002 would require renaming them, which breaks mypy. "tests/unit/services/test_optimal_grounding.py" = ["ARG002"] +# Minimal AgentProvider stub exercising the interactive default — same reason. +"tests/unit/llm/test_provider_base.py" = ["ARG002"] # ============================================================================= # MyPy Configuration diff --git a/roboco/llm/providers/base.py b/roboco/llm/providers/base.py index 759129cb..b3d002c6 100644 --- a/roboco/llm/providers/base.py +++ b/roboco/llm/providers/base.py @@ -38,6 +38,27 @@ class SpawnResult: extra: dict[str, object] = field(default_factory=dict) +@dataclass(frozen=True) +class InteractiveSpawnSpec: + """Inputs to spawn a long-lived **interactive** agent (intake / secretary). + + Interactive roles run a held-open chat session the human types into, rather + than a one-shot task. This bundles everything a provider's + :meth:`AgentProvider.spawn_interactive` needs so it never imports + orchestrator internals: the resolved :class:`AgentConfig` (carries the + provider routing creds, model, mcp config, agent id), the per-session id the + relay/SSE keys on, the role-specific interactive image, and — for the + secretary, whose directive tools authenticate to the API — the HMAC token. + """ + + config: AgentConfig + image: str + session_id: str + role: str # "prompter" | "secretary" + agent_token: str | None = None + agent_settings_path: Path | None = None + + class ProviderError(Exception): """Raised when an agent-lifecycle operation fails inside a provider. @@ -62,10 +83,18 @@ class ProviderError(Exception): class AgentProvider(ABC): """Abstract base for an agent-lifecycle backend. - Every concrete provider implements the full lifecycle so the orchestrator - can drive any backend through one interface. + Every concrete provider implements the full one-shot lifecycle so the + orchestrator can drive any backend through one interface. Interactive + (held-open chat) spawns are opt-in: a provider that serves the human-facing + intake/secretary roles sets ``supports_interactive = True`` and overrides + :meth:`spawn_interactive`. One-shot-only providers inherit the default, + which declines — so adding the interactive surface breaks no existing + provider. """ + #: Whether this backend can spawn long-lived interactive (chat) agents. + supports_interactive: bool = False + @abstractmethod async def spawn( self, @@ -76,6 +105,17 @@ class AgentProvider(ABC): """Spawn an agent instance and return a handle to it.""" ... + async def spawn_interactive(self, spec: InteractiveSpawnSpec) -> SpawnResult: + """Spawn a long-lived interactive agent (intake / secretary). + + Non-abstract: the default declines so one-shot-only providers need no + change. Providers that set ``supports_interactive = True`` override this. + """ + raise ProviderError( + f"{type(self).__name__} does not support interactive spawns", + agent_id=spec.config.agent_id, + ) + @abstractmethod async def stop(self, instance_id: str, graceful: bool = True) -> None: """Stop a running instance (graceful shutdown unless ``graceful=False``).""" diff --git a/tests/unit/llm/test_provider_base.py b/tests/unit/llm/test_provider_base.py new file mode 100644 index 00000000..e0384b7e --- /dev/null +++ b/tests/unit/llm/test_provider_base.py @@ -0,0 +1,55 @@ +"""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())