mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(grok): convert interactive intake/secretary to the grok CLI; delete opencode
Move the last Grok runtime off opencode onto xAI's official `grok` CLI, for full parity with the Claude path. The intake/secretary chat now runs per-turn headless `grok -p` invocations that resume one session id (proven live: context carries across runs), with streaming-json deltas mapped to the existing panel StreamChunk kinds — the IntakeDriver loop, message source, relay, and idle reaper are reused unchanged; only the SessionFactory differs (GrokCliSession replaces the opencode-serve session). - GrokCliSession + a pure, unit-tested streaming-json -> StreamChunk assembler (thought coalesced to one block, text streamed live, end captures the session id for -r, fenced-draft fallback, clear errors incl. rate-limit). - intake propose_draft and secretary read_company_state/read_task/submit_directive are now FastMCP servers (roboco-intake / roboco-secretary) wired into ~/.grok/config.toml, launched via `uv run --directory /app` to resolve the installed package. The secretary tools reuse the shared backend helpers. - Orchestrator: interactive spawn mounts the subscription auth + per-agent usage dir (no metered xAI key, no permission env — grok flags carry per-role perms); usage/cost now read a captured usage.json (drop the opencode.db reader, the _opencode_db_path/_grok_usage_from_opencode methods, and the cost-cap's opencode read). hosts["opencode"] -> hosts["grok_usage"]; OPENCODE_DATA_DIR -> GROK_USAGE_DATA_DIR. - Fix one-shot usage capture: `-s` does not pin the session id (grok generates its own), so the entrypoint now reads the real id back from the JSON run log and the reader uses it; usage is captured per-turn on the interactive path. - Delete the opencode layer: opencode_config/opencode_usage/opencode_session, the docker/grok/*.js plugins, the old one-shot entrypoint, and their tests. - Compose (all three files), .env.example, and stale comments updated to the grok-CLI runtime; add the SuperGrok auth mount + grok-usage dir. Gate green: ruff, mypy (296 files), xenon, tests. NAS build/verify pending.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"""GROK cost budget kill-switch: kill a live container over the cost ceiling.
|
||||
|
||||
opencode exposes no usage hook to a plugin, so the budget kill-switch lives in
|
||||
the orchestrator: it reads each live GROK container's cumulative opencode cost
|
||||
and kills + evicts it past ROBOCO_GROK_MAX_COST_USD (also catching runaway-loop
|
||||
token burn). The cost computation itself is covered in opencode_usage tests; here
|
||||
cost_for_session is stubbed so the kill DECISION is exercised deterministically.
|
||||
The grok CLI exposes no live usage hook, so the budget kill-switch lives in the
|
||||
orchestrator: it reads each live GROK container's captured cost (from its
|
||||
usage.json, via ``_grok_cost_usd``) and kills + evicts it past
|
||||
ROBOCO_GROK_MAX_COST_USD (also catching runaway-loop token burn). The usage.json
|
||||
read is covered in the grok usage tests; here ``_grok_cost_usd`` is stubbed so the
|
||||
kill DECISION is exercised deterministically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,22 +16,32 @@ import pytest
|
||||
from roboco.models.runtime import AgentInstance
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
|
||||
|
||||
_COST_FN = "roboco.llm.providers.opencode_usage.cost_for_session"
|
||||
|
||||
|
||||
def _grok_instance(provider_type: str = "grok") -> AgentInstance:
|
||||
cfg = type("C", (), {"provider_type": provider_type, "model": "grok-build-0.1"})()
|
||||
cfg = type("C", (), {"provider_type": provider_type, "model": "grok-build"})()
|
||||
return AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE, config=cfg)
|
||||
|
||||
|
||||
def _orch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
cap: float,
|
||||
cost: float,
|
||||
provider_type: str = "grok",
|
||||
) -> tuple[AgentOrchestrator, AsyncMock]:
|
||||
"""A bare orchestrator with the cost reader + container removal stubbed."""
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._grok_max_cost_usd = cap
|
||||
orch._instances = {"be-dev-1": _grok_instance(provider_type)}
|
||||
monkeypatch.setattr(orch, "_grok_cost_usd", lambda _agent_id: cost)
|
||||
remove_mock = AsyncMock()
|
||||
monkeypatch.setattr(orch, "_remove_container", remove_mock)
|
||||
return orch, remove_mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_over_cap_kills_and_evicts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._grok_max_cost_usd = 5.0
|
||||
orch._instances = {"be-dev-1": _grok_instance()}
|
||||
remove_mock = AsyncMock()
|
||||
monkeypatch.setattr(orch, "_remove_container", remove_mock)
|
||||
monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 7.5))
|
||||
orch, remove_mock = _orch(monkeypatch, cap=5.0, cost=7.5)
|
||||
|
||||
await orch._enforce_grok_cost_budget()
|
||||
|
||||
@@ -40,12 +51,7 @@ async def test_cost_over_cap_kills_and_evicts(monkeypatch: pytest.MonkeyPatch) -
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_under_cap_spares(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._grok_max_cost_usd = 5.0
|
||||
orch._instances = {"be-dev-1": _grok_instance()}
|
||||
remove_mock = AsyncMock()
|
||||
monkeypatch.setattr(orch, "_remove_container", remove_mock)
|
||||
monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 1.0))
|
||||
orch, remove_mock = _orch(monkeypatch, cap=5.0, cost=1.0)
|
||||
|
||||
await orch._enforce_grok_cost_budget()
|
||||
|
||||
@@ -55,12 +61,7 @@ async def test_cost_under_cap_spares(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cap_zero_disables_the_sweep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._grok_max_cost_usd = 0.0
|
||||
orch._instances = {"be-dev-1": _grok_instance()}
|
||||
remove_mock = AsyncMock()
|
||||
monkeypatch.setattr(orch, "_remove_container", remove_mock)
|
||||
monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 999.0))
|
||||
orch, remove_mock = _orch(monkeypatch, cap=0.0, cost=999.0)
|
||||
|
||||
await orch._enforce_grok_cost_budget()
|
||||
|
||||
@@ -70,12 +71,9 @@ async def test_cap_zero_disables_the_sweep(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_grok_container_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._grok_max_cost_usd = 5.0
|
||||
orch._instances = {"be-dev-1": _grok_instance(provider_type="anthropic")}
|
||||
remove_mock = AsyncMock()
|
||||
monkeypatch.setattr(orch, "_remove_container", remove_mock)
|
||||
monkeypatch.setattr(_COST_FN, lambda *_a, **_k: (None, 999.0))
|
||||
orch, remove_mock = _orch(
|
||||
monkeypatch, cap=5.0, cost=999.0, provider_type="anthropic"
|
||||
)
|
||||
|
||||
await orch._enforce_grok_cost_budget()
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""GROK agents capture token usage/cost from their opencode SQLite store.
|
||||
"""GROK agents capture token usage/cost from their captured ``usage.json``.
|
||||
|
||||
A Grok agent runs opencode — no SDK /usage/status server and no Claude
|
||||
transcript — so finalize must read opencode.db (mounted into the orchestrator)
|
||||
instead. Reasoning folds into output (it bills at the output rate).
|
||||
A Grok agent runs the grok CLI — no SDK /usage/status server and no Claude
|
||||
transcript — so finalize reads the ``usage.json`` the entrypoint / interactive
|
||||
driver wrote to the per-agent data dir (mounted into the orchestrator). grok
|
||||
reports a single cumulative total with no input/output split, so it folds into
|
||||
output (it bills at the output rate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@@ -18,82 +20,58 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_db(path: Path, cols: dict[str, float]) -> None:
|
||||
con = sqlite3.connect(path)
|
||||
con.execute(
|
||||
"CREATE TABLE session (id TEXT, tokens_input INT, tokens_output INT, "
|
||||
"tokens_cache_read INT, tokens_cache_write INT, tokens_reasoning INT, "
|
||||
"cost REAL)"
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO session (id, tokens_input, tokens_output, tokens_cache_read, "
|
||||
"tokens_cache_write, tokens_reasoning, cost) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
"s1",
|
||||
cols["tokens_input"],
|
||||
cols["tokens_output"],
|
||||
cols["tokens_cache_read"],
|
||||
cols["tokens_cache_write"],
|
||||
cols["tokens_reasoning"],
|
||||
cols["cost"],
|
||||
def _write_usage(path: Path, total_tokens: int, cost_usd: float) -> None:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{"model": "grok-build", "total_tokens": total_tokens, "cost_usd": cost_usd}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def test_grok_usage_folds_reasoning_into_output(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
_make_db(
|
||||
db,
|
||||
{
|
||||
"tokens_input": 100,
|
||||
"tokens_output": 50,
|
||||
"tokens_reasoning": 30,
|
||||
"tokens_cache_read": 10,
|
||||
"tokens_cache_write": 5,
|
||||
"cost": 0.02,
|
||||
},
|
||||
)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(orch, "_opencode_db_path", lambda _aid: str(db))
|
||||
|
||||
# reasoning (30) folded into output (50) → 80; bills at the output rate.
|
||||
assert orch._grok_usage_from_opencode("be-dev-1") == (100, 80, 10, 5)
|
||||
|
||||
|
||||
def test_grok_usage_zero_when_store_missing(
|
||||
def test_grok_usage_folds_total_into_output(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
usage = tmp_path / "usage.json"
|
||||
_write_usage(usage, total_tokens=180, cost_usd=0.02)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
orch, "_opencode_db_path", lambda _aid: str(tmp_path / "absent.db")
|
||||
orch, "_grok_usage_json", lambda _aid: json.loads(usage.read_text())
|
||||
)
|
||||
assert orch._grok_usage_from_opencode("be-dev-1") == (0, 0, 0, 0)
|
||||
|
||||
# The whole total folds into output (no input/output split from the CLI).
|
||||
assert orch._grok_usage_tokens("be-dev-1") == (0, 180, 0, 0)
|
||||
|
||||
|
||||
def test_grok_usage_zero_when_store_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(orch, "_grok_usage_json", lambda _aid: None)
|
||||
assert orch._grok_usage_tokens("be-dev-1") == (0, 0, 0, 0)
|
||||
|
||||
|
||||
def test_grok_cost_read_from_usage_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured_cost = 3.25
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
orch,
|
||||
"_grok_usage_json",
|
||||
lambda _aid: {"cost_usd": captured_cost, "total_tokens": 9},
|
||||
)
|
||||
assert orch._grok_cost_usd("be-dev-1") == captured_cost
|
||||
monkeypatch.setattr(orch, "_grok_usage_json", lambda _aid: None)
|
||||
assert orch._grok_cost_usd("be-dev-1") == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_final_usage_routes_grok_to_opencode(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
async def test_resolve_final_usage_routes_grok_to_usage_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
_make_db(
|
||||
db,
|
||||
{
|
||||
"tokens_input": 7,
|
||||
"tokens_output": 3,
|
||||
"tokens_reasoning": 2,
|
||||
"tokens_cache_read": 0,
|
||||
"tokens_cache_write": 0,
|
||||
"cost": 0.01,
|
||||
},
|
||||
)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
monkeypatch.setattr(orch, "_opencode_db_path", lambda _aid: str(db))
|
||||
monkeypatch.setattr(
|
||||
orch, "_grok_usage_json", lambda _aid: {"total_tokens": 12, "cost_usd": 0.01}
|
||||
)
|
||||
cfg = type("C", (), {"provider_type": "grok"})()
|
||||
orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)}
|
||||
|
||||
# No SDK fetch / transcript read for GROK — usage comes from opencode.db.
|
||||
assert await orch._resolve_final_token_usage("be-dev-1") == (7, 5, 0, 0)
|
||||
# No SDK fetch / transcript read for GROK — usage comes from usage.json.
|
||||
assert await orch._resolve_final_token_usage("be-dev-1") == (0, 12, 0, 0)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Interactive intake/secretary builders fork a GROK route onto opencode.
|
||||
"""Interactive intake/secretary builders fork a GROK route onto the grok CLI.
|
||||
|
||||
A GROK route swaps the Claude SDK-driver image for the opencode-serve image and
|
||||
the ANTHROPIC_* env for XAI_* + the opencode store mount; every other
|
||||
provider keeps the Claude path's ANTHROPIC_* behaviour.
|
||||
A GROK route swaps the Claude SDK-driver image for the grok-CLI prompter/secretary
|
||||
image and the ANTHROPIC_* env for the subscription auth mount + the per-agent
|
||||
usage mount (no metered xAI key, no permission env — the driver computes the grok
|
||||
permission flags). Every other provider keeps the Claude path's ANTHROPIC_*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.llm.providers import grok as grok_provider
|
||||
from roboco.runtime.orchestrator import (
|
||||
GROK_PROMPTER_IMAGE,
|
||||
GROK_SECRETARY_IMAGE,
|
||||
@@ -15,20 +19,21 @@ from roboco.runtime.orchestrator import (
|
||||
_SecretaryRunSpec,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_HOSTS: dict[str, str | None] = {
|
||||
"claude": "/h/.claude",
|
||||
"prompt": "/h/p.md",
|
||||
"workspaces": "/h/ws",
|
||||
"opencode": "/h/oc/intake-1",
|
||||
"grok_usage": "/h/gu/intake-1",
|
||||
}
|
||||
|
||||
|
||||
def _intake_spec(
|
||||
provider_type: str,
|
||||
*,
|
||||
base_url: str | None,
|
||||
token: str | None,
|
||||
grok_variant: str | None = None,
|
||||
provider_type: str, *, base_url: str | None, token: str | None
|
||||
) -> _IntakeRunSpec:
|
||||
return _IntakeRunSpec(
|
||||
container_name="roboco-agent-intake-1",
|
||||
@@ -38,57 +43,46 @@ def _intake_spec(
|
||||
hosts=_HOSTS,
|
||||
session_id="sess-1",
|
||||
cwd="/data/workspace",
|
||||
cli_model="grok-build-0.1",
|
||||
cli_model="grok-build",
|
||||
api_url="http://roboco-orchestrator:8000",
|
||||
provider_base_url=base_url,
|
||||
provider_auth_token=token,
|
||||
provider_type=provider_type,
|
||||
model="grok-build-0.1",
|
||||
grok_variant=grok_variant,
|
||||
model="grok-build",
|
||||
)
|
||||
|
||||
|
||||
def test_intake_grok_uses_xai_env_and_opencode_mount() -> None:
|
||||
cmd = AgentOrchestrator._build_intake_run_cmd(
|
||||
_intake_spec(
|
||||
"grok",
|
||||
base_url="https://api.x.ai/v1",
|
||||
token="xai-key",
|
||||
grok_variant="minimal",
|
||||
)
|
||||
)
|
||||
assert "XAI_API_KEY=xai-key" in cmd
|
||||
assert "XAI_BASE_URL=https://api.x.ai/v1" 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
|
||||
# Per-role reasoning effort reaches the container for the serve driver.
|
||||
assert "ROBOCO_GROK_VARIANT=minimal" 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)
|
||||
# Intake is read-only (no code edits, no shell) but reads sibling product
|
||||
# repos OUTSIDE its cwd, so it keeps external-directory reads.
|
||||
assert "ROBOCO_GROK_EDIT_PERMISSION=deny" in cmd
|
||||
assert "ROBOCO_GROK_BASH_PERMISSION=deny" in cmd
|
||||
assert "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=allow" in cmd
|
||||
|
||||
|
||||
def test_intake_anthropic_omits_grok_permission_env() -> None:
|
||||
# The opencode permission env is a GROK-only contract; the Claude path never
|
||||
# sets it (it gates tools via the SDK can_use_tool allowlist instead).
|
||||
cmd = AgentOrchestrator._build_intake_run_cmd(
|
||||
_intake_spec("anthropic", base_url="https://api.anthropic.com", token="sk-ant")
|
||||
)
|
||||
assert not any(c.startswith("ROBOCO_GROK_EDIT_PERMISSION=") for c in cmd)
|
||||
assert not any(c.startswith("ROBOCO_GROK_BASH_PERMISSION=") for c in cmd)
|
||||
|
||||
|
||||
def test_intake_grok_omits_variant_when_unset() -> None:
|
||||
def test_intake_grok_uses_grok_cli_usage_mount_and_env() -> None:
|
||||
cmd = AgentOrchestrator._build_intake_run_cmd(
|
||||
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
|
||||
)
|
||||
assert not any(c.startswith("ROBOCO_GROK_VARIANT=") for c in cmd)
|
||||
# The per-agent usage dir is mounted so finalize reads usage.json back.
|
||||
assert "/h/gu/intake-1:/home/agent/.grok-usage" in cmd
|
||||
assert "ROBOCO_AGENT_MODEL=grok-build" in cmd
|
||||
assert "ROBOCO_GROK_USAGE_FILE=/home/agent/.grok-usage/usage.json" in cmd
|
||||
assert cmd[-1] == GROK_PROMPTER_IMAGE
|
||||
# No metered xAI key, no Anthropic mislabelling, no stale opencode contract.
|
||||
assert not any(c.startswith("XAI_") for c in cmd)
|
||||
assert not any(c.startswith("ANTHROPIC_") for c in cmd)
|
||||
assert not any(c.startswith("ROBOCO_GROK_VARIANT") for c in cmd)
|
||||
assert not any(c.startswith("ROBOCO_GROK_EDIT_PERMISSION") for c in cmd)
|
||||
assert "/home/agent/.local/share/opencode" not in " ".join(cmd)
|
||||
|
||||
|
||||
def test_intake_grok_mounts_subscription_auth_when_present(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# The auth mount is .exists()-guarded; point the host dir at a tmp ~/.grok
|
||||
# holding an auth.json so the mount is emitted.
|
||||
grok_dir = tmp_path / ".grok"
|
||||
grok_dir.mkdir()
|
||||
(grok_dir / "auth.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(grok_provider, "GROK_AUTH_HOST_PATH", str(grok_dir))
|
||||
|
||||
cmd = AgentOrchestrator._build_intake_run_cmd(
|
||||
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
|
||||
)
|
||||
assert f"{grok_dir / 'auth.json'}:/home/agent/.grok/auth.json:ro" in cmd
|
||||
|
||||
|
||||
def test_intake_anthropic_keeps_anthropic_env() -> None:
|
||||
@@ -98,34 +92,35 @@ def test_intake_anthropic_keeps_anthropic_env() -> None:
|
||||
assert "ANTHROPIC_BASE_URL=https://api.anthropic.com" in cmd
|
||||
assert "ANTHROPIC_AUTH_TOKEN=sk-ant" in cmd
|
||||
assert not any(c.startswith("XAI_") for c in cmd)
|
||||
assert not any(c.startswith("ROBOCO_GROK_USAGE_FILE") for c in cmd)
|
||||
assert cmd[-1] == "roboco-agent-prompter"
|
||||
|
||||
|
||||
def test_secretary_grok_uses_openai_env_and_grok_image() -> None:
|
||||
def test_secretary_grok_uses_grok_cli_env_and_keeps_hmac() -> 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"},
|
||||
hosts={
|
||||
"claude": "/h/.claude",
|
||||
"prompt": "/h/p.md",
|
||||
"grok_usage": "/h/gu/sec-1",
|
||||
},
|
||||
session_id="sess-2",
|
||||
cwd="/app",
|
||||
cli_model="grok-build-0.1",
|
||||
cli_model="grok-build",
|
||||
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",
|
||||
model="grok-build",
|
||||
)
|
||||
cmd = AgentOrchestrator._build_secretary_run_cmd(spec)
|
||||
assert "XAI_API_KEY=xai-key" in cmd
|
||||
assert "/h/oc/sec-1:/home/agent/.local/share/opencode" in cmd
|
||||
assert "/h/gu/sec-1:/home/agent/.grok-usage" in cmd
|
||||
assert "ROBOCO_AGENT_MODEL=grok-build" 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("XAI_") for c in cmd)
|
||||
assert not any(c.startswith("ANTHROPIC_") for c in cmd)
|
||||
# The Secretary is read-only and reads only /app + the API, so edit/bash
|
||||
# are denied and it gets NO external-directory reads (unlike intake).
|
||||
assert "ROBOCO_GROK_EDIT_PERMISSION=deny" in cmd
|
||||
assert "ROBOCO_GROK_BASH_PERMISSION=deny" in cmd
|
||||
assert "ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=deny" in cmd
|
||||
|
||||
@@ -153,7 +153,7 @@ async def test_reaper_kills_and_releases_wedged_grok_container(
|
||||
) -> None:
|
||||
"""A GROK container idle past the kill TTL is killed, evicted, and released.
|
||||
|
||||
Unlike a Claude agent, a wedged opencode container is ACTIVE yet fires no
|
||||
Unlike a Claude agent, a wedged grok container is ACTIVE yet fires no
|
||||
verb, so the live-instance skip would shield it forever. Past the longer
|
||||
grok-idle TTL the watchdog removes the container and drops it from
|
||||
`_instances`, so the same reap pass then unclaims the task.
|
||||
|
||||
Reference in New Issue
Block a user