mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(kimi): Kimi K3 provider on the official kimi-code CLI (Wave 1) ModelProvider.KIMI routes through KimiCliProvider driving Moonshot's kimi CLI on a Kimi subscription (OAuth device-code, no metered key). One-shot delivery roles only (V1), interactive ban wired in both guard lists. Auth: one shared RW auth mount; containers symlink credentials/ and oauth/ (the CLI's cross-process refresh-lock dir) into a container-local KIMI_CODE_HOME so every container and the host redeem the SAME rotating refresh chain - live-verified that per-copy chains cross-invalidate after the reuse-grace window. No orchestrator refresh daemon; an expires_at preflight exits 78. Config renderer mirrors the login-managed provider/model blocks field-for-field (live-captured; the model value is the CLI-side name, never the raw API id), plus per-role deny rules and the bash-guard as a PreToolUse hook via a wrapper script (an env key on a hooks entry makes the CLI silently drop ALL hooks - live-verified). Usage capture sums wire.jsonl usage.record 4-bucket events; sniff classifies rate-limit/auth from structured error text only, mapped to the shared 75/78 park contract. Image installs the CLI latest-at-build (no version pin, by policy) with the resolved version stamped as provenance, binary split to /usr/local away from mutable state. Migrations 090 (enum) + 091 (provider seed); catalog, pricing, routing mode, and orchestrator park/usage wiring mirror the codex integration. * feat(kimi): surface sweep + fleet-wide pin drop (Wave 2) Compose x3 gain the agent-kimi-image service and the orchestrator's read-write ~/.kimi-code mount + kimi-usage dir; .env.example documents the Kimi block. Panel mirrors ModelProvider.KIMI and adds the kimi routing mode (catalog filter, mode button, mix-picker group, badge) with tests; provider routes gain the kimi remediation entry. CLAUDE.md and docs/map document the runtime. Per the no-pins policy, agent-grok/ gemini/codex Dockerfiles drop their version pins for latest-at-build with resolved-version provenance stamps (grok resolves 0.2.112 vs the old 0.2.56 pin - verified by real builds of all four images). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
203 lines
7.4 KiB
Python
203 lines
7.4 KiB
Python
"""Codex (OPENAI), Gemini (GEMINI), and Kimi (KIMI) are V1 delivery-roles-only
|
|
— none has an interactive-session driver image (unlike GROK's dedicated
|
|
GROK_PROMPTER_IMAGE / GROK_SECRETARY_IMAGE). Routing any of them to the
|
|
persistent Intake/Secretary agent must refuse loudly instead of silently
|
|
falling through to the plain Claude SDK-driver image with a mismatched
|
|
provider env.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from roboco.models.base import ModelProvider
|
|
from roboco.runtime.orchestrator import (
|
|
_INTERACTIVE_UNSUPPORTED_PROVIDERS,
|
|
INTAKE_AGENT_ID,
|
|
SECRETARY_AGENT_ID,
|
|
AgentOrchestrator,
|
|
_reject_interactive_unsupported_provider,
|
|
)
|
|
from roboco.services import prompter_live
|
|
from roboco.services.llm import (
|
|
INTERACTIVE_AGENT_SLUGS,
|
|
INTERACTIVE_UNSUPPORTED_PROVIDERS,
|
|
)
|
|
|
|
|
|
def _make_minimal_orchestrator() -> AgentOrchestrator:
|
|
with patch.object(AgentOrchestrator, "__init__", return_value=None):
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
orch._instances = {}
|
|
orch._bg_tasks = set()
|
|
orch._running = True
|
|
orch._intake_spawn_lock = asyncio.Lock()
|
|
orch._secretary_spawn_lock = asyncio.Lock()
|
|
return orch
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fresh_registry() -> Any:
|
|
prev = prompter_live._RegistryHolder.instance
|
|
prompter_live._RegistryHolder.instance = prompter_live.PrompterLiveRegistry()
|
|
yield
|
|
prompter_live._RegistryHolder.instance = prev
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit-level: the pure guard function itself.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRejectInteractiveUnsupportedProvider:
|
|
def test_guard_set_matches_the_resolver_exemption_set(self) -> None:
|
|
"""The orchestrator's literal must track the resolver's canonical
|
|
tuple (kept separate to avoid a runtime import cycle)."""
|
|
assert tuple(_INTERACTIVE_UNSUPPORTED_PROVIDERS) == tuple(
|
|
INTERACTIVE_UNSUPPORTED_PROVIDERS
|
|
)
|
|
|
|
def test_resolver_slugs_match_the_orchestrator_agent_ids(self) -> None:
|
|
"""The resolver's exemption must cover exactly the two interactive
|
|
agents the orchestrator spawns — a renamed id would silently
|
|
un-exempt a chat."""
|
|
assert set(INTERACTIVE_AGENT_SLUGS) == {INTAKE_AGENT_ID, SECRETARY_AGENT_ID}
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider", [ModelProvider.OPENAI, ModelProvider.GEMINI, ModelProvider.KIMI]
|
|
)
|
|
def test_raises_for_delivery_only_providers(self, provider: ModelProvider) -> None:
|
|
with pytest.raises(RuntimeError, match="delivery-roles-only"):
|
|
_reject_interactive_unsupported_provider(INTAKE_AGENT_ID, provider)
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider",
|
|
[
|
|
ModelProvider.ANTHROPIC,
|
|
ModelProvider.GROK,
|
|
ModelProvider.OLLAMA_CLOUD,
|
|
ModelProvider.LOCAL,
|
|
],
|
|
)
|
|
def test_passes_for_interactive_capable_providers(
|
|
self, provider: ModelProvider
|
|
) -> None:
|
|
_reject_interactive_unsupported_provider(INTAKE_AGENT_ID, provider) # no raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Intake spawn refusal — surfaces on the relay, container never launched.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIntakeSpawnRefusesDeliveryOnlyProvider:
|
|
@pytest.mark.parametrize(
|
|
"provider", [ModelProvider.OPENAI, ModelProvider.GEMINI, ModelProvider.KIMI]
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_refuses_before_any_container_work(
|
|
self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider
|
|
) -> None:
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
async def _clone(*_a: Any, **_k: Any) -> tuple[str, list[str]]:
|
|
return "/data/workspaces/roboco/board/intake-1", ["/cwd"]
|
|
|
|
async def _route(_aid: str) -> Any:
|
|
return SimpleNamespace(
|
|
provider_type=provider,
|
|
model_name="whatever",
|
|
base_url=None,
|
|
auth_token=None,
|
|
)
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
async def _run(cmd: list[str]) -> str:
|
|
run_calls.append(cmd)
|
|
return "containerid0123456789"
|
|
|
|
monkeypatch.setattr(orch, "_clone_intake_scope", _clone)
|
|
monkeypatch.setattr(orch, "_resolve_agent_route", _route)
|
|
monkeypatch.setattr(
|
|
orch, "_generate_composed_prompt", lambda *_a, **_k: Path("/tmp/p.md")
|
|
)
|
|
monkeypatch.setattr(orch, "_run_container_cmd", _run)
|
|
|
|
registry = prompter_live.get_live_registry()
|
|
pushed: list[tuple[str, dict[str, Any]]] = []
|
|
closed: list[str] = []
|
|
monkeypatch.setattr(registry, "push", lambda sid, ev: pushed.append((sid, ev)))
|
|
monkeypatch.setattr(registry, "close", closed.append)
|
|
registry.open("sess-refuse", INTAKE_AGENT_ID)
|
|
|
|
await orch._spawn_intake_container_guarded(
|
|
"sess-refuse", project_slug="roboco", product_id=None, initial_message=None
|
|
)
|
|
|
|
assert not run_calls # no container was ever launched
|
|
assert len(pushed) == 1
|
|
assert pushed[0][1]["kind"] == "error"
|
|
assert "delivery-roles-only" in pushed[0][1]["text"]
|
|
assert closed == ["sess-refuse"]
|
|
assert INTAKE_AGENT_ID not in orch._instances
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Secretary spawn refusal — same shape, same guard.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSecretarySpawnRefusesDeliveryOnlyProvider:
|
|
@pytest.mark.parametrize(
|
|
"provider", [ModelProvider.OPENAI, ModelProvider.GEMINI, ModelProvider.KIMI]
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_refuses_before_any_container_work(
|
|
self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider
|
|
) -> None:
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
async def _route(_aid: str) -> Any:
|
|
return SimpleNamespace(
|
|
provider_type=provider,
|
|
model_name="whatever",
|
|
base_url=None,
|
|
auth_token=None,
|
|
)
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
async def _run(cmd: list[str]) -> str:
|
|
run_calls.append(cmd)
|
|
return "containerid0123456789"
|
|
|
|
monkeypatch.setattr(orch, "_resolve_agent_route", _route)
|
|
monkeypatch.setattr(
|
|
orch, "_generate_composed_prompt", lambda *_a, **_k: Path("/tmp/p.md")
|
|
)
|
|
monkeypatch.setattr(orch, "_run_container_cmd", _run)
|
|
|
|
registry = prompter_live.get_live_registry()
|
|
pushed: list[tuple[str, dict[str, Any]]] = []
|
|
closed: list[str] = []
|
|
monkeypatch.setattr(registry, "push", lambda sid, ev: pushed.append((sid, ev)))
|
|
monkeypatch.setattr(registry, "close", closed.append)
|
|
registry.open("sess-sec-refuse", SECRETARY_AGENT_ID)
|
|
|
|
await orch._spawn_secretary_container_guarded(
|
|
"sess-sec-refuse", initial_message=None
|
|
)
|
|
|
|
assert not run_calls # no container was ever launched
|
|
assert len(pushed) == 1
|
|
assert pushed[0][1]["kind"] == "error"
|
|
assert "delivery-roles-only" in pushed[0][1]["text"]
|
|
assert closed == ["sess-sec-refuse"]
|
|
assert SECRETARY_AGENT_ID not in orch._instances
|