mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(grok): make interactive spawns first-class on AgentProvider (additive)
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.
This commit is contained in:
@@ -170,6 +170,8 @@ select = [
|
|||||||
# signature for keyword-argument compatibility (mypy override check), but the
|
# signature for keyword-argument compatibility (mypy override check), but the
|
||||||
# stub bodies are empty — ARG002 would require renaming them, which breaks mypy.
|
# stub bodies are empty — ARG002 would require renaming them, which breaks mypy.
|
||||||
"tests/unit/services/test_optimal_grounding.py" = ["ARG002"]
|
"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
|
# MyPy Configuration
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ class SpawnResult:
|
|||||||
extra: dict[str, object] = field(default_factory=dict)
|
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):
|
class ProviderError(Exception):
|
||||||
"""Raised when an agent-lifecycle operation fails inside a provider.
|
"""Raised when an agent-lifecycle operation fails inside a provider.
|
||||||
|
|
||||||
@@ -62,10 +83,18 @@ class ProviderError(Exception):
|
|||||||
class AgentProvider(ABC):
|
class AgentProvider(ABC):
|
||||||
"""Abstract base for an agent-lifecycle backend.
|
"""Abstract base for an agent-lifecycle backend.
|
||||||
|
|
||||||
Every concrete provider implements the full lifecycle so the orchestrator
|
Every concrete provider implements the full one-shot lifecycle so the
|
||||||
can drive any backend through one interface.
|
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
|
@abstractmethod
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self,
|
||||||
@@ -76,6 +105,17 @@ class AgentProvider(ABC):
|
|||||||
"""Spawn an agent instance and return a handle to it."""
|
"""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
|
@abstractmethod
|
||||||
async def stop(self, instance_id: str, graceful: bool = True) -> None:
|
async def stop(self, instance_id: str, graceful: bool = True) -> None:
|
||||||
"""Stop a running instance (graceful shutdown unless ``graceful=False``)."""
|
"""Stop a running instance (graceful shutdown unless ``graceful=False``)."""
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user