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:
+21
-14
@@ -86,26 +86,33 @@ ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||
# =============================================================================
|
||||
# Grok (xAI) Provider — optional
|
||||
# =============================================================================
|
||||
# RoboCo can run agents on grok-build-0.1 (xAI) via the opencode runtime instead
|
||||
# of Claude Code. The xAI API key is NOT set here — store it encrypted per
|
||||
# project from the panel (provider key), the same as the Ollama/Anthropic keys.
|
||||
# Every var below is optional; defaults shown.
|
||||
# RoboCo can run agents on Grok Build (xAI) via xAI's official `grok` CLI on the
|
||||
# SuperGrok subscription, instead of Claude Code. No metered xAI API key is used:
|
||||
# the CLI authenticates from a mounted ~/.grok/auth.json — run `grok login` once
|
||||
# on the host (auth.json auto-refreshes). Every var below is optional.
|
||||
|
||||
# Image the orchestrator spawns for Grok agents.
|
||||
# Host dir holding the SuperGrok auth. The orchestrator mounts <dir>/auth.json
|
||||
# read-only into each Grok agent's ~/.grok. GROK_AUTH_DIR is the host source the
|
||||
# compose mounts into the orchestrator; keep both equal to the host's ~/.grok.
|
||||
# ROBOCO_HOST_GROK_DIR=/home/youruser/.grok
|
||||
# GROK_AUTH_DIR=/home/youruser/.grok
|
||||
|
||||
# Image the orchestrator spawns for Grok agents, and the CLI model id.
|
||||
# ROBOCO_GROK_AGENT_IMAGE=roboco-agent-grok:latest
|
||||
# ROBOCO_GROK_CLI_MODEL=grok-build
|
||||
|
||||
# opencode tool permissions for Grok agents: allow | ask | deny. Defaults are
|
||||
# "allow"; tighten bash to "deny"/"ask" to fail closed on untrusted repos (the
|
||||
# secret-scrub plugin is a denylist, not a full sandbox).
|
||||
# ROBOCO_GROK_BASH_PERMISSION=allow
|
||||
# ROBOCO_GROK_EDIT_PERMISSION=allow
|
||||
# ROBOCO_GROK_EXTERNAL_DIR_PERMISSION=allow
|
||||
# Per-role tool permissions are computed as native grok flags (subagents off;
|
||||
# edit/shell removed for non-coding roles; raw git mutation + rm -rf denied for
|
||||
# coding roles) — there is nothing to set here.
|
||||
|
||||
# Force one reasoning effort for ALL Grok agents: minimal | high | max (or empty
|
||||
# for opencode's default). Empty = per-role: coordination/docs roles request
|
||||
# "minimal" to cut reasoning cost, code roles keep full reasoning.
|
||||
# Force one reasoning effort for ALL Grok agents: low | medium | high | xhigh |
|
||||
# max (or empty for the per-role default — coordination/docs/board roles request
|
||||
# "low" to cut reasoning cost, code roles keep full reasoning).
|
||||
# ROBOCO_GROK_REASONING_EFFORT=
|
||||
|
||||
# Hard ceiling on agentic turns per run (loop guard).
|
||||
# ROBOCO_GROK_MAX_TURNS=200
|
||||
|
||||
# Kill a Grok agent container after this many seconds idle (no model call /
|
||||
# stream) to reclaim a wedged one. Minimum 120.
|
||||
# ROBOCO_GROK_IDLE_KILL_SECONDS=900
|
||||
|
||||
@@ -209,6 +209,9 @@ services:
|
||||
# absolute paths on the host. Default to this compose project's ./data.
|
||||
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/opt/roboco}
|
||||
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-${HOME}/.claude}
|
||||
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
|
||||
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
|
||||
ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}
|
||||
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/opt/roboco/data}
|
||||
# Reachable base URL for commit-trailer links — set to your host's LAN
|
||||
# address or domain so the links in commit bodies resolve.
|
||||
@@ -237,12 +240,16 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${CLAUDE_AUTH_DIR:-${HOME}/.claude}:/root/.claude
|
||||
# SuperGrok auth — mount host ~/.grok at the SAME host path the orchestrator
|
||||
# hands each Grok agent's `-v`, so its auth.json exists() check passes here
|
||||
# AND the agent bind resolves on the host. `grok login` on the host. RO.
|
||||
- ${GROK_AUTH_DIR:-${HOME}/.grok}:${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}:ro
|
||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
||||
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
||||
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
|
||||
# Per-agent opencode stores (GROK usage/cost capture).
|
||||
- ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
|
||||
# Per-agent GROK usage capture (usage.json -> finalizer).
|
||||
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
|
||||
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
|
||||
- ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings
|
||||
- ${ROBOCO_DATA_DIR:-./data}/manifests:/app/manifests
|
||||
|
||||
+14
-6
@@ -230,7 +230,7 @@ services:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
|
||||
# Agent Grok Image Builder (xAI Grok Build via the official grok CLI)
|
||||
# ==========================================================================
|
||||
agent-grok-image:
|
||||
build:
|
||||
@@ -242,8 +242,8 @@ services:
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# Interactive Grok roles (intake/secretary) — opencode-serve sessions; built
|
||||
# FROM roboco-agent-grok, so they depend on the Grok runtime image.
|
||||
# Interactive Grok roles (intake/secretary) — panel-driven grok-CLI sessions;
|
||||
# built FROM roboco-agent-grok, so they depend on the Grok runtime image.
|
||||
agent-grok-prompter-image:
|
||||
build:
|
||||
context: .
|
||||
@@ -306,6 +306,9 @@ services:
|
||||
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
|
||||
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
|
||||
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
|
||||
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
|
||||
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
|
||||
ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
|
||||
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
|
||||
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
|
||||
# unusable links in commit message bodies; set to NAS LAN IP so
|
||||
@@ -344,6 +347,11 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Claude Code auth - mount your ~/.claude directory
|
||||
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||
# SuperGrok auth — mount the host ~/.grok at the SAME host path the
|
||||
# orchestrator passes to each Grok agent's `-v`, so its auth.json exists()
|
||||
# check passes here AND the agent bind resolves on the host. `grok login`
|
||||
# on the host writes auth.json (auto-refreshing). Read-only.
|
||||
- ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
||||
# Shared config directory for MCP configs (writable)
|
||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||
# Generated prompts directory - composed at runtime from layers
|
||||
@@ -352,9 +360,9 @@ services:
|
||||
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
||||
# Agent workspaces (git clones) - persisted across restarts
|
||||
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
|
||||
# Per-agent opencode stores (GROK usage/cost capture): each Grok agent
|
||||
# writes opencode.db under <agent_id>/; the finalizer reads it back here.
|
||||
- ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
|
||||
# Per-agent GROK usage capture: each Grok agent writes usage.json under
|
||||
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
|
||||
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
|
||||
# Persistent logs — survive `docker compose down/up`. Orchestrator and
|
||||
# each spawned agent write structured logs here so we can audit past
|
||||
# runs instead of relying on ephemeral `docker logs`.
|
||||
|
||||
+14
-6
@@ -230,7 +230,7 @@ services:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
|
||||
# Agent Grok Image Builder (xAI Grok Build via the official grok CLI)
|
||||
# ==========================================================================
|
||||
agent-grok-image:
|
||||
build:
|
||||
@@ -242,8 +242,8 @@ services:
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# Interactive Grok roles (intake/secretary) — opencode-serve sessions; built
|
||||
# FROM roboco-agent-grok, so they depend on the Grok runtime image.
|
||||
# Interactive Grok roles (intake/secretary) — panel-driven grok-CLI sessions;
|
||||
# built FROM roboco-agent-grok, so they depend on the Grok runtime image.
|
||||
agent-grok-prompter-image:
|
||||
build:
|
||||
context: .
|
||||
@@ -306,6 +306,9 @@ services:
|
||||
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
|
||||
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
|
||||
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
|
||||
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
|
||||
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
|
||||
ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
|
||||
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
|
||||
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
|
||||
# unusable links in commit message bodies; set to NAS LAN IP so
|
||||
@@ -344,6 +347,11 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Claude Code auth - mount your ~/.claude directory
|
||||
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||
# SuperGrok auth — mount the host ~/.grok at the SAME host path the
|
||||
# orchestrator passes to each Grok agent's `-v`, so its auth.json exists()
|
||||
# check passes here AND the agent bind resolves on the host. `grok login`
|
||||
# on the host writes auth.json (auto-refreshing). Read-only.
|
||||
- ${GROK_AUTH_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:ro
|
||||
# Shared config directory for MCP configs (writable)
|
||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||
# Generated prompts directory - composed at runtime from layers
|
||||
@@ -352,9 +360,9 @@ services:
|
||||
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
||||
# Agent workspaces (git clones) - persisted across restarts
|
||||
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
|
||||
# Per-agent opencode stores (GROK usage/cost capture): each Grok agent
|
||||
# writes opencode.db under <agent_id>/; the finalizer reads it back here.
|
||||
- ${ROBOCO_DATA_DIR:-./data}/opencode:/data/opencode
|
||||
# Per-agent GROK usage capture: each Grok agent writes usage.json under
|
||||
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
|
||||
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
|
||||
# Persistent logs — survive `docker compose down/up`. Orchestrator and
|
||||
# each spawned agent write structured logs here so we can audit past
|
||||
# runs instead of relying on ephemeral `docker logs`.
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
# GROK Intake (Prompter) Agent — interactive opencode-serve session on Grok.
|
||||
# GROK Intake (Prompter) Agent — interactive grok-CLI session on Grok.
|
||||
# =============================================================================
|
||||
# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a
|
||||
# single `opencode run` that exits), this holds a PERSISTENT `opencode serve`
|
||||
# session open, receives the human's messages over HTTP (POST /turn on :9000),
|
||||
# and streams each reply back to the panel via the relay. Builds on the Grok
|
||||
# runtime image (opencode + @ai-sdk/openai + the secret-scrub plugin); the
|
||||
# driver renders opencode.json from the spawn env, then drives the session.
|
||||
# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a single
|
||||
# `grok -p` that exits), this holds a PERSISTENT conversation: it receives the
|
||||
# human's messages over HTTP (POST /turn on :9000) and, per turn, runs a headless
|
||||
# `grok -p` that resumes one session id, streaming each reply back to the panel
|
||||
# via the relay (see roboco.agent_sdk.grok_intake_main + grok_cli_session). The
|
||||
# intake `propose_draft` tool is wired as the roboco-intake MCP server (rendered
|
||||
# into ~/.grok/config.toml by the driver). Builds on the Grok runtime image
|
||||
# (grok CLI + the roboco venv).
|
||||
# =============================================================================
|
||||
|
||||
FROM roboco-agent-grok
|
||||
|
||||
USER root
|
||||
|
||||
# The intake propose_draft tool plugin (the model calls it; the driver turns the
|
||||
# call into the panel's draft card), baked into the auto-discovery dir so only
|
||||
# the intake image carries it. opencode registers tools from this directory, not
|
||||
# from a config `plugin:`-array path (verified live).
|
||||
COPY docker/grok/intake-tools.js /home/agent/.config/opencode/plugin/intake-tools.js
|
||||
RUN chown agent:agent /home/agent/.config/opencode/plugin/intake-tools.js
|
||||
|
||||
USER agent
|
||||
|
||||
LABEL role="grok-prompter"
|
||||
LABEL description="Intake interviewer on Grok — a long-lived opencode serve session driven by the panel"
|
||||
LABEL description="Intake interviewer on Grok — a panel-driven grok-CLI conversation"
|
||||
|
||||
# The in-container receiver the orchestrator delivers the human's turns to.
|
||||
EXPOSE 9000
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
# GROK Secretary Agent — interactive opencode-serve session on Grok.
|
||||
# GROK Secretary Agent — interactive grok-CLI session on Grok.
|
||||
# =============================================================================
|
||||
# The Grok analogue of agent-secretary. Holds a PERSISTENT `opencode serve`
|
||||
# session open, receives the CEO's messages over HTTP (POST /turn on :9000), and
|
||||
# streams each reply back to the panel via the relay. The Secretary's CEO-
|
||||
# authority tools (read_company_state / read_task / submit_directive) are
|
||||
# registered as opencode tools by the secretary-tools.js plugin, which calls
|
||||
# /api/secretary/* with the container's HMAC agent token. Builds on the Grok
|
||||
# runtime image; the driver renders opencode.json from the spawn env first.
|
||||
# The Grok analogue of agent-secretary. Holds a PERSISTENT conversation: receives
|
||||
# the CEO's messages over HTTP (POST /turn on :9000) and, per turn, runs a
|
||||
# headless `grok -p` that resumes one session id, streaming each reply back to the
|
||||
# panel via the relay. The Secretary's CEO-authority tools (read_company_state /
|
||||
# read_task / submit_directive) are wired as the roboco-secretary MCP server
|
||||
# (rendered into ~/.grok/config.toml by the driver), which calls /api/secretary/*
|
||||
# with the container's HMAC agent token. Builds on the Grok runtime image
|
||||
# (grok CLI + the roboco venv).
|
||||
# =============================================================================
|
||||
|
||||
FROM roboco-agent-grok
|
||||
|
||||
USER root
|
||||
|
||||
# The CEO-authority tool plugin (read_company_state / read_task / submit_directive),
|
||||
# baked into the auto-discovery dir so ONLY the Secretary image carries it (no
|
||||
# other role gets CEO authority). opencode registers it from this directory; a
|
||||
# config `plugin:`-array path would not register its tools (verified live).
|
||||
COPY docker/grok/secretary-tools.js /home/agent/.config/opencode/plugin/secretary-tools.js
|
||||
RUN chown agent:agent /home/agent/.config/opencode/plugin/secretary-tools.js
|
||||
|
||||
USER agent
|
||||
|
||||
LABEL role="grok-secretary"
|
||||
LABEL description="Secretary on Grok — a long-lived opencode serve session driven by the panel"
|
||||
LABEL description="Secretary on Grok — a panel-driven grok-CLI conversation"
|
||||
|
||||
# The in-container receiver the orchestrator delivers the CEO's turns to.
|
||||
EXPOSE 9000
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// opencode plugin — budget / loop / terminal feed for one-shot RoboCo Grok agents.
|
||||
//
|
||||
// Ports the Claude PostToolUse budget hook (docker/scripts/post-tool-budget-hook.sh)
|
||||
// and the Stop hook's terminal-tool tracking (docker/scripts/stop-hook.sh) to
|
||||
// opencode's tool.execute.{before,after}. The in-container SDK server
|
||||
// (roboco.agent_sdk.server, :9000) is the same long-lived process the Claude
|
||||
// path runs — the grok entrypoint starts it before `opencode run`. The flow/do
|
||||
// MCP servers already POST /verb/attempted to it for the per-verb circuit
|
||||
// breaker, so starting it + this feed restores the budget/loop/terminal cluster
|
||||
// on Grok (Claude-parity, the CEO's "create what's missing" call).
|
||||
//
|
||||
// before: read /budget/status and DENY (throw) on a hard halt, or a loop with
|
||||
// loop_action=halt. opencode has no PostToolUse-deny, so the pre-exec
|
||||
// gate is the only place to stop a runaway one-shot run from burning
|
||||
// the cost cap mid-turn. The loop trips one call later than Claude
|
||||
// (record is in `after`) but still halts the burn.
|
||||
// after: record the executed tool on /terminal/tool_recorded (so a graceful
|
||||
// terminal verb is recognized) and /budget/tool_called (advances the
|
||||
// breaker/loop counters and feeds the post-exit post-mortem).
|
||||
//
|
||||
// Fail policy: the `after` POSTs never block (recording can't risk spend). The
|
||||
// `before` gate fails OPEN by default, but fails CLOSED when ROBOCO_BUDGET_ENFORCE=1
|
||||
// (set by the one-shot entrypoint, which always starts the SDK budget server) and
|
||||
// the budget server is unreachable — an unenforceable cost cap on a task agent is
|
||||
// the one case worth halting for. Interactive serve images (intake / secretary)
|
||||
// own :9000 for the human-turn receiver, run NO SDK budget server, and set no
|
||||
// ENFORCE flag, so their tool calls always proceed (these POSTs 404 there).
|
||||
|
||||
const SDK_URL = process.env.ROBOCO_SDK_URL || "http://localhost:9000";
|
||||
|
||||
async function sdk(method, path, body) {
|
||||
try {
|
||||
const res = await fetch(`${SDK_URL}${path}`, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null; // fail-open — never block the agent on SDK reachability
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical, dependency-free serialization (sorted keys) so the SDK's loop
|
||||
// detector sees identical (tool, args) calls as identical. Need NOT match the
|
||||
// Claude hook's sha256 — only be stable within one session.
|
||||
function canonical(value) {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
|
||||
const keys = Object.keys(value).sort();
|
||||
return (
|
||||
"{" +
|
||||
keys.map((k) => JSON.stringify(k) + ":" + canonical(value[k])).join(",") +
|
||||
"}"
|
||||
);
|
||||
}
|
||||
|
||||
function argsHash(args) {
|
||||
const s = canonical(args ?? {});
|
||||
let h = 0x811c9dc5; // FNV-1a, 32-bit
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
// opencode namespaces an MCP tool as "<server>_<verb>" (or "<server>.<verb>");
|
||||
// the Claude path's verbs arrive bare. Strip a known roboco-* server prefix so
|
||||
// the SDK recognizes a terminal verb (i_am_idle / i_am_done / ...) — the SDK's
|
||||
// own "__"-split is a no-op on the already-bare verb this returns.
|
||||
// Verified live: opencode delivers MCP tools as "roboco-flow_<verb>" (underscore);
|
||||
// the "." form and an mcp__ prefix are still handled defensively.
|
||||
function bareVerb(tool) {
|
||||
const mcp = tool.match(/^mcp__[a-z0-9-]+__(.+)$/);
|
||||
if (mcp) return mcp[1];
|
||||
const pref = tool.match(/^roboco-[a-z-]+[_.](.+)$/);
|
||||
if (pref) return pref[1];
|
||||
return tool;
|
||||
}
|
||||
|
||||
// Release/escape verbs must ALWAYS be allowed through the before-gate. A halt
|
||||
// (budget, loop, or fail-closed) that also blocks these traps the agent: it can
|
||||
// neither continue nor stop, so it flails — and every blocked retry is another
|
||||
// billed model turn. Letting i_am_idle / unclaim / i_am_blocked through is the
|
||||
// only way a halted agent can exit cleanly.
|
||||
const RELEASE_VERBS = new Set(["i_am_idle", "unclaim", "i_am_blocked"]);
|
||||
|
||||
// Named export (opencode's plugin convention) + baked into the plugin
|
||||
// auto-discovery dir (~/.config/opencode/plugin/) at image build — the simplest
|
||||
// registration route (no config `plugin:` path needed).
|
||||
export const RobocoBudgetFeed = async () => {
|
||||
return {
|
||||
"tool.execute.before": async (input) => {
|
||||
// Escape hatches always pass — a halted agent must be able to stop.
|
||||
if (RELEASE_VERBS.has(bareVerb(String(input?.tool || "")))) return;
|
||||
const status = await sdk("GET", "/budget/status", null);
|
||||
if (!status) {
|
||||
// One-shot delivery agents MUST have the in-container SDK budget server
|
||||
// (the entrypoint starts it and exports ENFORCE=1). A missing signal
|
||||
// there means the cost cap is unenforceable — fail CLOSED to stop an
|
||||
// uncapped burn. Interactive serve agents set no flag → fail open.
|
||||
if (process.env.ROBOCO_BUDGET_ENFORCE === "1") {
|
||||
throw new Error(
|
||||
"[Halt] budget server unreachable — failing closed to prevent " +
|
||||
"uncapped token spend. Stop now with i_am_idle() or unclaim().",
|
||||
);
|
||||
}
|
||||
return; // fail-open (no budget server expected for this role)
|
||||
}
|
||||
if (status.halt) {
|
||||
throw new Error(
|
||||
`[Halt] tool budget exhausted (${status.total}/${status.halt_threshold}). ` +
|
||||
"Stop now — release the task with unclaim() or i_am_idle().",
|
||||
);
|
||||
}
|
||||
if (status.loop && status.loop_action === "halt") {
|
||||
throw new Error(
|
||||
"[Loop] same tool+args repeated in window — halting. " +
|
||||
"Release the task with unclaim() or stop with i_am_idle().",
|
||||
);
|
||||
}
|
||||
},
|
||||
"tool.execute.after": async (input) => {
|
||||
const raw = String(input?.tool || "");
|
||||
if (!raw) return;
|
||||
await sdk("POST", "/terminal/tool_recorded", { tool: bareVerb(raw) });
|
||||
await sdk("POST", "/budget/tool_called", {
|
||||
tool: bareVerb(raw),
|
||||
args_hash: argsHash(input?.args),
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
// opencode plugin — the Intake interviewer's propose_draft tool, on Grok.
|
||||
//
|
||||
// The model calls propose_draft once the task spec is ready; this delivers the
|
||||
// draft to the panel's reviewable draft card.
|
||||
//
|
||||
// WHY IT POSTS DIRECTLY (not via the driver): opencode's synchronous serve reply
|
||||
// (POST /session/:id/message) returns only the final assistant text + step
|
||||
// markers — NOT the tool-CALL parts. So OpencodeServeSession cannot intercept
|
||||
// this call to emit a `draft` chunk (verified live: a propose_draft call comes
|
||||
// back as parts=[step-start, text, step-finish], no tool part). Instead the tool
|
||||
// POSTs the draft straight to the prompter-live relay — the same
|
||||
// /api/prompter/live/{session}/events endpoint the driver's relay sink uses — so
|
||||
// the panel renders the card regardless. (The Claude intake path differs: the
|
||||
// Claude SDK DOES expose the tool-use block, so its driver intercepts it.)
|
||||
//
|
||||
// Loaded from the plugin auto-discovery dir (~/.config/opencode/plugin/), baked
|
||||
// into the grok-prompter image only (the one-shot delivery roles never draft).
|
||||
// The container provides ROBOCO_API_URL + ROBOCO_PROMPTER_SESSION_ID.
|
||||
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const API_BASE = (
|
||||
process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000"
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
export const RobocoIntakeTools = async () => ({
|
||||
tool: {
|
||||
propose_draft: tool({
|
||||
description:
|
||||
"Submit the finished task draft for the human to review and confirm. " +
|
||||
"Call this once the spec is complete. Pass a JSON object: title, " +
|
||||
"objective, what_this_builds[], the_work[] ({team, summary, items}), " +
|
||||
"notes[], acceptance_criteria[], team, scale, task_type, nature, " +
|
||||
"estimated_complexity, priority.",
|
||||
args: {
|
||||
draft: tool.schema
|
||||
.record(tool.schema.string(), tool.schema.any())
|
||||
.describe("The task draft object"),
|
||||
},
|
||||
async execute(args) {
|
||||
const session = process.env.ROBOCO_PROMPTER_SESSION_ID || "";
|
||||
if (!session) {
|
||||
return "No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot surface the draft.";
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/prompter/live/${encodeURIComponent(session)}/events`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: "draft",
|
||||
text: "",
|
||||
tool: "propose_draft",
|
||||
data: args.draft || {},
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return `Draft relay returned HTTP ${res.status}; the human may not see the card.`;
|
||||
}
|
||||
} catch (e) {
|
||||
return "Could not submit the draft to the panel: " + String(e);
|
||||
}
|
||||
return "Draft submitted — the human can review it in the panel.";
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
// opencode plugin — command guard / secret-scrub for RoboCo Grok agents.
|
||||
//
|
||||
// Ports the security-critical deny rules from docker/scripts/bash-guard-hook.sh
|
||||
// (the Claude Code PreToolUse guard) to opencode's `tool.execute.before` hook.
|
||||
// Those rules are Claude Code hooks and do NOT transfer to the opencode runtime,
|
||||
// so a Grok agent would otherwise run bash unguarded — this restores parity.
|
||||
//
|
||||
// Mechanism (confirmed by opencode's own env-protection plugin example):
|
||||
// throwing inside `tool.execute.before` denies the tool call. For `bash` the
|
||||
// command is `output.args.command`; for `read`/`edit` the path is
|
||||
// `output.args.filePath`.
|
||||
//
|
||||
// Baked into the plugin auto-discovery dir (~/.config/opencode/plugin/) at image
|
||||
// build (named export, opencode's convention) — the same route as budget-feed.
|
||||
// The agent's bash permission is a second gate via ROBOCO_GROK_BASH_PERMISSION.
|
||||
//
|
||||
// STATUS: the plugin loads in the live runtime (same auto-discovery dir as the
|
||||
// live-confirmed budget-feed), but the deny-on-match path has not yet blocked a
|
||||
// real command on the NAS — confirm before trusting it as the sole bash gate.
|
||||
// Deny-on-match is fail-closed: a false positive blocks a legitimate command
|
||||
// (annoying, safe) rather than letting a dangerous one through.
|
||||
|
||||
const CREDENTIAL_FILE =
|
||||
/(\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519|id_ecdsa|known_hosts)/;
|
||||
|
||||
// Secret-bearing files for the source / encode / interpreter rules. Wider than
|
||||
// CREDENTIAL_FILE (which also gates Read/Edit *paths*, so it must NOT include
|
||||
// .env lest it block reading .env.example): a bash command that READS these is
|
||||
// exfiltration. Mirrors the file set in bash-guard-hook.sh's source/interpreter
|
||||
// rules.
|
||||
const SECRET_FILE =
|
||||
/(\.env\b|\/etc\/environment|\.git-credentials|\.netrc|\.git\/config|\.gitconfig|\/proc\/[^\s]*environ|\.profile|\.bashrc|\.zshrc|id_rsa|id_ed25519|id_ecdsa|\.ssh\/)/;
|
||||
|
||||
// git network/auth/branch-mutating ops — run against the SKELETONIZED command
|
||||
// (see gitSkeleton) so a heredoc/echo that merely documents `git push` is not
|
||||
// mistaken for invoking it. Mirrors bash-guard-hook.sh's git-ops rule.
|
||||
const GIT_OPS =
|
||||
/(^|[\s;&|])git\s+(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag\s+-d|update-ref|reflog\s+delete)/;
|
||||
|
||||
const INTERNAL_HOST =
|
||||
/((https?|wss?):\/\/)?\/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]/;
|
||||
|
||||
const HTTP_CLIENT_LIB =
|
||||
/(httpx|requests|urllib|aiohttp|http\.client|httplib|net\/http|net::http|node-fetch|axios|xmlhttprequest|websocket|fetch\s*\()/;
|
||||
|
||||
// Each check takes the lowercased bash command and returns a deny reason, or
|
||||
// null to allow. Mirrors the categories in bash-guard-hook.sh.
|
||||
const BASH_CHECKS = [
|
||||
// (git-ops is checked first in denyBash, on the skeletonized command.)
|
||||
(low) =>
|
||||
CREDENTIAL_FILE.test(low)
|
||||
? "command references a credential file or SSH key — the PAT is injected subprocess-side by the MCP layer, never read from these files."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(source|\.)\s+[^|;&]*/.test(low) && SECRET_FILE.test(low)
|
||||
? "sourcing a credential-bearing file (.env / .git-credentials / .netrc / ...) exposes secrets in the current shell."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(python3?|perl|node|ruby|awk|sed)\s+[^|;&]*-[ce]\s/.test(low) &&
|
||||
SECRET_FILE.test(low)
|
||||
? "interpreter one-liner reads a credential file — ask for the value you need via the task description."
|
||||
: null,
|
||||
(low) =>
|
||||
/\/proc\/(self|\d+|\$\$)\/(environ|cmdline|cwd|exe)/.test(low)
|
||||
? "reading /proc/*/environ or /proc/*/cmdline can leak credentials."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(curl|wget|http|https|httpie)\s[^|]*(github\.com|api\.github\.com)/.test(
|
||||
low,
|
||||
)
|
||||
? "direct GitHub HTTP calls bypass the PAT handler — use the role-appropriate MCP verb."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(curl|wget|http|https|httpie)\s/.test(low) && INTERNAL_HOST.test(low)
|
||||
? "internal API calls bypass the gateway — use the MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal)."
|
||||
: null,
|
||||
(low) =>
|
||||
HTTP_CLIENT_LIB.test(low) && INTERNAL_HOST.test(low)
|
||||
? "reaching an internal host via an HTTP client bypasses the gateway, role manifest, tracing and auth (and can forge X-Agent-* headers). Use your MCP verbs."
|
||||
: null,
|
||||
(low) =>
|
||||
/(python3?|uv\s+run|poetry\s+run|pipenv\s+run|pdm\s+run|hatch\s+run)/.test(low) &&
|
||||
/(import\s+roboco|from\s+roboco|-m\s+roboco|roboco\.(mcp|services|runtime|foundation|api|enforcement)\b)/.test(
|
||||
low,
|
||||
)
|
||||
? "importing or running roboco.* internals from the shell bypasses the MCP role manifest, tracing and auth. Use your role's MCP verbs."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|]|env\s+|export\s+)roboco_agent_id\s*=/.test(low)
|
||||
? "ROBOCO_AGENT_ID is your injected identity — overriding it forges another agent's identity. Never set or export it."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(env|printenv)([\s]|$)/.test(low) &&
|
||||
!/(^|[\s;&|])env\s+(-i|[a-z_][a-z0-9_]*=)/.test(low)
|
||||
? "env / printenv can leak secrets. Ask for the specific value you need via the task description."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])set([\s]*$|[\s]*[|;&])/.test(low) ||
|
||||
/(^|[\s;&|])(declare|typeset)\s+-[a-z]*[xp]/.test(low) ||
|
||||
/(^|[\s;&|])export\s+-p([\s]|$)/.test(low) ||
|
||||
/(^|[\s;&|])compgen\s+-[a-z]*[ve]/.test(low)
|
||||
? "shell built-ins that dump variables/exports can leak credentials."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])(base64|od|xxd|hexdump|strings|uuencode)\s[^|;&]*(\.env|\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519)/.test(
|
||||
low,
|
||||
)
|
||||
? "encoding/inspecting a credential file is still exfiltration."
|
||||
: null,
|
||||
(low) =>
|
||||
/(^|[\s;&|])rm\s[^|;&]*-[a-z]*[rf][a-z]*\s/.test(low) &&
|
||||
/(^|[\s;&|])rm\s[^|;&]*(\/app($|[\s/])|\/root|\/etc|\/var|\/usr|\/bin|\/sbin|\/lib|\/home|\s\/\s*(;|\||&|$))/.test(
|
||||
low,
|
||||
)
|
||||
? "rm on a system path. Operate inside your own workspace only."
|
||||
: null,
|
||||
];
|
||||
|
||||
// Tools that take a file path we must keep away from credential files.
|
||||
const PATH_TOOLS = new Set(["read", "edit", "write"]);
|
||||
|
||||
// Strip heredoc bodies and echo/printf literal args BEFORE the git-ops check —
|
||||
// those are data the shell writes to a file, not commands it runs, so a
|
||||
// README/heredoc that documents `git push` must not be mistaken for invoking
|
||||
// it. Quoted args to an interpreter (`bash -c "... && git fetch"`) ARE executed
|
||||
// and are not echo/printf/heredoc bodies, so they survive. Mirrors the
|
||||
// git_skel logic in bash-guard-hook.sh; every other rule sees the full command.
|
||||
function gitSkeleton(command) {
|
||||
const lines = String(command || "").split("\n");
|
||||
const opener = /<<-?\s*[^\sA-Za-z_]*([A-Za-z_]\w*)/;
|
||||
const kept = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
kept.push(lines[i]);
|
||||
const m = opener.exec(lines[i]);
|
||||
if (m) {
|
||||
const delim = m[1];
|
||||
const dash = lines[i].includes("<<-");
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const body = lines[i];
|
||||
const cand = dash ? body.trim() : body;
|
||||
if (cand === delim) {
|
||||
kept.push(body);
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept
|
||||
.join("\n")
|
||||
.replace(/(^|[\n;&|]|&&|\|\|)\s*(echo|printf)\b[^\n;&|]*/g, "$1");
|
||||
}
|
||||
|
||||
function denyBash(command) {
|
||||
const low = String(command || "").toLowerCase();
|
||||
if (!low) return null;
|
||||
if (GIT_OPS.test(gitSkeleton(command).toLowerCase())) {
|
||||
return "shell git for network/auth/branch-mutating ops is blocked — use your role's MCP verb (commit, complete, i_am_done, ...).";
|
||||
}
|
||||
for (const check of BASH_CHECKS) {
|
||||
const reason = check(low);
|
||||
if (reason) return reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Named export (opencode's plugin convention) + loaded from opencode's plugin
|
||||
// auto-discovery dir (~/.config/opencode/plugin/), where it's baked at image
|
||||
// build — the simplest registration route (no config `plugin:` path needed).
|
||||
// Hook firing verified live against grok-build-0.1.
|
||||
export const RobocoSecretScrub = async () => {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
const tool = input?.tool;
|
||||
const args = output?.args || {};
|
||||
if (tool === "bash") {
|
||||
const reason = denyBash(args.command);
|
||||
if (reason) throw new Error(`Denied by roboco secret-scrub: ${reason}`);
|
||||
return;
|
||||
}
|
||||
if (PATH_TOOLS.has(tool)) {
|
||||
const path = String(args.filePath || args.path || "").toLowerCase();
|
||||
if (path && CREDENTIAL_FILE.test(path)) {
|
||||
throw new Error(
|
||||
"Denied by roboco secret-scrub: access to a credential file / SSH key is blocked.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
// opencode plugin — the Secretary's CEO-authority tools, on Grok.
|
||||
//
|
||||
// Parity with the Claude Secretary's SDK tools (roboco.agent_sdk.secretary_driver
|
||||
// .build_secretary_options): read_company_state / read_task / submit_directive,
|
||||
// each calling the backend /api/secretary/* routes with the container's HMAC
|
||||
// agent token. Without these the Grok Secretary can chat but cannot read company
|
||||
// state or act on a CEO directive — the integration blocker.
|
||||
//
|
||||
// Loaded ONLY into the roboco-agent-grok-secretary image via
|
||||
// ROBOCO_OPENCODE_EXTRA_PLUGINS (so no other role gets CEO authority). The
|
||||
// container already carries ROBOCO_AGENT_TOKEN / ROBOCO_API_URL / ROBOCO_AGENT_ID
|
||||
// / ROBOCO_AGENT_ROLE (set by the orchestrator's _build_secretary_run_cmd), so
|
||||
// the auth substrate matches the one-shot Grok path exactly.
|
||||
//
|
||||
// The backend gate-list queues high-impact directive kinds (charter,
|
||||
// control_task, approve_pitch, announce) for the CEO's explicit confirmation and
|
||||
// runs relay_message directly — that policy lives server-side; this plugin only
|
||||
// forwards the call. Each tool returns the backend JSON as a string the model
|
||||
// reads back (mirrors secretary_driver._text_result).
|
||||
//
|
||||
// Verified live on the NAS: the @opencode-ai/plugin tool-registration path
|
||||
// round-trips against a live opencode serve + grok-build-0.1 — a directive
|
||||
// reaches the backend with the HMAC token and the JSON result returns to the model.
|
||||
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const API_BASE = (
|
||||
process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000"
|
||||
).replace(/\/+$/, "");
|
||||
const TIMEOUT_MS = 30000;
|
||||
|
||||
function headers() {
|
||||
const h = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Agent-ID": process.env.ROBOCO_AGENT_ID || "",
|
||||
"X-Agent-Role": process.env.ROBOCO_AGENT_ROLE || "secretary",
|
||||
};
|
||||
const token = process.env.ROBOCO_AGENT_TOKEN;
|
||||
if (token) h["X-Agent-Token"] = token;
|
||||
return h;
|
||||
}
|
||||
|
||||
// Call /api/secretary{path}; never throw — a failure becomes an {error,...}
|
||||
// object the model can read and report, exactly like secretary_driver._call_backend.
|
||||
async function callBackend(method, path, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/secretary${path}`, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
return { error: "request_failed", detail: String(e) };
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = { detail: await res.text().catch(() => "") };
|
||||
}
|
||||
if (!res.ok) return { error: `http_${res.status}`, detail: data };
|
||||
return data;
|
||||
}
|
||||
|
||||
const asText = (data) => JSON.stringify(data);
|
||||
|
||||
// Named export (opencode's plugin convention) + baked into the plugin
|
||||
// auto-discovery dir (~/.config/opencode/plugin/) at image build. Verified live
|
||||
// against grok-build-0.1: the model called read_company_state + submit_directive
|
||||
// and the backend received both requests with the X-Agent-Token.
|
||||
export const RobocoSecretaryTools = async () => ({
|
||||
tool: {
|
||||
read_company_state: tool({
|
||||
description:
|
||||
"Read a compact snapshot of company state: the charter (goals), task " +
|
||||
"counts by status, pending pitches, and any directives awaiting the " +
|
||||
"CEO's confirmation.",
|
||||
args: {},
|
||||
async execute() {
|
||||
return asText(await callBackend("GET", "/state"));
|
||||
},
|
||||
}),
|
||||
read_task: tool({
|
||||
description: "Read one task's detail by its id.",
|
||||
args: { task_id: tool.schema.string().describe("The task id") },
|
||||
async execute(args) {
|
||||
const id = encodeURIComponent(String(args.task_id));
|
||||
return asText(await callBackend("GET", `/tasks/${id}`));
|
||||
},
|
||||
}),
|
||||
submit_directive: tool({
|
||||
description:
|
||||
"Act on the CEO's command. 'kind' is one of: relay_message " +
|
||||
"(payload: channel, text), update_charter (payload: charter), " +
|
||||
"control_task (payload: task_id, action[start|cancel|override], " +
|
||||
"status?), approve_pitch (payload: pitch_id, notes?), announce " +
|
||||
"(payload: text). High-impact kinds (charter, control_task, " +
|
||||
"approve_pitch, announce) are queued for the CEO's explicit " +
|
||||
"confirmation; relay_message runs directly.",
|
||||
args: {
|
||||
kind: tool.schema.string().describe("The directive kind"),
|
||||
payload: tool.schema
|
||||
.record(tool.schema.string(), tool.schema.any())
|
||||
.describe("The directive payload object"),
|
||||
},
|
||||
async execute(args) {
|
||||
return asText(
|
||||
await callBackend("POST", "/directives", {
|
||||
kind: args.kind,
|
||||
payload: args.payload || {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Entrypoint for the roboco-agent-grok image (one-shot delivery roles).
|
||||
#
|
||||
# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by
|
||||
# GrokProvider) plus the mounted Claude Code mcp-config.json, starts the
|
||||
# in-container SDK server (parity with the Claude SessionStart sdk-startup-hook),
|
||||
# then runs opencode non-interactively. opencode speaks the OpenAI protocol, so
|
||||
# grok-build-0.1 runs natively against api.x.ai/v1 with no shim, while still
|
||||
# reaching the RoboCo MCP gateway (roboco-flow / roboco-do / ...) translated into
|
||||
# opencode's mcp config.
|
||||
set -euo pipefail
|
||||
|
||||
SDK_PORT="${ROBOCO_SDK_PORT:-9000}"
|
||||
SDK_URL="http://localhost:${SDK_PORT}"
|
||||
|
||||
# Generate opencode.json (provider + model + MCP gateway + permissions +
|
||||
# instructions). Writes to opencode's global config dir by default.
|
||||
# Run from /app so `python -m` resolves the INSTALLED roboco package. Dev/doc/qa
|
||||
# agents run at their workspace-clone cwd, which has its own `roboco/` dir on the
|
||||
# sys.path front (python -m prepends cwd); on a branch without the grok code that
|
||||
# clone lacks roboco.llm.providers and shadows /app → ModuleNotFoundError. The
|
||||
# config render has no cwd dependency (writes global, reads ROBOCO_MCP_CONFIG).
|
||||
( cd /app && python -m roboco.llm.providers.opencode_config )
|
||||
|
||||
# --- SDK server bring-up (Claude-parity) ----------------------------------
|
||||
# The flow/do MCP servers POST /verb/attempted here for the per-verb circuit
|
||||
# breaker; the budget-feed opencode plugin POSTs /budget/* + /terminal/* here;
|
||||
# the post-exit hook below reads /terminal/status and writes the post-mortem.
|
||||
# Bare `python` (the baked venv) — NOT `uv run`, which would re-sync the clone's
|
||||
# drifted lock and stall (the #179 fix the Claude hook needs `--no-sync` for).
|
||||
if ! curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then
|
||||
nohup python -m roboco.agent_sdk.server >/tmp/sdk-server.log 2>&1 &
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -sf -m 2 "${SDK_URL}/health" >/dev/null 2>&1; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
# Zero the budget/terminal counters at the start of the session.
|
||||
curl -sf -m 2 -X POST "${SDK_URL}/budget/reset" >/dev/null 2>&1 || true
|
||||
|
||||
# This is a one-shot delivery agent: the SDK budget server above is mandatory.
|
||||
# Tell the budget-feed plugin to FAIL CLOSED if that server ever goes
|
||||
# unreachable mid-run, so an unenforceable cost cap halts the burn instead of
|
||||
# letting it run uncapped. (Interactive serve images set no such flag.)
|
||||
export ROBOCO_BUDGET_ENFORCE=1
|
||||
|
||||
# Prompt-injection guard (parity with the Claude UserPromptSubmit hook): the
|
||||
# task prompt is DATA, not instructions — refuse a poisoned one before it
|
||||
# reaches the model. Same patterns as docker/scripts/user-prompt-hook.sh.
|
||||
if ! python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}"; then
|
||||
echo "Refusing to run: task prompt matched a prompt-injection pattern." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reasoning effort: GrokProvider sets ROBOCO_GROK_VARIANT per role (e.g.
|
||||
# "minimal" for coordination/docs roles to cut reasoning cost). Absent =
|
||||
# opencode default (full reasoning).
|
||||
variant_arg=()
|
||||
if [ -n "${ROBOCO_GROK_VARIANT:-}" ]; then
|
||||
variant_arg=(--variant "$ROBOCO_GROK_VARIANT")
|
||||
fi
|
||||
|
||||
# Run the agent. The prompt comes from an env var (never an untrusted argv
|
||||
# positional); `--` separates it from flags so a prompt starting with `--`
|
||||
# cannot be parsed as CLI options. `< /dev/null` is REQUIRED: without a closed
|
||||
# stdin, `opencode run` hangs after init in a headless / no-TTY environment.
|
||||
#
|
||||
# We do NOT `exec`: the script must regain control after opencode exits to run
|
||||
# the post-mortem + silent-exit substitute below (the Claude SessionEnd / Stop
|
||||
# hooks have no opencode equivalent, so the boundary handles them). `set +e`
|
||||
# around the run so a non-zero opencode exit doesn't abort before the post-run
|
||||
# hooks; tee captures the output for rate-limit detection and PIPESTATUS
|
||||
# preserves opencode's real exit code through the pipe.
|
||||
RUN_LOG="/tmp/opencode-run.log"
|
||||
set +e
|
||||
opencode run \
|
||||
--model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \
|
||||
"${variant_arg[@]}" \
|
||||
-- "${ROBOCO_INITIAL_PROMPT:-}" < /dev/null 2>&1 | tee "$RUN_LOG"
|
||||
run_rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
# --- Rate-limit detection (B4) ---------------------------------------------
|
||||
# A 429 from xAI ends the one-shot run without the agent ever calling a terminal
|
||||
# verb. Detect it from the run output and exit 75 (EX_TEMPFAIL) so the
|
||||
# orchestrator PARKS the grok provider instead of the dispatcher re-spawning the
|
||||
# same task every tick (429 -> exit -> respawn -> 429, a cost/token loop). A
|
||||
# rate-limited task is NOT substituted — it must be retried once the limit lifts.
|
||||
RATE_LIMITED=0
|
||||
if grep -qiE '(\b429\b|too many requests|rate.?limit|quota exceeded|rate_limit_exceeded)' \
|
||||
"$RUN_LOG" 2>/dev/null; then
|
||||
RATE_LIMITED=1
|
||||
fi
|
||||
|
||||
# --- Post-mortem (Claude SessionEnd parity) — always -----------------------
|
||||
terminal=$(curl -sf -m 2 "${SDK_URL}/terminal/status" 2>/dev/null || echo "")
|
||||
last_tool="null"
|
||||
had_terminal="false"
|
||||
if [ -n "$terminal" ]; then
|
||||
last_tool=$(echo "$terminal" | jq -r '.last_tool // "null"' 2>/dev/null || echo "null")
|
||||
had_terminal=$(echo "$terminal" | jq -r '.had_terminal_recently // false' 2>/dev/null || echo "false")
|
||||
fi
|
||||
|
||||
curl -sf -m 3 -X POST "${SDK_URL}/journal/post_mortem" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"terminal_tool\":\"${last_tool}\",\"reason\":\"session_end\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
if [ "$RATE_LIMITED" = "1" ]; then
|
||||
echo "[grok] xAI rate-limited — exiting 75 so the orchestrator parks the" \
|
||||
"provider; the task is retried when the limit lifts (not substituted)." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
# --- Silent-exit substitute (Claude Stop parity) ---------------------------
|
||||
# Only when NOT rate-limited: if the agent exited WITHOUT a terminal verb
|
||||
# (i_am_idle / i_am_done / pass / fail / ...), auto-substitute the task so it is
|
||||
# not left stuck in claimed/in_progress for a human to hand-unstick.
|
||||
if [ "$had_terminal" != "true" ]; then
|
||||
curl -sf -m 3 -X POST "${SDK_URL}/terminal/force_substitute" >/dev/null 2>&1 || true
|
||||
echo "[grok] exited without a terminal verb (last tool: ${last_tool}) — auto-substituted." >&2
|
||||
fi
|
||||
|
||||
exit "$run_rc"
|
||||
@@ -36,16 +36,14 @@ fi
|
||||
RUN_LOG="/tmp/grok-run.json"
|
||||
ERR_LOG="/tmp/grok-run.err"
|
||||
WORKSPACE="${ROBOCO_WORKSPACE:-$PWD}"
|
||||
# A fixed session id (set by the provider) makes the run's session store
|
||||
# locatable for usage capture below; absent, grok generates its own.
|
||||
SESSION_ARGS=()
|
||||
[ -n "${ROBOCO_AGENT_SESSION_ID:-}" ] && SESSION_ARGS=(-s "${ROBOCO_AGENT_SESSION_ID}")
|
||||
# NOTE: grok generates its own session id and ignores a requested one (`-s` does
|
||||
# not pin it), so we do NOT pass a session id in; usage capture below reads the
|
||||
# real id back out of the JSON run log instead.
|
||||
set +e
|
||||
grok -p "${ROBOCO_INITIAL_PROMPT:-}" \
|
||||
-m "${ROBOCO_AGENT_MODEL:-grok-build}" \
|
||||
--cwd "$WORKSPACE" \
|
||||
--output-format json \
|
||||
"${SESSION_ARGS[@]}" \
|
||||
"${GROK_ARGS[@]}" \
|
||||
< /dev/null > "$RUN_LOG" 2> "$ERR_LOG"
|
||||
run_rc=$?
|
||||
@@ -54,18 +52,19 @@ set -e
|
||||
cat "$RUN_LOG"
|
||||
[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2
|
||||
|
||||
# Capture token usage from the grok session store (~/.grok/sessions). The
|
||||
# orchestrator reads the written usage file back at finalize — the grok analogue
|
||||
# of the Claude transcript. Best-effort; never fails the run. Run from /app for
|
||||
# the same module-resolution reason as the render above.
|
||||
( cd /app && ROBOCO_GROK_RUN_CWD="$WORKSPACE" \
|
||||
# Capture token usage from the grok session store (~/.grok/sessions). The reader
|
||||
# reads the run's real session id out of $ROBOCO_GROK_RUN_LOG, locates the store,
|
||||
# and writes a usage.json the orchestrator reads back at finalize — the grok
|
||||
# analogue of the Claude transcript. Best-effort; never fails the run. Run from
|
||||
# /app for the same module-resolution reason as the render above.
|
||||
( cd /app && ROBOCO_GROK_RUN_CWD="$WORKSPACE" ROBOCO_GROK_RUN_LOG="$RUN_LOG" \
|
||||
python -m roboco.llm.providers.grok_cli_usage ) || true
|
||||
|
||||
# Rate-limit detection (parity with the opencode B4 path): an xAI 429 / quota
|
||||
# error ends the run without a terminal verb. Detect it from the run output and
|
||||
# exit 75 (EX_TEMPFAIL) so the orchestrator PARKS the grok provider instead of
|
||||
# the dispatcher respawning the same task every tick (429 -> exit -> respawn, a
|
||||
# token loop). A rate-limited task is retried once the limit lifts, not dropped.
|
||||
# Rate-limit detection: an xAI 429 / quota error ends the run without a terminal
|
||||
# verb. Detect it from the run output and exit 75 (EX_TEMPFAIL) so the
|
||||
# orchestrator PARKS the grok provider instead of the dispatcher respawning the
|
||||
# same task every tick (429 -> exit -> respawn, a token loop). A rate-limited task
|
||||
# is retried once the limit lifts, not dropped.
|
||||
if grep -qiE '(\b429\b|rate.?limit|too many requests|quota|insufficient_quota)' \
|
||||
"$RUN_LOG" "$ERR_LOG" 2>/dev/null; then
|
||||
echo "[grok] rate-limited — exiting 75 so the orchestrator parks the provider;" \
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Grok interactive session over the official ``grok`` CLI — the IntakeSession seam.
|
||||
|
||||
The Claude interactive roles (intake/secretary) run a held-open ``ClaudeSDKClient``.
|
||||
Grok runs the same conversation on xAI's official ``grok`` CLI: there is no
|
||||
long-lived server, so each human turn is one headless ``grok -p`` invocation.
|
||||
Conversation context persists by **resuming the same grok session id** — turn 1
|
||||
lets grok generate an id (read back from the terminal ``end`` event), and every
|
||||
later turn passes ``-r <id>`` so grok reloads the prior transcript. The CLI's
|
||||
``--output-format streaming-json`` events (``{type: thought|text|end}``) map to
|
||||
the same :class:`StreamChunk` kinds the panel already renders, so the existing
|
||||
``IntakeDriver`` loop / ``MessageSource`` / ``EventSink`` / relay are reused
|
||||
unchanged — only the ``SessionFactory`` differs.
|
||||
|
||||
Verified live on the maintainer's machine (grok 0.2.56):
|
||||
* ``grok -p "<text>" --output-format json`` returns ``{text, sessionId, ...}``;
|
||||
* ``grok -p "<text>" -r <sessionId>`` reloads the conversation (a fact set on
|
||||
turn 1 is recalled on turn 2 under the same session id);
|
||||
* streaming-json emits ``{type:thought,data}`` / ``{type:text,data}`` deltas and
|
||||
a final ``{type:end, sessionId, stopReason}``; tool calls do NOT surface as
|
||||
stream events (so the intake draft is delivered by the propose_draft MCP tool
|
||||
POSTing to the relay, not intercepted here).
|
||||
|
||||
Token usage is captured after every turn: the chat reuses one session id, so the
|
||||
session store's cumulative total is the running whole-chat usage and the last
|
||||
write to ``usage.json`` wins (the orchestrator reads it back at reap).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from roboco.agent_sdk.intake_driver import StreamChunk, _extract_draft
|
||||
from roboco.agents_config import get_agent_role
|
||||
from roboco.llm.providers.grok_cli_config import grok_cli_args_for_role
|
||||
from roboco.llm.providers.grok_cli_usage import capture_session_usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
_DEFAULT_MODEL = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build")
|
||||
# A rate-limit / quota end to a turn leaves no terminal verb on the one-shot path
|
||||
# and must read clearly on the interactive one; detected from the run's stderr.
|
||||
_RATE_LIMIT_MARKERS = (
|
||||
"429",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"quota",
|
||||
"insufficient_quota",
|
||||
)
|
||||
|
||||
|
||||
def _parse_event(line: str) -> dict[str, Any] | None:
|
||||
"""Parse one streaming-json NDJSON line into an event dict, tolerantly."""
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return event if isinstance(event, dict) else None
|
||||
|
||||
|
||||
class _StreamAssembler:
|
||||
"""Maps grok streaming-json events to ``StreamChunk``s, holding turn state.
|
||||
|
||||
Pure and synchronous so it is unit-tested without the live binary: feed it
|
||||
parsed event dicts, collect the chunks it returns, then read ``session_id``
|
||||
(for the next turn's ``-r``) and ``saw_end`` (to detect an abnormal turn).
|
||||
|
||||
Reasoning (``thought``) deltas are coalesced into one ``thinking`` chunk
|
||||
(flushed when the answer starts or at turn end) — the panel renders reasoning
|
||||
as a block, like the Claude path. Answer (``text``) deltas stream live, one
|
||||
chunk each, for the live-typing effect.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._thinking: list[str] = []
|
||||
self._text: list[str] = []
|
||||
self.session_id: str | None = None
|
||||
self.stop_reason: str | None = None
|
||||
self.saw_end: bool = False
|
||||
|
||||
def _flush_thinking(self) -> list[StreamChunk]:
|
||||
if not self._thinking:
|
||||
return []
|
||||
text = "".join(self._thinking)
|
||||
self._thinking = []
|
||||
return [StreamChunk(kind="thinking", text=text)] if text else []
|
||||
|
||||
def feed(self, event: dict[str, Any]) -> list[StreamChunk]:
|
||||
"""Return the chunks to emit for one event (may be empty)."""
|
||||
etype = str(event.get("type", ""))
|
||||
if etype == "thought":
|
||||
self._thinking.append(str(event.get("data", "")))
|
||||
return []
|
||||
if etype == "text":
|
||||
out = self._flush_thinking()
|
||||
piece = str(event.get("data", ""))
|
||||
if piece:
|
||||
self._text.append(piece)
|
||||
out.append(StreamChunk(kind="text", text=piece))
|
||||
return out
|
||||
if etype == "end":
|
||||
return self._finish(event)
|
||||
return [] # unknown event types ignored (tolerant)
|
||||
|
||||
def _finish(self, event: dict[str, Any]) -> list[StreamChunk]:
|
||||
out = self._flush_thinking()
|
||||
sid = event.get("sessionId") or event.get("session_id")
|
||||
if isinstance(sid, str) and sid:
|
||||
self.session_id = sid
|
||||
self.stop_reason = str(event.get("stopReason") or "") or None
|
||||
self.saw_end = True
|
||||
# Draft fallback only: the canonical draft path is the propose_draft MCP
|
||||
# tool POSTing straight to the relay (tool calls do not surface as stream
|
||||
# events), but if the agent typed a fenced ```roboco-draft``` block we
|
||||
# still surface it.
|
||||
draft = _extract_draft("".join(self._text))
|
||||
if draft is not None:
|
||||
out.append(StreamChunk(kind="draft", data=draft))
|
||||
out.append(
|
||||
StreamChunk(
|
||||
kind="turn_end",
|
||||
data={"session_id": self.session_id, "stop_reason": self.stop_reason},
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _classify_failure(returncode: int | None, stderr: str) -> str:
|
||||
"""A human-readable error for a turn that ended without an ``end`` event."""
|
||||
blob = stderr.strip()
|
||||
if any(marker in blob.lower() for marker in _RATE_LIMIT_MARKERS):
|
||||
return (
|
||||
"Grok is rate-limited right now (the SuperGrok quota is exhausted); "
|
||||
"please wait a moment and send your message again."
|
||||
)
|
||||
detail = blob.splitlines()[-1] if blob else f"exit code {returncode}"
|
||||
return f"The Grok turn ended unexpectedly ({detail}). Please try again."
|
||||
|
||||
|
||||
class GrokCliSession: # pragma: no cover - needs the live grok binary
|
||||
"""``IntakeSession`` backed by per-turn headless ``grok -p`` invocations.
|
||||
|
||||
Async context manager with no held-open process: ``__aenter__`` returns self,
|
||||
``__aexit__`` is a no-op (each turn owns its own subprocess). ``send`` runs one
|
||||
turn, resuming the captured grok session id so conversation context persists.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cwd: str,
|
||||
agent_id: str,
|
||||
model: str = _DEFAULT_MODEL,
|
||||
usage_file: str | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
) -> None:
|
||||
self._cwd = cwd
|
||||
self._agent_id = agent_id
|
||||
self._model = model
|
||||
self._usage_file = usage_file or os.environ.get("ROBOCO_GROK_USAGE_FILE")
|
||||
# The secretary's ROBOCO_AGENT_ID is its UUID (not a slug), so resolve the
|
||||
# role from the id when possible, else the container's ROBOCO_AGENT_ROLE.
|
||||
role = get_agent_role(agent_id) or os.environ.get("ROBOCO_AGENT_ROLE", "")
|
||||
self._role_args = grok_cli_args_for_role(role)
|
||||
self._extra_args = list(extra_args or [])
|
||||
self._session_id: str | None = None
|
||||
|
||||
async def __aenter__(self) -> GrokCliSession:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
def _build_argv(self, text: str) -> list[str]:
|
||||
argv = [
|
||||
"grok",
|
||||
"-p",
|
||||
text,
|
||||
"-m",
|
||||
self._model,
|
||||
"--cwd",
|
||||
self._cwd,
|
||||
"--output-format",
|
||||
"streaming-json",
|
||||
*self._role_args,
|
||||
*self._extra_args,
|
||||
]
|
||||
if self._session_id:
|
||||
argv += ["-r", self._session_id]
|
||||
return argv
|
||||
|
||||
async def send(self, text: str) -> AsyncIterator[StreamChunk]:
|
||||
"""Run one turn (one ``grok -p`` invocation) and yield its chunks.
|
||||
|
||||
A turn always ends with a ``turn_end`` chunk; a failure (spawn error,
|
||||
non-zero exit, or no ``end`` event) yields an ``error`` chunk first so the
|
||||
panel never renders a blank turn.
|
||||
"""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*self._build_argv(text),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error("grok turn could not start", error=str(exc))
|
||||
yield StreamChunk(kind="error", text=f"Could not start Grok: {exc}")
|
||||
yield StreamChunk(kind="turn_end", data={})
|
||||
return
|
||||
|
||||
assembler = _StreamAssembler()
|
||||
assert proc.stdout is not None
|
||||
async for raw in proc.stdout:
|
||||
event = _parse_event(raw.decode("utf-8", "replace").strip())
|
||||
if event is None:
|
||||
continue
|
||||
for chunk in assembler.feed(event):
|
||||
yield chunk
|
||||
|
||||
stderr_bytes = await proc.stderr.read() if proc.stderr else b""
|
||||
stderr = stderr_bytes.decode("utf-8", "replace")
|
||||
await proc.wait()
|
||||
|
||||
if assembler.session_id:
|
||||
self._session_id = assembler.session_id
|
||||
self._capture_usage()
|
||||
|
||||
if not assembler.saw_end:
|
||||
logger.error(
|
||||
"grok turn ended without a result",
|
||||
returncode=proc.returncode,
|
||||
stderr=stderr.strip()[:500],
|
||||
)
|
||||
yield StreamChunk(
|
||||
kind="error", text=_classify_failure(proc.returncode, stderr)
|
||||
)
|
||||
yield StreamChunk(kind="turn_end", data={})
|
||||
|
||||
def _capture_usage(self) -> None:
|
||||
"""Best-effort: rewrite usage.json with the chat's cumulative total."""
|
||||
if not (self._usage_file and self._session_id):
|
||||
return
|
||||
capture_session_usage(
|
||||
cwd=self._cwd,
|
||||
session_id=self._session_id,
|
||||
model=self._model,
|
||||
out_path=Path(self._usage_file),
|
||||
)
|
||||
@@ -1,17 +1,14 @@
|
||||
"""Container entrypoint for the GROK intake (prompter) agent — opencode serve.
|
||||
"""Container entrypoint for the GROK intake (prompter) agent — grok CLI.
|
||||
|
||||
The Grok analogue of ``intake_main``: the same in-container ``POST /turn``
|
||||
receiver and the same relay sink to ``/api/prompter/live/{id}/events``, but the
|
||||
held-open session is an :class:`OpencodeServeSession` (``opencode serve``)
|
||||
instead of a ``ClaudeSDKClient``. ``opencode.json`` (model + system prompt) is
|
||||
rendered first. Intake is a human-only interviewer (no gateway verbs); its one
|
||||
action tool, ``propose_draft``, is registered by the ``intake-tools.js`` opencode
|
||||
plugin (baked into the grok-prompter image's plugin auto-discovery dir). Because
|
||||
opencode's synchronous serve reply does NOT carry tool-call parts, that plugin
|
||||
POSTs the draft straight to the prompter-live relay (the same
|
||||
``/api/prompter/live/{id}/events`` endpoint) so the panel renders the draft card.
|
||||
The ``IntakeDriver`` loop, message source, and relay are reused unchanged — only
|
||||
the ``SessionFactory`` differs.
|
||||
held-open session is a :class:`GrokCliSession` (per-turn headless ``grok -p``,
|
||||
resuming one session id) instead of a ``ClaudeSDKClient``. ``~/.grok/config.toml``
|
||||
is rendered first to wire the intake agent's one action tool, ``propose_draft``,
|
||||
as the ``roboco-intake`` MCP server. Intake is a human-only interviewer with no
|
||||
gateway verbs; its only MCP server is ``roboco-intake``. The ``IntakeDriver``
|
||||
loop, message source, and relay are reused unchanged — only the
|
||||
``SessionFactory`` differs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,14 +21,14 @@ from typing import TYPE_CHECKING
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from roboco.agent_sdk.grok_cli_session import GrokCliSession
|
||||
from roboco.agent_sdk.intake_driver import IntakeDriver
|
||||
from roboco.agent_sdk.intake_main import (
|
||||
build_receiver,
|
||||
make_message_source,
|
||||
make_relay_sink,
|
||||
)
|
||||
from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port
|
||||
from roboco.llm.providers import opencode_config
|
||||
from roboco.llm.providers.grok_cli_config import GROK_CONFIG_PATH, render_config_toml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -41,24 +38,63 @@ logger = structlog.get_logger()
|
||||
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
|
||||
|
||||
|
||||
async def main() -> None: # pragma: no cover - needs the live container + opencode
|
||||
"""Render opencode.json, then run the receiver + driver for the chat's life."""
|
||||
import uvicorn
|
||||
def _render_grok_config(base_url: str, session_id: str) -> None:
|
||||
"""Write ``~/.grok/config.toml`` wiring the ``roboco-intake`` MCP server.
|
||||
|
||||
# Render opencode.json (provider/model/MCP gateway/instructions) from the
|
||||
# spawn env so `opencode serve` is gateway-wired before it starts.
|
||||
opencode_config.main()
|
||||
``uv run --directory /app`` pins both the project env (the baked
|
||||
``/app/.venv``) and the working directory to ``/app`` so ``-m
|
||||
roboco.mcp.intake_server`` resolves the INSTALLED package, never a workspace
|
||||
clone that might shadow it (the ModuleNotFound lesson).
|
||||
"""
|
||||
mcp_servers = {
|
||||
"roboco-intake": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--directory",
|
||||
"/app",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"-m",
|
||||
"roboco.mcp.intake_server",
|
||||
],
|
||||
"env": {
|
||||
"ROBOCO_API_URL": base_url,
|
||||
"ROBOCO_PROMPTER_SESSION_ID": session_id,
|
||||
"UV_PROJECT_ENVIRONMENT": "/app/.venv",
|
||||
},
|
||||
}
|
||||
}
|
||||
GROK_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
GROK_CONFIG_PATH.write_text(
|
||||
render_config_toml({"mcpServers": mcp_servers}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None: # pragma: no cover - needs the live container + grok
|
||||
"""Render config.toml, then run the receiver + driver for the chat's life."""
|
||||
import uvicorn
|
||||
|
||||
session_id = os.environ["ROBOCO_PROMPTER_SESSION_ID"]
|
||||
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
|
||||
cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace")
|
||||
|
||||
_render_grok_config(base_url, session_id)
|
||||
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_factory() -> AsyncIterator[OpencodeServeSession]:
|
||||
async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session:
|
||||
async def session_factory() -> AsyncIterator[GrokCliSession]:
|
||||
async with GrokCliSession(
|
||||
cwd=cwd,
|
||||
agent_id=os.environ.get("ROBOCO_AGENT_ID", ""),
|
||||
model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build"),
|
||||
usage_file=os.environ.get("ROBOCO_GROK_USAGE_FILE"),
|
||||
# Intake reads sibling product repos that sit outside its cwd under
|
||||
# the mounted workspaces tree — keep those reads allowed.
|
||||
extra_args=["--allow", "Read(/data/workspaces/**)"],
|
||||
) as session:
|
||||
yield session
|
||||
|
||||
driver = IntakeDriver(
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Container entrypoint for the GROK Secretary agent — opencode serve.
|
||||
"""Container entrypoint for the GROK Secretary agent — grok CLI.
|
||||
|
||||
The Grok analogue of ``secretary_main``: the same in-container ``POST /turn``
|
||||
receiver and the same relay sink to ``/api/secretary/live/{id}/events``, but the
|
||||
held-open session is an :class:`OpencodeServeSession` (``opencode serve``) rather
|
||||
than a ``ClaudeSDKClient``. ``opencode.json`` (xAI provider + system prompt) is
|
||||
rendered first. The Secretary's CEO-authority tools (read_company_state /
|
||||
read_task / submit_directive) are registered as opencode tools by the
|
||||
``secretary-tools.js`` plugin (baked into the grok-secretary image and wired in
|
||||
via ``ROBOCO_OPENCODE_EXTRA_PLUGINS``); they call ``/api/secretary/*`` with the
|
||||
container's HMAC agent token, the same auth the one-shot Grok path uses.
|
||||
held-open session is a :class:`GrokCliSession` (per-turn headless ``grok -p``,
|
||||
resuming one session id) rather than a ``ClaudeSDKClient``. ``~/.grok/config.toml``
|
||||
is rendered first to wire the Secretary's CEO-authority tools (read_company_state
|
||||
/ read_task / submit_directive) as the ``roboco-secretary`` MCP server, which
|
||||
calls ``/api/secretary/*`` with the container's HMAC agent token — the same auth
|
||||
the one-shot Grok path uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,11 +20,11 @@ from typing import TYPE_CHECKING
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from roboco.agent_sdk.grok_cli_session import GrokCliSession
|
||||
from roboco.agent_sdk.intake_driver import IntakeDriver
|
||||
from roboco.agent_sdk.intake_main import build_receiver, make_message_source
|
||||
from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port
|
||||
from roboco.agent_sdk.secretary_main import make_relay_sink
|
||||
from roboco.llm.providers import opencode_config
|
||||
from roboco.llm.providers.grok_cli_config import GROK_CONFIG_PATH, render_config_toml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -35,22 +34,62 @@ logger = structlog.get_logger()
|
||||
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
|
||||
|
||||
|
||||
async def main() -> None: # pragma: no cover - needs the live container + opencode
|
||||
"""Render opencode.json, then run the receiver + driver for the chat's life."""
|
||||
import uvicorn
|
||||
def _render_grok_config(base_url: str) -> None:
|
||||
"""Write ``~/.grok/config.toml`` wiring the ``roboco-secretary`` MCP server.
|
||||
|
||||
opencode_config.main()
|
||||
``uv run --directory /app`` pins the project env + working directory to
|
||||
``/app`` so ``-m roboco.mcp.secretary_server`` resolves the installed package
|
||||
(the ModuleNotFound lesson). The directive tools authenticate from the
|
||||
container's HMAC env, forwarded into the server's env below.
|
||||
"""
|
||||
mcp_servers = {
|
||||
"roboco-secretary": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--directory",
|
||||
"/app",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"-m",
|
||||
"roboco.mcp.secretary_server",
|
||||
],
|
||||
"env": {
|
||||
"ROBOCO_API_URL": base_url,
|
||||
"ROBOCO_AGENT_ID": os.environ.get("ROBOCO_AGENT_ID", ""),
|
||||
"ROBOCO_AGENT_ROLE": os.environ.get("ROBOCO_AGENT_ROLE", "secretary"),
|
||||
"ROBOCO_AGENT_TOKEN": os.environ.get("ROBOCO_AGENT_TOKEN", ""),
|
||||
"UV_PROJECT_ENVIRONMENT": "/app/.venv",
|
||||
},
|
||||
}
|
||||
}
|
||||
GROK_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
GROK_CONFIG_PATH.write_text(
|
||||
render_config_toml({"mcpServers": mcp_servers}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None: # pragma: no cover - needs the live container + grok
|
||||
"""Render config.toml, then run the receiver + driver for the chat's life."""
|
||||
import uvicorn
|
||||
|
||||
session_id = os.environ["ROBOCO_SECRETARY_SESSION_ID"]
|
||||
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
|
||||
cwd = os.environ.get("ROBOCO_WORKSPACE", "/app")
|
||||
|
||||
_render_grok_config(base_url)
|
||||
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_factory() -> AsyncIterator[OpencodeServeSession]:
|
||||
async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session:
|
||||
async def session_factory() -> AsyncIterator[GrokCliSession]:
|
||||
async with GrokCliSession(
|
||||
cwd=cwd,
|
||||
agent_id=os.environ.get("ROBOCO_AGENT_ID", ""),
|
||||
model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build"),
|
||||
usage_file=os.environ.get("ROBOCO_GROK_USAGE_FILE"),
|
||||
) as session:
|
||||
yield session
|
||||
|
||||
driver = IntakeDriver(
|
||||
|
||||
@@ -263,7 +263,7 @@ class IntakeDriver:
|
||||
"""
|
||||
# Prompt-injection guard at the input boundary (our own guard, runtime-
|
||||
# agnostic): deny a poisoned turn before the model ever sees it. Covers
|
||||
# the Grok (opencode) session and the Claude SDK session — the latter
|
||||
# the Grok (grok-CLI) session and the Claude SDK session — the latter
|
||||
# runs with setting_sources=[] and so never loads the bash UserPromptSubmit
|
||||
# hook, so this is the only injection guard either interactive path has.
|
||||
injection = detect_injection(text)
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
"""Grok interactive session over ``opencode serve`` — the IntakeSession seam.
|
||||
|
||||
The Claude interactive roles (intake/secretary) run a held-open ``ClaudeSDKClient``.
|
||||
Grok has no Claude binary, so its interactive runtime is ``opencode serve``: a
|
||||
long-lived local opencode HTTP server (the rendered ``opencode.json`` wires the
|
||||
xAI provider + the RoboCo MCP gateway + the system prompt as instructions). Each
|
||||
human turn is one **synchronous** ``POST /session/:id/message`` ("send and wait"),
|
||||
whose ``parts`` are mapped to the same :class:`StreamChunk` kinds the panel
|
||||
already renders. Conversation context persists because the opencode session is
|
||||
reused across turns. ``OpencodeServeSession`` satisfies the same ``IntakeSession``
|
||||
protocol the Claude ``SdkIntakeSession`` does, so the existing ``IntakeDriver``
|
||||
loop / ``MessageSource`` / ``EventSink`` / relay are reused unchanged.
|
||||
|
||||
Confirmed against opencode's server docs (https://opencode.ai/docs/server):
|
||||
``opencode serve`` on 127.0.0.1:<port>, ``POST /session`` -> Session, and the
|
||||
synchronous ``POST /session/:id/message`` -> ``{info, parts}``. Using the
|
||||
synchronous endpoint avoids the async ``/event`` SSE-bus correlation; the
|
||||
trade-off is that a turn's reply renders when it completes rather than as live
|
||||
token deltas (a later enhancement, once verifiable against grok-build-0.1).
|
||||
|
||||
UNVERIFIED-LIVE: opencode's exact ``Part`` schema is read defensively here — the
|
||||
mapping tolerates unknown shapes — and must be confirmed against a live
|
||||
``opencode serve`` + grok-build-0.1 run on the NAS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from roboco.agent_sdk.intake_driver import (
|
||||
StreamChunk,
|
||||
_draft_from_tool_input,
|
||||
_extract_draft,
|
||||
_is_propose_draft,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
_DEFAULT_PORT = 4096
|
||||
_READY_TIMEOUT_S = 30.0
|
||||
_READY_INTERVAL_S = 0.5
|
||||
|
||||
|
||||
def _part_to_chunk(
|
||||
part: dict[str, Any],
|
||||
) -> tuple[StreamChunk | None, str | None, dict[str, Any] | None]:
|
||||
"""Classify one opencode message part -> (chunk, text_part, draft).
|
||||
|
||||
Mirrors ``intake_driver._block_to_chunk`` for the opencode part shape and is
|
||||
tolerant of unknown shapes (skipped) since the Part schema may evolve.
|
||||
"""
|
||||
ptype = str(part.get("type", ""))
|
||||
if ptype in ("reasoning", "thinking"):
|
||||
return StreamChunk(kind="thinking", text=str(part.get("text", ""))), None, None
|
||||
if ptype in ("tool", "tool-invocation", "tool_use"):
|
||||
name = str(part.get("tool") or part.get("name") or "")
|
||||
tool_input = part.get("input") or part.get("args") or {}
|
||||
if _is_propose_draft(name):
|
||||
return None, None, _draft_from_tool_input(tool_input)
|
||||
return (
|
||||
StreamChunk(kind="tool_use", tool=name, data={"input": tool_input}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if ptype == "text":
|
||||
return None, str(part.get("text", "")), None
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _message_error(message: dict[str, Any]) -> str | None:
|
||||
"""Human-readable text of a turn-level error (``info.error``), else ``None``.
|
||||
|
||||
A model/turn failure (bad key, rate limit, model error) is reported by
|
||||
opencode in ``info.error`` with an EMPTY ``parts`` list — not as a part — so
|
||||
it must be surfaced explicitly or the turn renders blank (the original Claude
|
||||
intake bug). Confirmed live: a bad xAI key returns
|
||||
``info.error={"name":"APIError","data":{"message":"Incorrect API key ..."}}``.
|
||||
"""
|
||||
info = message.get("info")
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
err = info.get("error")
|
||||
if not err:
|
||||
return None
|
||||
if isinstance(err, dict):
|
||||
data = err.get("data")
|
||||
if isinstance(data, dict) and data.get("message"):
|
||||
return str(data["message"])
|
||||
if err.get("name"):
|
||||
return str(err["name"])
|
||||
return str(err)
|
||||
|
||||
|
||||
def normalize_opencode_message(message: dict[str, Any]) -> list[StreamChunk]:
|
||||
"""Map an opencode message reply (``{info, parts}``) to panel chunks.
|
||||
|
||||
Unlike the Claude path (which streams text deltas live and so drops the final
|
||||
TextBlock to avoid double-render), the synchronous opencode reply carries the
|
||||
text only here, so text parts ARE emitted. A turn-level ``info.error`` is
|
||||
surfaced as an ``error`` chunk so a failed turn is never silently blank.
|
||||
|
||||
Draft note: opencode's synchronous serve reply does NOT include tool-call
|
||||
parts, so the intake draft is delivered by the ``intake-tools.js``
|
||||
propose_draft tool POSTing to the prompter-live relay directly (not from
|
||||
here). The ``propose_draft`` tool-part and fenced ```roboco-draft``` handling
|
||||
below is a tolerant fallback for any opencode version that DOES surface the
|
||||
tool call in parts; it is normally a no-op on the serve path.
|
||||
"""
|
||||
parts = message.get("parts") or []
|
||||
chunks: list[StreamChunk] = []
|
||||
text_parts: list[str] = []
|
||||
draft: dict[str, Any] | None = None
|
||||
for part in parts:
|
||||
chunk, text_part, block_draft = _part_to_chunk(part)
|
||||
if chunk is not None:
|
||||
chunks.append(chunk)
|
||||
if text_part is not None:
|
||||
text_parts.append(text_part)
|
||||
chunks.append(StreamChunk(kind="text", text=text_part))
|
||||
draft = draft or block_draft
|
||||
draft = draft or _extract_draft("".join(text_parts))
|
||||
if draft is not None:
|
||||
chunks.append(StreamChunk(kind="draft", data=draft))
|
||||
error = _message_error(message)
|
||||
if error:
|
||||
chunks.append(StreamChunk(kind="error", text=error))
|
||||
chunks.append(StreamChunk(kind="turn_end", data={}))
|
||||
return chunks
|
||||
|
||||
|
||||
class OpencodeServeSession:
|
||||
"""``IntakeSession`` backed by a long-lived ``opencode serve`` process.
|
||||
|
||||
Async context manager: ``__aenter__`` launches ``opencode serve`` and opens
|
||||
one session; ``__aexit__`` tears the server down. ``send`` runs one turn via
|
||||
the synchronous message endpoint and yields normalized chunks. The opencode
|
||||
session id is reused across turns so conversation context persists in the
|
||||
server (the held-open analogue of the Claude SDK client).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
port: int = _DEFAULT_PORT,
|
||||
cwd: str | None = None,
|
||||
) -> None:
|
||||
self._port = port
|
||||
self._cwd = cwd
|
||||
self._proc: asyncio.subprocess.Process | None = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._session_id: str | None = None
|
||||
|
||||
@property
|
||||
def _base(self) -> str:
|
||||
return f"http://127.0.0.1:{self._port}"
|
||||
|
||||
async def __aenter__(self) -> OpencodeServeSession:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
"opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
str(self._port),
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
cwd=self._cwd,
|
||||
)
|
||||
# A generous per-turn timeout (grok-build-0.1 reasons before replying);
|
||||
# a short connect timeout so readiness polling fails fast and retries.
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=5.0))
|
||||
self._session_id = await self._open_session()
|
||||
logger.info("opencode serve session opened", session=self._session_id)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
if self._proc is not None and self._proc.returncode is None:
|
||||
self._proc.terminate()
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(self._proc.wait(), timeout=10.0)
|
||||
|
||||
async def _open_session(self) -> str:
|
||||
"""Create an opencode session, retrying until the server is ready."""
|
||||
assert self._client is not None
|
||||
last_error: str = "no response"
|
||||
attempts = max(1, int(_READY_TIMEOUT_S / _READY_INTERVAL_S))
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
resp = await self._client.post(f"{self._base}/session", json={})
|
||||
if resp.is_success:
|
||||
sid = _extract_session_id(resp.json())
|
||||
if sid:
|
||||
return sid
|
||||
last_error = f"HTTP {resp.status_code}"
|
||||
except Exception as exc: # server not up yet / transient
|
||||
last_error = str(exc)
|
||||
await asyncio.sleep(_READY_INTERVAL_S)
|
||||
raise RuntimeError(f"opencode serve never became ready: {last_error}")
|
||||
|
||||
async def send(self, text: str) -> AsyncIterator[StreamChunk]:
|
||||
"""Run one turn (synchronous message) and yield its normalized chunks."""
|
||||
if self._client is None or self._session_id is None:
|
||||
raise RuntimeError("OpencodeServeSession used outside its context")
|
||||
# If the serve subprocess has died, the session is gone — every turn
|
||||
# would otherwise fail with an opaque httpx connection error while the
|
||||
# container lingers as a zombie. Surface it clearly (the panel shows a
|
||||
# real message) and end the turn; the idle watchdog / a human reap tears
|
||||
# the container down.
|
||||
if self._proc is not None and self._proc.returncode is not None:
|
||||
logger.error("opencode serve exited", returncode=self._proc.returncode)
|
||||
yield StreamChunk(
|
||||
kind="error",
|
||||
text=(
|
||||
f"opencode serve exited (rc={self._proc.returncode}); this "
|
||||
"chat session ended — please start a new chat."
|
||||
),
|
||||
)
|
||||
yield StreamChunk(kind="turn_end", data={})
|
||||
return
|
||||
body: dict[str, Any] = {"parts": [{"type": "text", "text": text}]}
|
||||
# Per-role reasoning effort: the orchestrator sets ROBOCO_GROK_VARIANT on
|
||||
# the container; the serve message endpoint accepts a `variant` field
|
||||
# (confirmed against the live opencode OpenAPI), the same lever the
|
||||
# one-shot path drives via `opencode run --variant`.
|
||||
variant = _variant()
|
||||
if variant:
|
||||
body["variant"] = variant
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
f"{self._base}/session/{self._session_id}/message",
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
message = resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("opencode message turn failed", error=str(exc))
|
||||
yield StreamChunk(kind="error", text=str(exc))
|
||||
return
|
||||
for chunk in normalize_opencode_message(message):
|
||||
yield chunk
|
||||
|
||||
|
||||
def _extract_session_id(payload: Any) -> str | None:
|
||||
"""Pull the session id out of opencode's POST /session response, tolerantly."""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
for key in ("id", "sessionID", "session_id"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
info = payload.get("info")
|
||||
if isinstance(info, dict):
|
||||
ident = info.get("id")
|
||||
if isinstance(ident, str) and ident:
|
||||
return ident
|
||||
return None
|
||||
|
||||
|
||||
def serve_port() -> int:
|
||||
"""The opencode serve port (override with ROBOCO_OPENCODE_SERVE_PORT)."""
|
||||
raw = os.environ.get("ROBOCO_OPENCODE_SERVE_PORT", "").strip()
|
||||
if raw.isdigit() and int(raw) > 0:
|
||||
return int(raw)
|
||||
return _DEFAULT_PORT
|
||||
|
||||
|
||||
def _variant() -> str | None:
|
||||
"""The opencode reasoning variant to apply per turn (ROBOCO_GROK_VARIANT).
|
||||
|
||||
Set by the orchestrator from the per-role reasoning-effort policy (the same
|
||||
value the one-shot path passes to ``opencode run --variant``); unset = the
|
||||
model's default (full) reasoning.
|
||||
"""
|
||||
raw = os.environ.get("ROBOCO_GROK_VARIANT", "").strip()
|
||||
return raw or None
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Prompt-injection guard — shared detector for incoming agent turns.
|
||||
|
||||
RoboCo's prompt-injection guard is its OWN hook (``docker/scripts/user-prompt-hook.sh``,
|
||||
a Claude Code UserPromptSubmit hook), not a runtime built-in. opencode has no
|
||||
blocking pre-prompt hook, but it doesn't need one: the guard belongs at RoboCo's
|
||||
input boundary, in our own code, regardless of runtime. This ports the deny
|
||||
patterns to reusable Python so the same guard applies to:
|
||||
a Claude Code UserPromptSubmit hook), not a runtime built-in. The guard belongs
|
||||
at RoboCo's input boundary, in our own code, regardless of runtime. This ports
|
||||
the deny patterns to reusable Python so the same guard applies to:
|
||||
|
||||
* interactive sessions (intake / secretary) — the ``IntakeDriver`` scans each
|
||||
turn before sending it to the model, covering BOTH Claude (whose SDK session
|
||||
runs with ``setting_sources=[]`` and so never loads the bash hook) and Grok
|
||||
(opencode, no blocking pre-prompt hook);
|
||||
(the grok CLI, scanned at the same boundary);
|
||||
* one-shot Grok agents — the grok entrypoint scans ``ROBOCO_INITIAL_PROMPT``.
|
||||
|
||||
Content delivered to an agent (an A2A skill request, a PM's task description, an
|
||||
|
||||
@@ -115,7 +115,7 @@ def calculate_cost(
|
||||
Reasoning/thinking tokens that a provider reports *separately* from
|
||||
output (e.g. xAI grok-build-*) are billed at the output rate by the
|
||||
caller folding them into ``tokens_output`` (see
|
||||
``opencode_usage.cost_for_session``).
|
||||
``grok_cli_usage.usage_and_cost``).
|
||||
|
||||
Returns:
|
||||
Estimated cost in USD as a float. Returns 0.0 for unpriced models
|
||||
|
||||
+10
-10
@@ -662,12 +662,12 @@ class Settings(BaseSettings):
|
||||
"override via ROBOCO_STALE_CLAIM_REAP_SECONDS"
|
||||
),
|
||||
)
|
||||
# A GROK (opencode) agent that wedges — an idle model call / stream with no
|
||||
# gateway verb — is ACTIVE-yet-silent, so the heartbeat reaper's live-
|
||||
# container skip would shield its task forever (opencode emits no SDK budget
|
||||
# signal and advances no heartbeat while parked, unlike a Claude agent that
|
||||
# at least reports). After this longer window the orchestrator kills + evicts
|
||||
# the container so the reaper releases the task. Longer than
|
||||
# A GROK agent that wedges — an idle model call / stream with no gateway
|
||||
# verb — is ACTIVE-yet-silent, so the heartbeat reaper's live-container skip
|
||||
# would shield its task forever (the grok CLI emits no SDK budget signal and
|
||||
# advances no heartbeat while parked, unlike a Claude agent that at least
|
||||
# reports). After this longer window the orchestrator kills + evicts the
|
||||
# container so the reaper releases the task. Longer than
|
||||
# stale_claim_reap_seconds so only a truly-dead run trips it, never a
|
||||
# slow-but-working agent.
|
||||
grok_idle_kill_seconds: int = Field(
|
||||
@@ -679,10 +679,10 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
# Budget kill-switch parity for GROK. Claude Code's per-agent token-budget
|
||||
# hook fires against the SDK :9000 server; opencode exposes no usage hook to
|
||||
# a plugin, so the orchestrator enforces the cap by reading each live GROK
|
||||
# container's cumulative cost from its opencode store and killing it when it
|
||||
# crosses this ceiling (also catches runaway-loop token burn). USD; 0 = off.
|
||||
# hook fires against the SDK :9000 server; the grok CLI exposes no live usage
|
||||
# hook, so the orchestrator enforces the cap by reading each live GROK
|
||||
# container's captured cost from its usage.json and killing it when it crosses
|
||||
# this ceiling (also catches runaway-loop token burn). USD; 0 = off.
|
||||
grok_max_cost_usd: float = Field(
|
||||
default=0.0,
|
||||
ge=0,
|
||||
|
||||
@@ -31,7 +31,6 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from roboco.agents_config import get_agent_role
|
||||
from roboco.llm.providers._docker import container_running, stop_container
|
||||
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
||||
|
||||
@@ -50,9 +49,7 @@ _GROK_CLI_MODEL = os.environ.get("ROBOCO_GROK_CLI_MODEL", "grok-build")
|
||||
# Host directory holding the SuperGrok auth (from ``grok login``). Mounted into
|
||||
# the agent's ``~/.grok`` like the Claude path mounts ``~/.claude``. Override for
|
||||
# docker-in-docker / NAS deploys (the orchestrator's home is not the host's).
|
||||
GROK_AUTH_HOST_PATH = os.environ.get(
|
||||
"ROBOCO_HOST_GROK_DIR", str(Path.home() / ".grok")
|
||||
)
|
||||
GROK_AUTH_HOST_PATH = os.environ.get("ROBOCO_HOST_GROK_DIR", str(Path.home() / ".grok"))
|
||||
|
||||
# In-container paths.
|
||||
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
|
||||
@@ -68,44 +65,6 @@ def _container_name(agent_id: str) -> str:
|
||||
return f"roboco-agent-{agent_id}"
|
||||
|
||||
|
||||
# --- interactive (opencode-serve) shim — PENDING conversion to the grok CLI ---
|
||||
# The intake / secretary roles still run on the opencode-serve interactive path,
|
||||
# which needs the opencode ``--variant`` reasoning effort ("minimal") — distinct
|
||||
# from the one-shot CLI's ``--effort`` ("low") computed in grok_cli_config. Kept
|
||||
# here until the interactive path is moved onto the grok CLI too; the orchestrator
|
||||
# interactive-spawn methods import it.
|
||||
_MINIMAL_REASONING_ROLES = frozenset(
|
||||
{
|
||||
"cell_pm",
|
||||
"main_pm",
|
||||
"documenter",
|
||||
"product_owner",
|
||||
"head_marketing",
|
||||
"auditor",
|
||||
"prompter",
|
||||
"secretary",
|
||||
}
|
||||
)
|
||||
_FULL_REASONING_OVERRIDES = frozenset({"default", "full", "none", ""})
|
||||
|
||||
|
||||
def _reasoning_effort_for(agent_id: str) -> str | None:
|
||||
"""opencode ``--variant`` for the interactive serve path (pending conversion).
|
||||
|
||||
Returns ``None`` (opencode default / full reasoning) for code-quality roles;
|
||||
``"minimal"`` for coordination / docs / board roles. A global
|
||||
``ROBOCO_GROK_REASONING_EFFORT`` override wins.
|
||||
"""
|
||||
override = os.environ.get("ROBOCO_GROK_REASONING_EFFORT", "").strip()
|
||||
if override:
|
||||
return None if override.lower() in _FULL_REASONING_OVERRIDES else override
|
||||
return (
|
||||
"minimal"
|
||||
if (get_agent_role(agent_id) or "") in _MINIMAL_REASONING_ROLES
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
class _GrokHost(Protocol):
|
||||
"""The orchestrator surface GrokCliProvider reuses for container assembly.
|
||||
|
||||
@@ -115,7 +74,7 @@ class _GrokHost(Protocol):
|
||||
|
||||
async def _remove_container(self, container_name: str) -> None: ...
|
||||
|
||||
def _ensure_opencode_data_dir(self, agent_id: str) -> None: ...
|
||||
def _ensure_grok_usage_dir(self, agent_id: str) -> None: ...
|
||||
|
||||
def _resolve_host_paths(
|
||||
self, config: AgentConfig, agent_settings_path: Path | None
|
||||
@@ -156,7 +115,7 @@ class GrokCliProvider(AgentProvider):
|
||||
await self._host._remove_container(container_name)
|
||||
# Pre-create the per-agent data dir (world-writable) before the bind
|
||||
# mount so the non-root agent can write the usage file (else EACCES).
|
||||
self._host._ensure_opencode_data_dir(config.agent_id)
|
||||
self._host._ensure_grok_usage_dir(config.agent_id)
|
||||
|
||||
# Reuse the orchestrator's mount/auth/git assembly so the agent gets the
|
||||
# full MCP gateway + identity wiring. Blank the provider routing fields
|
||||
@@ -208,11 +167,11 @@ class GrokCliProvider(AgentProvider):
|
||||
def _append_usage_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
|
||||
"""Mount the per-agent data dir so the orchestrator reads usage back.
|
||||
|
||||
Reuses the shared per-agent host dir (``hosts["opencode"]``); the
|
||||
Reuses the shared per-agent host dir (``hosts["grok_usage"]``); the
|
||||
entrypoint writes ``usage.json`` here after the run and the orchestrator
|
||||
reads it at finalize. Without it a Grok agent finalizes at 0 tokens / $0.
|
||||
"""
|
||||
data_host = hosts.get("opencode")
|
||||
data_host = hosts.get("grok_usage")
|
||||
if data_host:
|
||||
cmd.extend(["-v", f"{data_host}:{_GROK_USAGE_DIR_IN_CONTAINER}"])
|
||||
|
||||
@@ -239,11 +198,6 @@ class GrokCliProvider(AgentProvider):
|
||||
f"ROBOCO_GROK_USAGE_FILE={_GROK_USAGE_FILE_IN_CONTAINER}",
|
||||
]
|
||||
)
|
||||
if config.claude_session_id:
|
||||
# A fixed session id (reused as the generic agent session id, as on
|
||||
# the Claude path) lets the entrypoint pin `grok -p -s <id>` so the
|
||||
# run's session store is locatable for token-usage capture.
|
||||
cmd.extend(["-e", f"ROBOCO_AGENT_SESSION_ID={config.claude_session_id}"])
|
||||
|
||||
async def stop(self, instance_id: str, graceful: bool = True) -> None:
|
||||
await stop_container(instance_id, graceful)
|
||||
|
||||
@@ -9,7 +9,7 @@ command. Keeping the translation in importable Python (not a shell heredoc)
|
||||
makes it unit-testable.
|
||||
|
||||
Parity with ``ClaudeCodeProvider``'s per-role permissions, expressed as native
|
||||
grok flags instead of an opencode permission block + a JS guard plugin:
|
||||
grok flags (built-in tool removal + ``--deny`` rules):
|
||||
|
||||
* **subagents** — every role gets ``--disallowed-tools Agent``: no RoboCo agent
|
||||
spawns the CLI's own subagents (work is driven through the gateway verbs).
|
||||
@@ -19,7 +19,7 @@ grok flags instead of an opencode permission block + a JS guard plugin:
|
||||
(``--disallowed-tools run_terminal_cmd``).
|
||||
* **git mutation** — bash-capable roles keep a shell, but raw git mutation is
|
||||
denied (``--deny "Bash(git push*)"`` ...): agents commit / push through the
|
||||
gateway verbs, never raw git (the opencode secret-scrub git-ops rule, ported).
|
||||
gateway verbs, never raw git.
|
||||
* **destructive** — ``--deny "Bash(rm -rf*)"`` for every bash-capable role.
|
||||
* **reasoning** — ``--effort`` by role (``low`` for coordination / docs / board;
|
||||
full for the code-quality roles). A global ``ROBOCO_GROK_REASONING_EFFORT``
|
||||
@@ -44,8 +44,7 @@ GROK_CONFIG_PATH = Path.home() / ".grok" / "config.toml"
|
||||
# The entrypoint reads the computed flags (one token per line) from this file.
|
||||
GROK_ARGS_PATH = Path(os.environ.get("ROBOCO_GROK_ARGS_FILE", "/tmp/roboco-grok-args"))
|
||||
|
||||
# Hard ceiling on agentic turns (loop guard; replaces the opencode budget-feed
|
||||
# loop cap). Operator-tunable.
|
||||
# Hard ceiling on agentic turns (loop guard). Operator-tunable.
|
||||
_DEFAULT_MAX_TURNS = 200
|
||||
|
||||
# Roles that request reduced reasoning (grok bills reasoning at the output rate,
|
||||
@@ -146,13 +145,14 @@ def _effort_for(role: str) -> str | None:
|
||||
return "low" if role in _MINIMAL_REASONING_ROLES else None
|
||||
|
||||
|
||||
def grok_cli_args(agent_id: str, *, max_turns: int = _DEFAULT_MAX_TURNS) -> list[str]:
|
||||
"""The per-role ``grok -p`` flag tokens for an agent (excludes ``-p``/model/cwd).
|
||||
def grok_cli_args_for_role(
|
||||
role: str, *, max_turns: int = _DEFAULT_MAX_TURNS
|
||||
) -> list[str]:
|
||||
"""The per-role ``grok -p`` flag tokens (excludes ``-p``/model/cwd).
|
||||
|
||||
Order: tool removal, turn cap, deny rules, then effort. Each token is a
|
||||
separate list element so the entrypoint can splice them without shell quoting.
|
||||
separate list element so callers can splice them without shell quoting.
|
||||
"""
|
||||
role = get_agent_role(agent_id) or ""
|
||||
args: list[str] = ["--disallowed-tools", _disallowed_tools(role)]
|
||||
args += ["--max-turns", str(max_turns)]
|
||||
for rule in _deny_rules(role):
|
||||
@@ -163,6 +163,11 @@ def grok_cli_args(agent_id: str, *, max_turns: int = _DEFAULT_MAX_TURNS) -> list
|
||||
return args
|
||||
|
||||
|
||||
def grok_cli_args(agent_id: str, *, max_turns: int = _DEFAULT_MAX_TURNS) -> list[str]:
|
||||
"""The per-role grok flags for an agent, resolving its role from the id."""
|
||||
return grok_cli_args_for_role(get_agent_role(agent_id) or "", max_turns=max_turns)
|
||||
|
||||
|
||||
def _load_mcp_config(path: str) -> dict[str, Any]:
|
||||
"""Load the mounted mcp-config.json, tolerating a missing / invalid file."""
|
||||
try:
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
The grok CLI persists each session under ``~/.grok/sessions/<url-encoded-cwd>/
|
||||
<session-id>/updates.jsonl``; every update carries a cumulative
|
||||
``params._meta.totalTokens``, so the maximum across the file is the session's
|
||||
total token count. This is the grok analogue of the Claude Code transcript and
|
||||
the old opencode.db — Grok runs on the SuperGrok subscription, but (exactly like
|
||||
Claude on Max) we still record per-agent tokens and a notional cost.
|
||||
total token count. This is the grok analogue of the Claude Code transcript —
|
||||
Grok runs on the SuperGrok subscription, but (exactly like Claude on Max) we
|
||||
still record per-agent tokens and a notional cost.
|
||||
|
||||
The grok CLI reports a single ``totalTokens`` with no input/output split, so the
|
||||
notional cost prices the whole total at the output rate (the higher rate —
|
||||
@@ -13,7 +13,14 @@ conservative, and consistent with reasoning tokens billing at the output rate).
|
||||
|
||||
The agent entrypoint runs ``python -m roboco.llm.providers.grok_cli_usage`` after
|
||||
the run to write a small ``usage.json`` (``{model, total_tokens, cost_usd}``)
|
||||
into a per-agent dir the orchestrator reads back at finalize.
|
||||
into a per-agent dir the orchestrator reads back at finalize. The interactive
|
||||
driver reuses :func:`capture_session_usage` directly after each turn (the chat
|
||||
reuses one grok session id, so the cumulative total is the whole-chat usage).
|
||||
|
||||
The session id must be grok's REAL one: ``grok -p`` ignores a requested id (the
|
||||
``-s`` flag does not pin), so the one-shot entrypoint hands us the run's JSON log
|
||||
and we read the generated ``sessionId`` out of it (``ROBOCO_GROK_RUN_LOG``),
|
||||
falling back to ``ROBOCO_AGENT_SESSION_ID`` only when no log is given.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -88,20 +95,67 @@ def usage_and_cost(model: str, total_tokens: int) -> tuple[int, float]:
|
||||
)
|
||||
|
||||
|
||||
def capture_session_usage(
|
||||
*,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
model: str,
|
||||
out_path: Path,
|
||||
grok_home: Path | None = None,
|
||||
) -> int:
|
||||
"""Write ``usage.json`` for one grok session; return its total tokens.
|
||||
|
||||
Reusable by the interactive driver after every turn — the chat reuses one
|
||||
session id, so the session store's cumulative ``totalTokens`` is the running
|
||||
whole-chat total and the last write wins. Best-effort: never raises (returns
|
||||
0 and writes nothing on any IO/lookup failure).
|
||||
"""
|
||||
home = grok_home or Path(os.environ.get("GROK_HOME", str(Path.home() / ".grok")))
|
||||
try:
|
||||
updates = find_updates_path(home, cwd, session_id)
|
||||
total = total_tokens_from_updates(updates) if updates else 0
|
||||
tokens, cost = usage_and_cost(model, total)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(
|
||||
json.dumps({"model": model, "total_tokens": tokens, "cost_usd": cost}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return tokens
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def session_id_from_run_log(run_log: Path) -> str | None:
|
||||
"""Read the ``sessionId`` grok generated from its ``--output-format json`` log.
|
||||
|
||||
``grok -p`` does not honour a requested session id, so the entrypoint hands
|
||||
us the run's JSON output and we read the real id back. Returns ``None`` for a
|
||||
missing / non-JSON / id-less log.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(run_log.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
sid = payload.get("sessionId") or payload.get("session_id")
|
||||
return sid if isinstance(sid, str) and sid else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entrypoint: write ``usage.json`` (model, total_tokens, cost) for the run."""
|
||||
grok_home = Path(os.environ.get("GROK_HOME", str(Path.home() / ".grok")))
|
||||
cwd = os.environ.get("ROBOCO_GROK_RUN_CWD", str(Path.cwd()))
|
||||
session_id = os.environ.get("ROBOCO_AGENT_SESSION_ID", "")
|
||||
model = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build")
|
||||
|
||||
updates = find_updates_path(grok_home, cwd, session_id)
|
||||
total = total_tokens_from_updates(updates) if updates else 0
|
||||
tokens, cost = usage_and_cost(model, total)
|
||||
# grok's real session id: from the run's JSON log if the entrypoint passed
|
||||
# one (`-s` does not pin the id), else the orchestrator-supplied fallback.
|
||||
run_log = os.environ.get("ROBOCO_GROK_RUN_LOG", "")
|
||||
session_id = (run_log and session_id_from_run_log(Path(run_log))) or os.environ.get(
|
||||
"ROBOCO_AGENT_SESSION_ID", ""
|
||||
)
|
||||
|
||||
USAGE_OUT_PATH.write_text(
|
||||
json.dumps({"model": model, "total_tokens": tokens, "cost_usd": cost}),
|
||||
encoding="utf-8",
|
||||
capture_session_usage(
|
||||
cwd=cwd, session_id=session_id, model=model, out_path=USAGE_OUT_PATH
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Generate an ``opencode.json`` for a Grok (xAI) agent at container start.
|
||||
|
||||
The ``roboco-agent-grok`` image's entrypoint runs ``python -m
|
||||
roboco.llm.providers.opencode_config`` to turn the env contract ``GrokProvider``
|
||||
sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code
|
||||
``mcp-config.json`` into the ``opencode.json`` that opencode reads. Keeping this
|
||||
as importable Python (not a shell heredoc) makes the translation unit-testable.
|
||||
|
||||
Config shape per opencode docs (https://opencode.ai/docs/config):
|
||||
* NO ``provider`` block — opencode's BUILT-IN xai provider already drives
|
||||
grok-build-0.1 (model resolution + tool-calls verified live), so a custom
|
||||
block is unnecessary. The key + base URL reach the provider via the
|
||||
``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars; ``model`` selects ``xai/<model>``.
|
||||
* ``mcp.<name>`` — ``{type:"local", command:[...], environment:{...}}``; this
|
||||
is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired,
|
||||
translated from Claude Code's ``mcpServers`` (``command`` + ``args`` + ``env``).
|
||||
* ``permission.{bash,edit,external_directory}`` and ``instructions`` (system
|
||||
prompt + briefing).
|
||||
* ``tools`` — opencode's subagent ``task`` tool is hard-disabled. No RoboCo role
|
||||
uses opencode-internal subagents (work is driven through the gateway verbs),
|
||||
and a ``task``-spawned subagent on ``grok-build-0.1`` whose model call opens an
|
||||
idle stream hangs the parent run with no recovery (observed live on a PR
|
||||
review). This is the primary idle-stream defence (the orchestrator reaper is
|
||||
the backstop).
|
||||
|
||||
There is NO ``plugin`` key: the plugins are baked into opencode's plugin
|
||||
AUTO-DISCOVERY dir (``~/.config/opencode/plugin/``, i.e.
|
||||
``/home/agent/.config/opencode/plugin/`` in the image), so the generated config
|
||||
doesn't need to reference them by path (secret-scrub + budget-feed in the base
|
||||
grok image; the Secretary's directive tools and the Intake's propose_draft in
|
||||
their interactive images). Each uses a NAMED export (opencode's documented
|
||||
plugin convention). The model writes this config to the GLOBAL location (see
|
||||
``main`` — ``~/.config/opencode/opencode.json``), which is what opencode reads.
|
||||
|
||||
GUARDRAIL PARITY: the bash-guard (PAT-scrub) is ported via ``secret-scrub.js``
|
||||
(``tool.execute.before``); the per-session budget / loop / terminal-verb
|
||||
counters and the per-verb circuit breaker are restored by starting the same
|
||||
in-container SDK server the Claude path runs (the grok entrypoint launches
|
||||
``roboco.agent_sdk.server``) and feeding it from ``budget-feed.js``
|
||||
(``tool.execute.{before,after}``); usage/cost is captured from opencode's SQLite
|
||||
store at finalize and bounded by the orchestrator cost watchdog
|
||||
(``ROBOCO_GROK_MAX_COST_USD``); the prompt-injection guard is recreated at
|
||||
RoboCo's input boundary (``roboco.agent_sdk.prompt_guard``); and the SessionEnd
|
||||
post-mortem + Stop silent-exit substitute run at the entrypoint boundary after
|
||||
``opencode run`` returns. ``bash`` / ``edit`` permissions are scoped per role
|
||||
(read-only roles get ``edit=deny``; only delivery roles get ``bash``) and stay
|
||||
operator-tunable (``ROBOCO_GROK_BASH_PERMISSION`` / ``ROBOCO_GROK_EDIT_PERMISSION``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_OPENCODE_SCHEMA = "https://opencode.ai/config.json"
|
||||
_PROVIDER_ID = "xai"
|
||||
# No provider block at all: opencode's BUILT-IN xai provider already drives
|
||||
# grok-build-0.1 with working tool-calls (verified live), so no custom `npm` /
|
||||
# `models` override is needed. The xAI key is injected via the XAI_API_KEY env
|
||||
# var the built-in provider reads (set by GrokProvider / the orchestrator).
|
||||
#
|
||||
# Plugins (secret-scrub / budget-feed / the per-role tool plugins) are baked into
|
||||
# the plugin AUTO-DISCOVERY dir (~/.config/opencode/plugin/, i.e.
|
||||
# /home/agent/.config/opencode/plugin/ in the image) rather than referenced by a
|
||||
# config `plugin:` path — the dir is the simplest registration route and keeps
|
||||
# the generated config path-free (registration verified live against grok-build-0.1).
|
||||
|
||||
|
||||
# opencode's built-in subagent-spawning tool. Hard-disabled in the generated
|
||||
# config (see the module docstring): a RoboCo agent never spawns opencode's own
|
||||
# subagents, and one that does can wedge the parent run on an idle stream. This
|
||||
# is the primary defence against the idle-stream hang; the orchestrator's
|
||||
# reaper watchdog (_maybe_kill_wedged_grok) is the backstop. (Per-provider
|
||||
# request/stream timeouts would need a custom provider block, which we don't
|
||||
# emit; the reaper + disabled subagents cover the idle-stream risk instead.)
|
||||
_SUBAGENT_TOOL = "task"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpencodeGuards:
|
||||
"""Tunable runtime guards baked into a Grok ``opencode.json``.
|
||||
|
||||
``bash``/``edit`` gate the command/file tools; ``external_directory`` gates
|
||||
reading paths outside the project cwd (opencode auto-DENIES an ``ask`` in
|
||||
headless mode, which blocked the pr-reviewer from reading a diff it wrote to
|
||||
/tmp — so default ``allow``: the container is the sandbox and secret-scrub
|
||||
still blocks credential files); ``disable_subagents`` removes the subagent
|
||||
``task`` tool.
|
||||
"""
|
||||
|
||||
bash_permission: str = "allow"
|
||||
edit_permission: str = "allow"
|
||||
external_directory_permission: str = "allow"
|
||||
disable_subagents: bool = True
|
||||
|
||||
|
||||
def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Translate Claude Code ``mcpServers`` into opencode's ``mcp`` block.
|
||||
|
||||
``{"command": "uv", "args": [...], "env": {...}}`` becomes
|
||||
``{"type": "local", "command": ["uv", ...], "environment": {...},
|
||||
"enabled": True}``.
|
||||
"""
|
||||
servers = mcp_config.get("mcpServers", {})
|
||||
out: dict[str, Any] = {}
|
||||
for name, spec in servers.items():
|
||||
command = spec.get("command")
|
||||
args = list(spec.get("args", []))
|
||||
cmd_list = [command, *args] if command else args
|
||||
entry: dict[str, Any] = {
|
||||
"type": "local",
|
||||
"command": cmd_list,
|
||||
"enabled": True,
|
||||
}
|
||||
env = spec.get("env")
|
||||
if env:
|
||||
entry["environment"] = env
|
||||
out[name] = entry
|
||||
return out
|
||||
|
||||
|
||||
def build_opencode_config(
|
||||
mcp_config: dict[str, Any],
|
||||
model: str,
|
||||
*,
|
||||
instruction_paths: list[str],
|
||||
guards: OpencodeGuards | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the ``opencode.json`` dict for a Grok agent.
|
||||
|
||||
Emits NO ``provider`` block — opencode's BUILT-IN xai provider drives
|
||||
grok-build-0.1 (verified live), so a custom block is unnecessary; the key +
|
||||
base URL are injected via the ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars
|
||||
(set by GrokProvider / the orchestrator). No ``plugin`` array either —
|
||||
plugins live in the auto-discovery dir baked into the images.
|
||||
"""
|
||||
guards = guards or OpencodeGuards()
|
||||
config: dict[str, Any] = {
|
||||
"$schema": _OPENCODE_SCHEMA,
|
||||
"model": f"{_PROVIDER_ID}/{model}",
|
||||
"mcp": translate_mcp_servers(mcp_config),
|
||||
"permission": {
|
||||
"bash": guards.bash_permission,
|
||||
"edit": guards.edit_permission,
|
||||
# Reading paths outside the project cwd (e.g. /tmp scratch). opencode
|
||||
# auto-denies an "ask" in headless mode, which blocked the pr-reviewer
|
||||
# from reading a diff it wrote to /tmp; "allow" since the container is
|
||||
# the sandbox and secret-scrub still blocks credential files.
|
||||
"external_directory": guards.external_directory_permission,
|
||||
},
|
||||
"instructions": instruction_paths,
|
||||
}
|
||||
if guards.disable_subagents:
|
||||
# Remove the subagent tool entirely so the model can never invoke it.
|
||||
config["tools"] = {_SUBAGENT_TOOL: False}
|
||||
return config
|
||||
|
||||
|
||||
def _load_mcp_config(path: str) -> dict[str, Any]:
|
||||
"""Load the mounted mcp-config.json, tolerating a missing/invalid file."""
|
||||
try:
|
||||
with Path(path).open() as fh:
|
||||
data: dict[str, Any] = json.load(fh)
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entrypoint: read env + mounted mcp-config.json, write opencode.json.
|
||||
|
||||
The xAI key + base URL are NOT read here — they reach opencode's built-in
|
||||
xai provider via the ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars.
|
||||
"""
|
||||
model = os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1")
|
||||
mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json")
|
||||
system_prompt = os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md")
|
||||
# Default to opencode's global config location so it is found regardless of
|
||||
# the agent's working directory (cwd is the per-agent workspace at spawn).
|
||||
out_path = os.environ.get(
|
||||
"ROBOCO_OPENCODE_CONFIG",
|
||||
str(Path.home() / ".config" / "opencode" / "opencode.json"),
|
||||
)
|
||||
guards = OpencodeGuards(
|
||||
bash_permission=os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow"),
|
||||
edit_permission=os.environ.get("ROBOCO_GROK_EDIT_PERMISSION", "allow"),
|
||||
external_directory_permission=os.environ.get(
|
||||
"ROBOCO_GROK_EXTERNAL_DIR_PERMISSION", "allow"
|
||||
),
|
||||
)
|
||||
|
||||
# Instructions = system prompt + the SessionStart briefing when mounted.
|
||||
candidates = [system_prompt, "/app/briefing.md"]
|
||||
instructions = [p for p in candidates if p and Path(p).exists()]
|
||||
|
||||
config = build_opencode_config(
|
||||
_load_mcp_config(mcp_path),
|
||||
model,
|
||||
instruction_paths=instructions,
|
||||
guards=guards,
|
||||
)
|
||||
out = Path(out_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w") as fh:
|
||||
json.dump(config, fh, indent=2)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Read token usage from an opencode SQLite store — Grok agent cost capture.
|
||||
|
||||
opencode (v1.x) persists per-session usage in a SQLite DB at
|
||||
``~/.local/share/opencode/opencode.db`` (confirmed by inspecting a local run:
|
||||
the ``session`` table carries ``cost`` and ``tokens_input`` / ``tokens_output``
|
||||
/ ``tokens_reasoning`` / ``tokens_cache_read`` / ``tokens_cache_write``).
|
||||
|
||||
A Grok agent runs opencode, so its usage lands there rather than in a Claude
|
||||
Code transcript. The orchestrator reads this at agent finalize and feeds the
|
||||
token counts to :func:`roboco.billing.pricing.calculate_cost` — keeping our
|
||||
pricing authoritative — while opencode's own ``cost`` column is kept for
|
||||
reference. A per-agent container has a single opencode store, so summing all
|
||||
session rows is correct without needing to map an opencode session id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from roboco.billing.pricing import calculate_cost
|
||||
|
||||
# Default location inside the agent container (HOME=/home/agent).
|
||||
DEFAULT_DB_PATH = "/home/agent/.local/share/opencode/opencode.db"
|
||||
|
||||
# Column order here MUST match the unpacking in read_session_usage below.
|
||||
_SELECT_ALL = (
|
||||
"SELECT tokens_input, tokens_output, tokens_cache_read, "
|
||||
"tokens_cache_write, tokens_reasoning, cost FROM session"
|
||||
)
|
||||
_SELECT_ONE = _SELECT_ALL + " WHERE id = ?"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpencodeUsage:
|
||||
"""Aggregated token usage read from an opencode store."""
|
||||
|
||||
tokens_input: int
|
||||
tokens_output: int
|
||||
tokens_cache_read: int
|
||||
tokens_cache_write: int
|
||||
tokens_reasoning: int
|
||||
opencode_cost: float # opencode's own computed cost (reference only)
|
||||
|
||||
|
||||
def read_session_usage(
|
||||
db_path: str | Path = DEFAULT_DB_PATH,
|
||||
session_id: str | None = None,
|
||||
) -> OpencodeUsage | None:
|
||||
"""Read aggregated usage from an opencode SQLite store.
|
||||
|
||||
Reads the ``session`` table — a specific row when ``session_id`` is given,
|
||||
otherwise the sum across all sessions (one store per agent container).
|
||||
Returns ``None`` if the DB is missing, the table absent, or there are no
|
||||
rows — never raises, so callers don't need to guard finalize on it.
|
||||
"""
|
||||
path = Path(db_path)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
try:
|
||||
if session_id is not None:
|
||||
cur = con.execute(_SELECT_ONE, (session_id,))
|
||||
else:
|
||||
cur = con.execute(_SELECT_ALL)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
con.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
totals = [0, 0, 0, 0, 0]
|
||||
cost = 0.0
|
||||
for row in rows:
|
||||
for i in range(5):
|
||||
totals[i] += int(row[i] or 0)
|
||||
cost += float(row[5] or 0.0)
|
||||
|
||||
return OpencodeUsage(
|
||||
tokens_input=totals[0],
|
||||
tokens_output=totals[1],
|
||||
tokens_cache_read=totals[2],
|
||||
tokens_cache_write=totals[3],
|
||||
tokens_reasoning=totals[4],
|
||||
opencode_cost=cost,
|
||||
)
|
||||
|
||||
|
||||
def cost_for_session(
|
||||
model: str,
|
||||
db_path: str | Path = DEFAULT_DB_PATH,
|
||||
session_id: str | None = None,
|
||||
) -> tuple[OpencodeUsage | None, float]:
|
||||
"""Return (usage, roboco_cost_usd) for a Grok agent's opencode session.
|
||||
|
||||
``roboco_cost_usd`` is computed from our own pricing table so cost is
|
||||
consistent with the Claude path. Returns ``(None, 0.0)`` when no usage is
|
||||
recorded yet.
|
||||
"""
|
||||
usage = read_session_usage(db_path, session_id)
|
||||
if usage is None:
|
||||
return None, 0.0
|
||||
# opencode stores tokens_input as non-cached input (disjoint from
|
||||
# tokens_cache_read) and tokens_output EXCLUDING reasoning, with reasoning
|
||||
# in its own column. Reasoning bills at the output rate, so fold it into
|
||||
# output. Verified against a live run: this reproduces opencode's own `cost`
|
||||
# column (= xAI's authoritative cost) to the cent.
|
||||
cost = calculate_cost(
|
||||
model,
|
||||
tokens_input=usage.tokens_input,
|
||||
tokens_output=usage.tokens_output + usage.tokens_reasoning,
|
||||
tokens_cache_read=usage.tokens_cache_read,
|
||||
tokens_cache_write=usage.tokens_cache_write,
|
||||
)
|
||||
return usage, cost
|
||||
@@ -0,0 +1,95 @@
|
||||
"""roboco-intake MCP server — the Intake interviewer's ``propose_draft`` tool.
|
||||
|
||||
The grok-CLI interactive intake agent calls ``propose_draft`` once the task spec
|
||||
is ready; this delivers the draft to the panel's reviewable draft card by POSTing
|
||||
it straight to the prompter-live relay (the same ``/api/prompter/live/{session}/
|
||||
events`` endpoint the driver's relay sink uses).
|
||||
|
||||
WHY IT POSTS DIRECTLY: grok's ``streaming-json`` output does not surface
|
||||
tool-call events (verified live — a tool runs but never appears in the stream),
|
||||
so :class:`~roboco.agent_sdk.grok_cli_session.GrokCliSession` cannot intercept
|
||||
this call to emit a ``draft`` chunk. The tool POSTs the draft itself. (The Claude
|
||||
intake path differs: the Claude SDK exposes the tool-use block, so its driver
|
||||
intercepts it.)
|
||||
|
||||
Wired into ``~/.grok/config.toml`` by ``grok_intake_main``; the container
|
||||
provides ``ROBOCO_API_URL`` + ``ROBOCO_PROMPTER_SESSION_ID``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
_TIMEOUT = 15.0
|
||||
|
||||
mcp = FastMCP("roboco-intake")
|
||||
|
||||
|
||||
def _api_base() -> str:
|
||||
return os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000").rstrip(
|
||||
"/"
|
||||
)
|
||||
|
||||
|
||||
async def post_draft(
|
||||
session_id: str,
|
||||
draft: dict[str, Any],
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST the draft to the prompter-live relay; never raises.
|
||||
|
||||
Module-level so it is unit-testable with ``httpx.MockTransport`` (the tool
|
||||
wrapper below only shapes the result string).
|
||||
"""
|
||||
owns = client is None
|
||||
http = client or httpx.AsyncClient(timeout=_TIMEOUT)
|
||||
url = f"{_api_base()}/api/prompter/live/{session_id}/events"
|
||||
try:
|
||||
resp = await http.post(
|
||||
url,
|
||||
json={
|
||||
"kind": "draft",
|
||||
"text": "",
|
||||
"tool": "propose_draft",
|
||||
"data": draft,
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return {"error": "request_failed", "detail": str(exc)}
|
||||
finally:
|
||||
if owns:
|
||||
await http.aclose()
|
||||
if not resp.is_success:
|
||||
return {"error": f"http_{resp.status_code}"}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def propose_draft(draft: dict[str, Any]) -> str:
|
||||
"""Submit the finished task draft for the human to review and confirm.
|
||||
|
||||
Call this once the spec is complete. Pass a JSON object: title, objective,
|
||||
what_this_builds[], the_work[] ({team, summary, items}), notes[],
|
||||
acceptance_criteria[], team, scale, task_type, nature, estimated_complexity,
|
||||
priority.
|
||||
"""
|
||||
session_id = os.environ.get("ROBOCO_PROMPTER_SESSION_ID", "")
|
||||
if not session_id:
|
||||
return (
|
||||
"No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot surface the "
|
||||
"draft."
|
||||
)
|
||||
result = await post_draft(session_id, draft or {})
|
||||
if result.get("ok"):
|
||||
return "Draft submitted — the human can review it in the panel."
|
||||
detail = result.get("detail") or result.get("error") or "unknown error"
|
||||
return f"Could not submit the draft to the panel: {detail}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""roboco-secretary MCP server — the Secretary's CEO-authority tools.
|
||||
|
||||
Parity with the Claude Secretary's SDK tools
|
||||
(:func:`roboco.agent_sdk.secretary_driver.build_secretary_options`):
|
||||
``read_company_state`` / ``read_task`` (reads) and ``submit_directive`` (acts).
|
||||
Each calls the backend ``/api/secretary/*`` routes with the container's HMAC
|
||||
agent token; the backend gate-list queues high-impact directive kinds for the
|
||||
CEO's confirmation and runs low-risk ones directly. The backend-calling logic is
|
||||
reused verbatim from ``secretary_driver`` (the SDK and grok paths share one
|
||||
HTTP seam), so this server only wraps those helpers as MCP tools.
|
||||
|
||||
Wired into ``~/.grok/config.toml`` by ``grok_secretary_main``; the container
|
||||
provides ``ROBOCO_API_URL`` / ``ROBOCO_AGENT_ID`` / ``ROBOCO_AGENT_ROLE`` /
|
||||
``ROBOCO_AGENT_TOKEN`` (the same auth substrate the one-shot Grok path uses).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from roboco.agent_sdk.secretary_driver import (
|
||||
_do_read_state,
|
||||
_do_read_task,
|
||||
_do_submit_directive,
|
||||
)
|
||||
|
||||
mcp = FastMCP("roboco-secretary")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def read_company_state() -> str:
|
||||
"""Read a compact snapshot of company state.
|
||||
|
||||
The charter (goals), task counts by status, pending pitches, and any
|
||||
directives awaiting the CEO's confirmation.
|
||||
"""
|
||||
return json.dumps(await _do_read_state())
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def read_task(task_id: str) -> str:
|
||||
"""Read one task's detail by its id."""
|
||||
return json.dumps(await _do_read_task(task_id))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def submit_directive(kind: str, payload: dict[str, Any]) -> str:
|
||||
"""Act on the CEO's command.
|
||||
|
||||
'kind' is one of: relay_message (payload: channel, text), update_charter
|
||||
(payload: charter), control_task (payload: task_id, action[start|cancel|
|
||||
override], status?), approve_pitch (payload: pitch_id, notes?), announce
|
||||
(payload: text). High-impact kinds (charter, control_task, approve_pitch,
|
||||
announce) are queued for the CEO's explicit confirmation; relay_message runs
|
||||
directly.
|
||||
"""
|
||||
return json.dumps(await _do_submit_directive(kind, payload or {}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
+106
-154
@@ -163,15 +163,16 @@ CLAUDE_AUTH_HOST_PATH = os.environ.get(
|
||||
)
|
||||
PROJECT_HOST_PATH = os.environ.get("ROBOCO_HOST_PROJECT_DIR", "")
|
||||
DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "")
|
||||
# In-orchestrator path where each GROK agent's opencode store is visible. The
|
||||
# agent writes <DATA_HOST_PATH>/opencode/<agent_id>; the compose file mounts the
|
||||
# same host dir here so the finalizer can read opencode.db back (mirrors how the
|
||||
# Claude transcript is read from the mounted ~/.claude). Override for local runs.
|
||||
OPENCODE_DATA_DIR = os.environ.get("ROBOCO_OPENCODE_DATA_DIR", "/data/opencode")
|
||||
# In-orchestrator path where each GROK agent's usage capture is visible. The
|
||||
# agent writes <DATA_HOST_PATH>/grok-usage/<agent_id>/usage.json; the compose file
|
||||
# mounts the same host dir here so the finalizer can read the captured tokens back
|
||||
# (the grok analogue of reading the Claude transcript from the mounted ~/.claude).
|
||||
# Override for local runs.
|
||||
GROK_USAGE_DATA_DIR = os.environ.get("ROBOCO_GROK_USAGE_DIR", "/data/grok-usage")
|
||||
|
||||
# Interactive Grok images (opencode-serve drivers) — selected for the intake /
|
||||
# secretary roles when their route resolves to GROK, instead of the Claude
|
||||
# prompter/secretary images. Their dockerfiles build FROM roboco-agent-grok.
|
||||
# Interactive Grok images (grok-CLI conversation drivers) — selected for the
|
||||
# intake / secretary roles when their route resolves to GROK, instead of the
|
||||
# Claude prompter/secretary images. Their dockerfiles build FROM roboco-agent-grok.
|
||||
GROK_PROMPTER_IMAGE = "roboco-agent-grok-prompter"
|
||||
GROK_SECRETARY_IMAGE = "roboco-agent-grok-secretary"
|
||||
_GROK_INTERACTIVE_DOCKERFILES = {
|
||||
@@ -219,7 +220,6 @@ class _IntakeRunSpec:
|
||||
provider_auth_token: str | None
|
||||
provider_type: str = "anthropic"
|
||||
model: str = ""
|
||||
grok_variant: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -243,7 +243,6 @@ class _SecretaryRunSpec:
|
||||
provider_auth_token: str | None
|
||||
provider_type: str = "anthropic"
|
||||
model: str = ""
|
||||
grok_variant: str | None = None
|
||||
|
||||
|
||||
def _read_project_slug(task: dict[str, Any]) -> str | None:
|
||||
@@ -716,8 +715,8 @@ class AgentOrchestrator:
|
||||
# _maybe_kill_wedged_grok.
|
||||
self._grok_idle_kill_ttl: int = settings.grok_idle_kill_seconds
|
||||
# Cost ceiling (USD) before a live GROK container is killed — the budget
|
||||
# kill-switch parity (opencode exposes no usage hook). 0 disables. See
|
||||
# _enforce_grok_cost_budget.
|
||||
# kill-switch parity (the grok CLI exposes no live usage hook). 0 disables.
|
||||
# See _enforce_grok_cost_budget.
|
||||
self._grok_max_cost_usd: float = settings.grok_max_cost_usd
|
||||
|
||||
# =========================================================================
|
||||
@@ -878,27 +877,26 @@ class AgentOrchestrator:
|
||||
img, f"{docker_dir}/{dockerfile}", build_context
|
||||
)
|
||||
|
||||
def _ensure_opencode_data_dir(self, agent_id: str) -> None:
|
||||
"""Pre-create the agent's opencode store dir (world-writable) before the mount.
|
||||
def _ensure_grok_usage_dir(self, agent_id: str) -> None:
|
||||
"""Pre-create the agent's grok usage dir (world-writable) before the mount.
|
||||
|
||||
On Linux, ``docker run -v`` auto-creates a MISSING bind source as
|
||||
``root:root``, so the non-root ``agent`` user EACCESes on opencode's
|
||||
first write (``repos/``, ``opencode.db``) and ``opencode serve``/``run``
|
||||
dies at boot — the live intake crash. Creating the dir ``0777`` first
|
||||
makes the mounted store writable regardless of the agent uid; the
|
||||
orchestrator (root) can still read it back at finalize. Mirrors the
|
||||
container-vs-local split in ``_resolve_host_paths``.
|
||||
``root:root``, so the non-root ``agent`` user EACCESes when the grok
|
||||
entrypoint / interactive driver writes ``usage.json`` there. Creating the
|
||||
dir ``0777`` first makes the mounted dir writable regardless of the agent
|
||||
uid; the orchestrator (root) can still read it back at finalize. Mirrors
|
||||
the container-vs-local split in ``_resolve_host_paths``.
|
||||
"""
|
||||
if PROJECT_HOST_PATH:
|
||||
target = Path(OPENCODE_DATA_DIR) / agent_id
|
||||
target = Path(GROK_USAGE_DATA_DIR) / agent_id
|
||||
else:
|
||||
target = Path(tempfile.gettempdir()) / "roboco-opencode" / agent_id
|
||||
target = Path(tempfile.gettempdir()) / "roboco-grok-usage" / agent_id
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
target.chmod(0o777)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"could not pre-create opencode data dir; grok agent may EACCES",
|
||||
"could not pre-create grok usage dir; grok agent may EACCES",
|
||||
agent_id=agent_id,
|
||||
path=str(target),
|
||||
error=str(exc),
|
||||
@@ -1790,9 +1788,10 @@ class AgentOrchestrator:
|
||||
"workspaces": f"{DATA_HOST_PATH}/workspaces",
|
||||
"claude": CLAUDE_AUTH_HOST_PATH,
|
||||
"mcp_config": f"{DATA_HOST_PATH}/mcp-configs/{mcp_name}",
|
||||
# Per-agent opencode store (GROK only); the orchestrator reads it
|
||||
# back at finalize via the shared data volume (see OPENCODE_DATA_DIR).
|
||||
"opencode": f"{DATA_HOST_PATH}/opencode/{config.agent_id}",
|
||||
# Per-agent grok usage dir (GROK only); the orchestrator reads the
|
||||
# captured tokens back at finalize via the shared data volume
|
||||
# (see GROK_USAGE_DATA_DIR).
|
||||
"grok_usage": f"{DATA_HOST_PATH}/grok-usage/{config.agent_id}",
|
||||
"prompt": (
|
||||
f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
|
||||
),
|
||||
@@ -1812,8 +1811,8 @@ class AgentOrchestrator:
|
||||
"workspaces": str(Path(settings.workspaces_root)),
|
||||
"claude": CLAUDE_AUTH_HOST_PATH,
|
||||
"mcp_config": str(config.mcp_config_path),
|
||||
"opencode": str(
|
||||
Path(tempfile.gettempdir()) / "roboco-opencode" / config.agent_id
|
||||
"grok_usage": str(
|
||||
Path(tempfile.gettempdir()) / "roboco-grok-usage" / config.agent_id
|
||||
),
|
||||
"prompt": str(
|
||||
Path(tempfile.gettempdir())
|
||||
@@ -3022,17 +3021,13 @@ class AgentOrchestrator:
|
||||
else f"http://127.0.0.1:{settings.port}"
|
||||
)
|
||||
|
||||
# GROK runs the interactive driver on its own opencode-serve image; every
|
||||
# other provider uses the Claude SDK-driver prompter image.
|
||||
# GROK runs the interactive driver on its own grok-CLI prompter image;
|
||||
# every other provider uses the Claude SDK-driver prompter image.
|
||||
is_grok = route.provider_type == ModelProvider.GROK
|
||||
image = GROK_PROMPTER_IMAGE if is_grok else get_agent_image(INTAKE_AGENT_ID)
|
||||
grok_variant: str | None = None
|
||||
if is_grok:
|
||||
from roboco.llm.providers.grok import _reasoning_effort_for
|
||||
|
||||
grok_variant = _reasoning_effort_for(INTAKE_AGENT_ID)
|
||||
await self._ensure_grok_interactive_image(image)
|
||||
self._ensure_opencode_data_dir(INTAKE_AGENT_ID)
|
||||
self._ensure_grok_usage_dir(INTAKE_AGENT_ID)
|
||||
else:
|
||||
await self._ensure_agent_image(INTAKE_AGENT_ID)
|
||||
container_name = f"roboco-agent-{INTAKE_AGENT_ID}"
|
||||
@@ -3051,7 +3046,6 @@ class AgentOrchestrator:
|
||||
provider_auth_token=route.auth_token,
|
||||
provider_type=route.provider_type.value,
|
||||
model=route.model_name,
|
||||
grok_variant=grok_variant,
|
||||
)
|
||||
)
|
||||
container_id = await self._run_container_cmd(cmd)
|
||||
@@ -3076,8 +3070,8 @@ class AgentOrchestrator:
|
||||
|
||||
# Record a usage session (task_id=None) and pin its id on the instance so
|
||||
# the reap finalizer can look up token usage — without this an interactive
|
||||
# session finalizes at 0 tokens / $0 (the GROK path reads opencode.db by
|
||||
# this id; the Claude path reads the transcript). Mirrors _launch_spawn.
|
||||
# session finalizes at 0 tokens / $0 (the GROK path reads the captured
|
||||
# usage.json; the Claude path reads the transcript). Mirrors _launch_spawn.
|
||||
usage_session_id = await self._record_spawn_session(config, None)
|
||||
if usage_session_id is not None:
|
||||
instance.usage_session_id = usage_session_id
|
||||
@@ -3194,13 +3188,9 @@ class AgentOrchestrator:
|
||||
|
||||
is_grok = route.provider_type == ModelProvider.GROK
|
||||
image = GROK_SECRETARY_IMAGE if is_grok else get_agent_image(SECRETARY_AGENT_ID)
|
||||
grok_variant: str | None = None
|
||||
if is_grok:
|
||||
from roboco.llm.providers.grok import _reasoning_effort_for
|
||||
|
||||
grok_variant = _reasoning_effort_for(SECRETARY_AGENT_ID)
|
||||
await self._ensure_grok_interactive_image(image)
|
||||
self._ensure_opencode_data_dir(SECRETARY_AGENT_ID)
|
||||
self._ensure_grok_usage_dir(SECRETARY_AGENT_ID)
|
||||
else:
|
||||
await self._ensure_agent_image(SECRETARY_AGENT_ID)
|
||||
container_name = f"roboco-agent-{SECRETARY_AGENT_ID}"
|
||||
@@ -3222,7 +3212,6 @@ class AgentOrchestrator:
|
||||
provider_auth_token=route.auth_token,
|
||||
provider_type=route.provider_type.value,
|
||||
model=route.model_name,
|
||||
grok_variant=grok_variant,
|
||||
)
|
||||
)
|
||||
container_id = await self._run_container_cmd(cmd)
|
||||
@@ -3319,7 +3308,7 @@ class AgentOrchestrator:
|
||||
"prompt": (
|
||||
f"{DATA_HOST_PATH}/prompts-generated/{SECRETARY_AGENT_ID}-prompt.md"
|
||||
),
|
||||
"opencode": f"{DATA_HOST_PATH}/opencode/{SECRETARY_AGENT_ID}",
|
||||
"grok_usage": f"{DATA_HOST_PATH}/grok-usage/{SECRETARY_AGENT_ID}",
|
||||
}
|
||||
return {
|
||||
"claude": CLAUDE_AUTH_HOST_PATH,
|
||||
@@ -3328,8 +3317,8 @@ class AgentOrchestrator:
|
||||
/ "roboco-prompts"
|
||||
/ f"{SECRETARY_AGENT_ID}-prompt.md"
|
||||
),
|
||||
"opencode": str(
|
||||
Path(tempfile.gettempdir()) / "roboco-opencode" / SECRETARY_AGENT_ID
|
||||
"grok_usage": str(
|
||||
Path(tempfile.gettempdir()) / "roboco-grok-usage" / SECRETARY_AGENT_ID
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3436,7 +3425,7 @@ class AgentOrchestrator:
|
||||
f"{DATA_HOST_PATH}/prompts-generated/{INTAKE_AGENT_ID}-prompt.md"
|
||||
),
|
||||
"workspaces": f"{DATA_HOST_PATH}/workspaces",
|
||||
"opencode": f"{DATA_HOST_PATH}/opencode/{INTAKE_AGENT_ID}",
|
||||
"grok_usage": f"{DATA_HOST_PATH}/grok-usage/{INTAKE_AGENT_ID}",
|
||||
}
|
||||
return {
|
||||
"claude": CLAUDE_AUTH_HOST_PATH,
|
||||
@@ -3446,8 +3435,8 @@ class AgentOrchestrator:
|
||||
/ f"{INTAKE_AGENT_ID}-prompt.md"
|
||||
),
|
||||
"workspaces": str(Path(settings.workspaces_root)),
|
||||
"opencode": str(
|
||||
Path(tempfile.gettempdir()) / "roboco-opencode" / INTAKE_AGENT_ID
|
||||
"grok_usage": str(
|
||||
Path(tempfile.gettempdir()) / "roboco-grok-usage" / INTAKE_AGENT_ID
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3457,59 +3446,31 @@ class AgentOrchestrator:
|
||||
) -> None:
|
||||
"""Inject the per-provider LLM env for an interactive container.
|
||||
|
||||
GROK runs natively on opencode: ``OPENAI_*`` (xAI) + ``ROBOCO_AGENT_MODEL``
|
||||
+ the mounted system prompt the driver renders ``opencode.json`` from,
|
||||
plus the per-agent opencode store mount so finalize can read usage back.
|
||||
Every other provider uses the Claude path's ``ANTHROPIC_*`` injection (or
|
||||
the mounted ``~/.claude`` default when the route carries no creds).
|
||||
GROK runs on the official ``grok`` CLI, exactly like the one-shot path:
|
||||
the subscription auth (``~/.grok/auth.json``) is mounted read-only, no
|
||||
metered xAI key is used, the per-agent data dir is mounted so the driver's
|
||||
per-turn usage capture lands a ``usage.json`` the finalizer reads back, and
|
||||
the per-role permissions / reasoning come from the grok flags the driver
|
||||
computes (``grok_cli_config``) — not env. Every other provider uses the
|
||||
Claude path's ``ANTHROPIC_*`` injection (or the mounted ``~/.claude``
|
||||
default when the route carries no creds).
|
||||
"""
|
||||
from roboco.llm.providers.grok import GrokCliProvider
|
||||
from roboco.models.base import ModelProvider
|
||||
|
||||
base_url = spec.provider_base_url
|
||||
auth_token = spec.provider_auth_token
|
||||
if spec.provider_type == ModelProvider.GROK.value:
|
||||
opencode_host = spec.hosts.get("opencode")
|
||||
if opencode_host:
|
||||
# opencode persists usage to opencode.db under its data dir; the
|
||||
# per-agent host mount lets the finalizer read it (same in-
|
||||
# container path as the one-shot Grok store).
|
||||
cmd.extend(["-v", f"{opencode_host}:/home/agent/.local/share/opencode"])
|
||||
cmd.extend(
|
||||
[
|
||||
# Built-in xai provider authenticates from XAI_API_KEY and
|
||||
# reads XAI_BASE_URL; opencode_config emits no provider block,
|
||||
# so these envs are the only LLM wiring needed.
|
||||
"-e",
|
||||
f"XAI_API_KEY={auth_token or ''}",
|
||||
"-e",
|
||||
f"XAI_BASE_URL={base_url or 'https://api.x.ai/v1'}",
|
||||
"-e",
|
||||
f"ROBOCO_AGENT_MODEL={spec.model}",
|
||||
"-e",
|
||||
"ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md",
|
||||
]
|
||||
)
|
||||
# Both interactive roles are read-only conversational agents: no code
|
||||
# edits, no shell (Claude-parity — the SDK gates deny everything but
|
||||
# Read/Grep/Glob + their tools). Intake reads sibling product repos
|
||||
# that sit OUTSIDE its cwd, so it keeps external-directory reads; the
|
||||
# Secretary only reads /app + the API, so it doesn't.
|
||||
is_intake = isinstance(spec, _IntakeRunSpec)
|
||||
GrokCliProvider._append_grok_auth_mount(cmd)
|
||||
GrokCliProvider._append_usage_mount(cmd, spec.hosts)
|
||||
cmd.extend(
|
||||
[
|
||||
"-e",
|
||||
"ROBOCO_GROK_EDIT_PERMISSION=deny",
|
||||
"ROBOCO_AGENT_MODEL=grok-build",
|
||||
"-e",
|
||||
"ROBOCO_GROK_BASH_PERMISSION=deny",
|
||||
"-e",
|
||||
"ROBOCO_GROK_EXTERNAL_DIR_PERMISSION="
|
||||
f"{'allow' if is_intake else 'deny'}",
|
||||
"ROBOCO_GROK_USAGE_FILE=/home/agent/.grok-usage/usage.json",
|
||||
]
|
||||
)
|
||||
# Per-role reasoning effort: the opencode-serve driver passes this as
|
||||
# the message `variant` (same lever as the one-shot --variant).
|
||||
if spec.grok_variant:
|
||||
cmd.extend(["-e", f"ROBOCO_GROK_VARIANT={spec.grok_variant}"])
|
||||
return
|
||||
if base_url:
|
||||
cmd.extend(["-e", f"ANTHROPIC_BASE_URL={base_url}"])
|
||||
@@ -3556,7 +3517,7 @@ class AgentOrchestrator:
|
||||
f"CLAUDE_CODE_SUBAGENT_MODEL={spec.cli_model}",
|
||||
]
|
||||
)
|
||||
# GROK runs opencode (OPENAI_* + opencode store); other providers use the
|
||||
# GROK mounts the subscription auth + usage dir; other providers use the
|
||||
# ANTHROPIC_* injection or the mounted ~/.claude default.
|
||||
AgentOrchestrator._append_interactive_provider_env(cmd, spec)
|
||||
cmd.append(spec.image)
|
||||
@@ -3923,77 +3884,70 @@ class AgentOrchestrator:
|
||||
except OSError:
|
||||
return (0, 0, 0, 0)
|
||||
|
||||
def _opencode_db_path(self, agent_id: str) -> str:
|
||||
"""In-orchestrator path to a GROK agent's opencode SQLite store.
|
||||
def _grok_usage_json(self, agent_id: str) -> dict[str, Any] | None:
|
||||
"""Read a GROK agent's ``usage.json`` (``{model, total_tokens, cost_usd}``).
|
||||
|
||||
The agent writes opencode.db under the shared data volume; the compose
|
||||
file mounts that host dir at ``OPENCODE_DATA_DIR`` here, so finalize can
|
||||
read it back — the opencode analogue of the mounted Claude transcript.
|
||||
Written to the per-agent data dir by the grok-CLI entrypoint (one-shot,
|
||||
post-run) and the interactive driver (per-turn); the orchestrator sees it
|
||||
at ``GROK_USAGE_DATA_DIR``. Returns ``None`` when absent / unreadable.
|
||||
"""
|
||||
return str(Path(OPENCODE_DATA_DIR) / agent_id / "opencode.db")
|
||||
usage_json = Path(GROK_USAGE_DATA_DIR) / agent_id / "usage.json"
|
||||
try:
|
||||
data = json.loads(usage_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
def _grok_usage_from_opencode(self, agent_id: str) -> tuple[int, int, int, int]:
|
||||
"""Sum a GROK agent's token usage from its per-agent data dir.
|
||||
def _grok_usage_tokens(self, agent_id: str) -> tuple[int, int, int, int]:
|
||||
"""A GROK agent's token usage from its ``usage.json``.
|
||||
|
||||
The grok-CLI one-shot path writes a ``usage.json`` (``total_tokens``)
|
||||
there post-run — read that first. The interactive opencode-serve path
|
||||
still lands its usage in ``opencode.db``, so fall back to it. The grok
|
||||
total folds into output (it bills at the output rate, matching
|
||||
``calculate_cost``). A WARNING is logged on a 0-token read because a
|
||||
grok reports a single cumulative total with no input/output split, so it
|
||||
folds into output (it bills at the output rate, matching
|
||||
``calculate_cost``). A WARNING is logged on a missing/zero read because a
|
||||
silent mount/uid failure is otherwise indistinguishable from a genuine
|
||||
zero-cost run. Returns ``(input, output, cache_read, cache_write)``.
|
||||
"""
|
||||
cli_tokens = self._grok_cli_total_tokens(agent_id)
|
||||
if cli_tokens is not None:
|
||||
return (0, cli_tokens, 0, 0)
|
||||
|
||||
from roboco.llm.providers.opencode_usage import read_session_usage
|
||||
|
||||
db_path = self._opencode_db_path(agent_id)
|
||||
usage = read_session_usage(db_path)
|
||||
if usage is None:
|
||||
data = self._grok_usage_json(agent_id)
|
||||
total = 0
|
||||
if data:
|
||||
try:
|
||||
total = int(data.get("total_tokens", 0))
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
if not total:
|
||||
logger.warning(
|
||||
"GROK agent finalized with no readable usage "
|
||||
"(0 tokens / $0) — check the data dir mount",
|
||||
agent_id=agent_id,
|
||||
db_path=db_path,
|
||||
)
|
||||
return (0, 0, 0, 0)
|
||||
return (
|
||||
usage.tokens_input,
|
||||
usage.tokens_output + usage.tokens_reasoning,
|
||||
usage.tokens_cache_read,
|
||||
usage.tokens_cache_write,
|
||||
)
|
||||
return (0, total, 0, 0)
|
||||
|
||||
@staticmethod
|
||||
def _grok_cli_total_tokens(agent_id: str) -> int | None:
|
||||
"""Total tokens from a grok-CLI ``usage.json``, or None if absent.
|
||||
|
||||
The grok-cli entrypoint writes ``{model, total_tokens, cost_usd}`` to the
|
||||
per-agent data dir; the orchestrator sees it at ``OPENCODE_DATA_DIR``.
|
||||
"""
|
||||
usage_json = Path(OPENCODE_DATA_DIR) / agent_id / "usage.json"
|
||||
def _grok_cost_usd(self, agent_id: str) -> float:
|
||||
"""A GROK agent's captured notional cost from its ``usage.json`` (0 if none)."""
|
||||
data = self._grok_usage_json(agent_id)
|
||||
if not data:
|
||||
return 0.0
|
||||
try:
|
||||
data = json.loads(usage_json.read_text(encoding="utf-8"))
|
||||
return int(data.get("total_tokens", 0))
|
||||
except (OSError, json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
return float(data.get("cost_usd", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
async def _enforce_grok_cost_budget(self) -> None:
|
||||
"""Kill a live GROK container whose cumulative opencode cost exceeds the cap.
|
||||
"""Kill a live GROK container whose captured cost exceeds the cap.
|
||||
|
||||
opencode exposes no token/budget hook to a plugin, so the budget
|
||||
kill-switch (Claude Code parity for runaway token burn — a loop that
|
||||
keeps firing verbs evades the idle watchdog but still burns cost) lives
|
||||
here: read each ACTIVE GROK container's cumulative cost from its opencode
|
||||
store and kill + evict it past ``ROBOCO_GROK_MAX_COST_USD``. The reaper
|
||||
then releases the freed task. Disabled (no-op) when the cap is <= 0.
|
||||
The grok CLI exposes no live token/budget hook, so the budget kill-switch
|
||||
(Claude Code parity for runaway token burn — a loop that keeps firing
|
||||
verbs evades the idle watchdog but still burns cost) reads each ACTIVE
|
||||
GROK container's captured cost from its ``usage.json`` and kills + evicts
|
||||
it past ``ROBOCO_GROK_MAX_COST_USD``. The reaper then releases the freed
|
||||
task. This bites on the interactive sessions (the driver rewrites
|
||||
usage.json every turn, so a runaway chat is caught between turns); a
|
||||
one-shot ``grok -p`` writes usage.json only post-run and is bounded by its
|
||||
``--max-turns`` cap instead. Disabled (no-op) when the cap is <= 0.
|
||||
"""
|
||||
cap = getattr(self, "_grok_max_cost_usd", 0.0)
|
||||
if cap <= 0:
|
||||
return
|
||||
from roboco.llm.providers.opencode_usage import cost_for_session
|
||||
from roboco.models.base import ModelProvider
|
||||
|
||||
for agent_id, instance in list(self._instances.items()):
|
||||
@@ -4004,9 +3958,7 @@ class AgentOrchestrator:
|
||||
or instance.state != AgentState.ACTIVE
|
||||
):
|
||||
continue
|
||||
_, cost = cost_for_session(
|
||||
config.model or "", self._opencode_db_path(agent_id)
|
||||
)
|
||||
cost = self._grok_cost_usd(agent_id)
|
||||
if cost <= cap:
|
||||
continue
|
||||
try:
|
||||
@@ -4040,17 +3992,17 @@ class AgentOrchestrator:
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Resolve final token counts for a stopping agent.
|
||||
|
||||
For a GROK agent, reads the opencode SQLite store (no SDK server / Claude
|
||||
transcript exists). Otherwise tries the live SDK ``/usage/status`` first;
|
||||
if that misses — the SDK's in-memory counts race container teardown for
|
||||
short-lived agents — it falls back to the agent's Claude Code transcript,
|
||||
which is durable and mounted into this container. Returns
|
||||
For a GROK agent, reads the captured ``usage.json`` (no SDK server /
|
||||
Claude transcript exists). Otherwise tries the live SDK ``/usage/status``
|
||||
first; if that misses — the SDK's in-memory counts race container teardown
|
||||
for short-lived agents — it falls back to the agent's Claude Code
|
||||
transcript, which is durable and mounted into this container. Returns
|
||||
``(input, output, cache_read, cache_write)``.
|
||||
"""
|
||||
from roboco.models.base import ModelProvider
|
||||
|
||||
if self.get_provider_for_agent(agent_id) == ModelProvider.GROK.value:
|
||||
return self._grok_usage_from_opencode(agent_id)
|
||||
return self._grok_usage_tokens(agent_id)
|
||||
|
||||
tokens = (0, 0, 0, 0)
|
||||
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
|
||||
@@ -6701,7 +6653,7 @@ Start now: evidence(task_id="{task_id}")
|
||||
|
||||
``_assignee_has_active_instance`` shields a live container from the
|
||||
reaper — correct for a Claude agent quiet during a long edit/test cycle.
|
||||
A wedged opencode container is the one case that breaks: ACTIVE *and*
|
||||
A wedged GROK container is the one case that breaks: ACTIVE *and*
|
||||
silent (an idle model call fires no gateway verb), so its heartbeat never
|
||||
advances and the skip would protect it forever. Returns the slug only for
|
||||
a GROK instance idle past the grok-kill TTL — a recent heartbeat, no
|
||||
@@ -6774,8 +6726,8 @@ Start now: evidence(task_id="{task_id}")
|
||||
ts = t.last_heartbeat_at
|
||||
if ts is None or ts < cutoff:
|
||||
# A live container normally protects its task. The sole exception
|
||||
# is a wedged GROK (opencode) container — ACTIVE yet firing no
|
||||
# verb — which the live-instance skip would shield forever. Kill +
|
||||
# is a wedged GROK container — ACTIVE yet firing no verb — which
|
||||
# the live-instance skip would shield forever. Kill +
|
||||
# evict it past the grok-idle TTL (then fall through to release);
|
||||
# a live non-grok agent, or a grok within the TTL, is skipped.
|
||||
if self._assignee_has_active_instance(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""grok_cli_session — the pure streaming-json → StreamChunk mapper.
|
||||
|
||||
The subprocess runner (``GrokCliSession``) needs the live grok binary, so it is
|
||||
not gate-covered; the turn-mapping logic lives in the pure ``_StreamAssembler``
|
||||
and is fully exercised here by feeding it parsed events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from roboco.agent_sdk.grok_cli_session import (
|
||||
_classify_failure,
|
||||
_parse_event,
|
||||
_StreamAssembler,
|
||||
)
|
||||
|
||||
|
||||
def _kinds(chunks: list) -> list[str]:
|
||||
return [c.kind for c in chunks]
|
||||
|
||||
|
||||
def test_thought_deltas_coalesce_into_one_thinking_block() -> None:
|
||||
a = _StreamAssembler()
|
||||
out: list = []
|
||||
for piece in ("Let", " me", " think"):
|
||||
out += a.feed({"type": "thought", "data": piece})
|
||||
# Nothing emitted until the answer starts (reasoning shown as one block).
|
||||
assert out == []
|
||||
out += a.feed({"type": "text", "data": "Hello"})
|
||||
assert _kinds(out) == ["thinking", "text"]
|
||||
assert out[0].text == "Let me think"
|
||||
assert out[1].text == "Hello"
|
||||
|
||||
|
||||
def test_text_deltas_stream_live() -> None:
|
||||
a = _StreamAssembler()
|
||||
out: list = []
|
||||
for piece in ("a", "b", "c"):
|
||||
out += a.feed({"type": "text", "data": piece})
|
||||
assert _kinds(out) == ["text", "text", "text"]
|
||||
assert "".join(c.text for c in out) == "abc"
|
||||
|
||||
|
||||
def test_end_captures_session_id_and_emits_turn_end() -> None:
|
||||
a = _StreamAssembler()
|
||||
a.feed({"type": "text", "data": "hi"})
|
||||
out = a.feed({"type": "end", "sessionId": "sid-9", "stopReason": "EndTurn"})
|
||||
assert _kinds(out) == ["turn_end"]
|
||||
assert a.session_id == "sid-9"
|
||||
assert a.saw_end is True
|
||||
assert out[-1].data["session_id"] == "sid-9"
|
||||
|
||||
|
||||
def test_end_flushes_pending_thinking_before_turn_end() -> None:
|
||||
a = _StreamAssembler()
|
||||
a.feed({"type": "thought", "data": "reasoning only"})
|
||||
out = a.feed({"type": "end", "sessionId": "s", "stopReason": "EndTurn"})
|
||||
assert _kinds(out) == ["thinking", "turn_end"]
|
||||
|
||||
|
||||
def test_fenced_draft_is_surfaced_as_a_draft_chunk() -> None:
|
||||
a = _StreamAssembler()
|
||||
draft = {"title": "Build X", "objective": "do it"}
|
||||
a.feed({"type": "text", "data": "Here:\n```roboco-draft\n"})
|
||||
a.feed({"type": "text", "data": json.dumps(draft)})
|
||||
a.feed({"type": "text", "data": "\n```\n"})
|
||||
out = a.feed({"type": "end", "sessionId": "s", "stopReason": "EndTurn"})
|
||||
assert "draft" in _kinds(out)
|
||||
draft_chunk = next(c for c in out if c.kind == "draft")
|
||||
assert draft_chunk.data["title"] == "Build X"
|
||||
|
||||
|
||||
def test_unknown_event_types_are_ignored() -> None:
|
||||
a = _StreamAssembler()
|
||||
assert a.feed({"type": "tool", "name": "whatever"}) == []
|
||||
assert a.feed({"type": "", "data": "x"}) == []
|
||||
|
||||
|
||||
def test_parse_event_is_tolerant() -> None:
|
||||
assert _parse_event('{"type":"text","data":"x"}') == {"type": "text", "data": "x"}
|
||||
assert _parse_event("not json") is None
|
||||
assert _parse_event("[1,2,3]") is None # not a dict
|
||||
|
||||
|
||||
def test_classify_failure_detects_rate_limit() -> None:
|
||||
msg = _classify_failure(1, "xAI error: 429 too many requests")
|
||||
assert "rate-limited" in msg.lower()
|
||||
|
||||
|
||||
def test_classify_failure_generic_uses_last_stderr_line() -> None:
|
||||
msg = _classify_failure(2, "warming up\nboom: the model exploded")
|
||||
assert "boom: the model exploded" in msg
|
||||
# With no stderr, the exit code is surfaced.
|
||||
assert "exit code 2" in _classify_failure(2, "")
|
||||
@@ -1,130 +0,0 @@
|
||||
"""normalize_opencode_message maps an opencode message reply to panel chunks.
|
||||
|
||||
The OpencodeServeSession transport (subprocess + HTTP) is exercised live against
|
||||
a real `opencode serve`; the deterministic message→chunk mapping, the
|
||||
turn-level error surfacing, and session-id extraction are covered here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from roboco.agent_sdk.opencode_session import (
|
||||
OpencodeServeSession,
|
||||
_extract_session_id,
|
||||
_message_error,
|
||||
normalize_opencode_message,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.agent_sdk.intake_driver import StreamChunk
|
||||
|
||||
|
||||
def _kinds(chunks: list[StreamChunk]) -> list[str]:
|
||||
return [c.kind for c in chunks]
|
||||
|
||||
|
||||
def test_text_part_emits_text_then_turn_end() -> None:
|
||||
chunks = normalize_opencode_message({"parts": [{"type": "text", "text": "Hi"}]})
|
||||
assert _kinds(chunks) == ["text", "turn_end"]
|
||||
assert chunks[0].text == "Hi"
|
||||
|
||||
|
||||
def test_reasoning_part_maps_to_thinking() -> None:
|
||||
chunks = normalize_opencode_message({"parts": [{"type": "reasoning", "text": "x"}]})
|
||||
assert chunks[0].kind == "thinking"
|
||||
assert chunks[0].text == "x"
|
||||
|
||||
|
||||
def test_tool_part_maps_to_tool_use() -> None:
|
||||
chunks = normalize_opencode_message(
|
||||
{"parts": [{"type": "tool", "tool": "read", "input": {"path": "x"}}]}
|
||||
)
|
||||
tool = next(c for c in chunks if c.kind == "tool_use")
|
||||
assert tool.tool == "read"
|
||||
assert tool.data == {"input": {"path": "x"}}
|
||||
|
||||
|
||||
def test_fenced_draft_in_text_becomes_draft_chunk() -> None:
|
||||
fenced = '```roboco-draft\n{"title": "Add login"}\n```'
|
||||
chunks = normalize_opencode_message({"parts": [{"type": "text", "text": fenced}]})
|
||||
draft = next(c for c in chunks if c.kind == "draft")
|
||||
assert draft.data["title"] == "Add login"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_on_dead_serve_yields_clear_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# A crashed `opencode serve` must surface a clear error + end the turn, not
|
||||
# hang the chat with opaque connection errors while the container zombies.
|
||||
sess = OpencodeServeSession()
|
||||
monkeypatch.setattr(sess, "_session_id", "ses-1")
|
||||
monkeypatch.setattr(sess, "_client", object()) # unused: dead-proc guard wins
|
||||
monkeypatch.setattr(sess, "_proc", type("P", (), {"returncode": 1})())
|
||||
chunks = [c async for c in sess.send("hi")]
|
||||
assert [c.kind for c in chunks] == ["error", "turn_end"]
|
||||
assert "exited" in chunks[0].text
|
||||
|
||||
|
||||
def test_propose_draft_tool_part_becomes_draft_chunk() -> None:
|
||||
# The intake-tools.js propose_draft tool call (its input nested under
|
||||
# `draft`) is intercepted into a draft chunk — NOT rendered as a tool_use —
|
||||
# so the panel shows the draft card. This is the primary Grok-intake path.
|
||||
chunks = normalize_opencode_message(
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool",
|
||||
"tool": "propose_draft",
|
||||
"input": {"draft": {"title": "Add login", "team": "backend"}},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert "tool_use" not in _kinds(chunks)
|
||||
draft = next(c for c in chunks if c.kind == "draft")
|
||||
assert draft.data["title"] == "Add login"
|
||||
assert draft.data["team"] == "backend"
|
||||
|
||||
|
||||
def test_unknown_part_skipped_but_turn_still_ends() -> None:
|
||||
chunks = normalize_opencode_message({"parts": [{"type": "mystery", "x": 1}]})
|
||||
assert _kinds(chunks) == ["turn_end"]
|
||||
|
||||
|
||||
def test_empty_message_yields_only_turn_end() -> None:
|
||||
assert _kinds(normalize_opencode_message({"parts": []})) == ["turn_end"]
|
||||
|
||||
|
||||
def test_turn_level_error_is_surfaced_not_blank() -> None:
|
||||
# A model failure lands in info.error with parts=[]; it must NOT render blank.
|
||||
msg = {
|
||||
"info": {
|
||||
"role": "assistant",
|
||||
"error": {
|
||||
"name": "APIError",
|
||||
"data": {"message": "Incorrect API key provided"},
|
||||
},
|
||||
},
|
||||
"parts": [],
|
||||
}
|
||||
chunks = normalize_opencode_message(msg)
|
||||
assert _kinds(chunks) == ["error", "turn_end"]
|
||||
assert "Incorrect API key" in chunks[0].text
|
||||
|
||||
|
||||
def test_message_error_extraction() -> None:
|
||||
assert _message_error({"info": {"error": {"data": {"message": "boom"}}}}) == "boom"
|
||||
assert _message_error({"info": {"error": {"name": "APIError"}}}) == "APIError"
|
||||
assert _message_error({"info": {}}) is None
|
||||
assert _message_error({"parts": []}) is None
|
||||
|
||||
|
||||
def test_extract_session_id_is_tolerant() -> None:
|
||||
assert _extract_session_id({"id": "s1"}) == "s1"
|
||||
assert _extract_session_id({"sessionID": "s2"}) == "s2"
|
||||
assert _extract_session_id({"info": {"id": "s3"}}) == "s3"
|
||||
assert _extract_session_id({}) is None
|
||||
assert _extract_session_id("nope") is None
|
||||
@@ -10,6 +10,8 @@ from roboco.llm.providers import grok_cli_usage as gu
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_updates(path: Path, totals: list[int]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -79,7 +81,9 @@ def test_usage_and_cost_prices_total_at_output_rate() -> None:
|
||||
assert abs(cost - 2.00) < 1e-6 # noqa: PLR2004
|
||||
|
||||
|
||||
def test_main_writes_usage_file(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_main_writes_usage_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
home = tmp_path / ".grok"
|
||||
cwd = "/ws/be-dev-1"
|
||||
sid = "sid-1"
|
||||
@@ -89,6 +93,7 @@ def test_main_writes_usage_file(tmp_path: Path, monkeypatch) -> None: # type: i
|
||||
monkeypatch.setattr(gu, "USAGE_OUT_PATH", out)
|
||||
monkeypatch.setenv("GROK_HOME", str(home))
|
||||
monkeypatch.setenv("ROBOCO_GROK_RUN_CWD", cwd)
|
||||
monkeypatch.delenv("ROBOCO_GROK_RUN_LOG", raising=False)
|
||||
monkeypatch.setenv("ROBOCO_AGENT_SESSION_ID", sid)
|
||||
monkeypatch.setenv("ROBOCO_AGENT_MODEL", "grok-build")
|
||||
assert gu.main() == 0
|
||||
@@ -96,3 +101,76 @@ def test_main_writes_usage_file(tmp_path: Path, monkeypatch) -> None: # type: i
|
||||
assert data["total_tokens"] == 1234 # noqa: PLR2004
|
||||
assert data["model"] == "grok-build"
|
||||
assert data["cost_usd"] > 0.0
|
||||
|
||||
|
||||
def test_capture_session_usage_writes_running_total(tmp_path: Path) -> None:
|
||||
home = tmp_path / ".grok"
|
||||
cwd = "/ws/intake-1"
|
||||
sid = "sid-x"
|
||||
target = home / "sessions" / "%2Fws%2Fintake-1" / sid
|
||||
_write_updates(target / "updates.jsonl", [100, 900, 500])
|
||||
out = tmp_path / "usage.json"
|
||||
tokens = gu.capture_session_usage(
|
||||
cwd=cwd, session_id=sid, model="grok-build", out_path=out, grok_home=home
|
||||
)
|
||||
assert tokens == 900 # noqa: PLR2004 — the running max is the chat total
|
||||
data = json.loads(out.read_text())
|
||||
assert data["total_tokens"] == 900 # noqa: PLR2004
|
||||
assert data["cost_usd"] > 0.0
|
||||
|
||||
|
||||
def test_capture_session_usage_zero_when_session_absent(tmp_path: Path) -> None:
|
||||
out = tmp_path / "usage.json"
|
||||
tokens = gu.capture_session_usage(
|
||||
cwd="/ws/x",
|
||||
session_id="missing",
|
||||
model="grok-build",
|
||||
out_path=out,
|
||||
grok_home=tmp_path / ".grok",
|
||||
)
|
||||
assert tokens == 0
|
||||
# A zero session still writes a usage file (a real zero-cost run).
|
||||
assert json.loads(out.read_text())["total_tokens"] == 0
|
||||
|
||||
|
||||
def test_session_id_from_run_log_reads_the_real_id(tmp_path: Path) -> None:
|
||||
log = tmp_path / "run.json"
|
||||
log.write_text(
|
||||
json.dumps({"text": "ok", "sessionId": "019edd9d-real", "stopReason": "End"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert gu.session_id_from_run_log(log) == "019edd9d-real"
|
||||
|
||||
|
||||
def test_session_id_from_run_log_none_for_bad_log(tmp_path: Path) -> None:
|
||||
assert gu.session_id_from_run_log(tmp_path / "absent.json") is None
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text("not json", encoding="utf-8")
|
||||
assert gu.session_id_from_run_log(bad) is None
|
||||
idless = tmp_path / "idless.json"
|
||||
idless.write_text(json.dumps({"text": "ok"}), encoding="utf-8")
|
||||
assert gu.session_id_from_run_log(idless) is None
|
||||
|
||||
|
||||
def test_main_prefers_run_log_session_id(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# grok ignores a requested id, so the real id comes from the run log — it must
|
||||
# win over the ROBOCO_AGENT_SESSION_ID fallback (which points at no store).
|
||||
home = tmp_path / ".grok"
|
||||
cwd = "/ws/be-dev-1"
|
||||
real_sid = "real-sid"
|
||||
_write_updates(
|
||||
home / "sessions" / "%2Fws%2Fbe-dev-1" / real_sid / "updates.jsonl", [777]
|
||||
)
|
||||
run_log = tmp_path / "run.json"
|
||||
run_log.write_text(json.dumps({"sessionId": real_sid}), encoding="utf-8")
|
||||
out = tmp_path / "usage.json"
|
||||
monkeypatch.setattr(gu, "USAGE_OUT_PATH", out)
|
||||
monkeypatch.setenv("GROK_HOME", str(home))
|
||||
monkeypatch.setenv("ROBOCO_GROK_RUN_CWD", cwd)
|
||||
monkeypatch.setenv("ROBOCO_GROK_RUN_LOG", str(run_log))
|
||||
monkeypatch.setenv("ROBOCO_AGENT_SESSION_ID", "ignored-fallback")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_MODEL", "grok-build")
|
||||
assert gu.main() == 0
|
||||
assert json.loads(out.read_text())["total_tokens"] == 777 # noqa: PLR2004
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Tests for the Grok opencode.json generator (RoboCo MCP -> opencode config)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.llm.providers.opencode_config import (
|
||||
OpencodeGuards,
|
||||
build_opencode_config,
|
||||
translate_mcp_servers,
|
||||
)
|
||||
|
||||
_MODEL = "grok-build-0.1"
|
||||
|
||||
_MCP = {
|
||||
"mcpServers": {
|
||||
"roboco-flow": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"],
|
||||
"env": {
|
||||
"ROBOCO_AGENT_ID": "uuid-1",
|
||||
"UV_PROJECT_ENVIRONMENT": "/app/.venv",
|
||||
},
|
||||
},
|
||||
"roboco-do": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.do_server"],
|
||||
"env": {"ROBOCO_AGENT_ID": "uuid-1"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_translate_mcp_servers_shape() -> None:
|
||||
out = translate_mcp_servers(_MCP)
|
||||
flow = out["roboco-flow"]
|
||||
assert flow["type"] == "local"
|
||||
assert flow["enabled"] is True
|
||||
# command + args collapse into a single command array (opencode shape).
|
||||
assert flow["command"] == [
|
||||
"uv",
|
||||
"run",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"-m",
|
||||
"roboco.mcp.flow_server",
|
||||
]
|
||||
# env -> environment (opencode key).
|
||||
assert flow["environment"]["ROBOCO_AGENT_ID"] == "uuid-1"
|
||||
assert "env" not in flow
|
||||
assert set(out) == {"roboco-flow", "roboco-do"}
|
||||
|
||||
|
||||
def test_translate_mcp_servers_empty() -> None:
|
||||
assert translate_mcp_servers({}) == {}
|
||||
assert translate_mcp_servers({"mcpServers": {}}) == {}
|
||||
|
||||
|
||||
def test_translate_mcp_servers_omits_environment_when_no_env() -> None:
|
||||
out = translate_mcp_servers(
|
||||
{"mcpServers": {"x": {"command": "uv", "args": ["run"]}}}
|
||||
)
|
||||
assert "environment" not in out["x"]
|
||||
assert out["x"]["command"] == ["uv", "run"]
|
||||
|
||||
|
||||
def test_build_opencode_config_emits_no_provider_block() -> None:
|
||||
cfg = build_opencode_config(
|
||||
_MCP,
|
||||
_MODEL,
|
||||
instruction_paths=["/app/system-prompt.md"],
|
||||
)
|
||||
# CRITICAL: NO provider block. ANY provider.xai block breaks plugin-tool
|
||||
# registration on opencode 1.17.8 (verified live). The built-in xai provider
|
||||
# drives the model; the key reaches it via the XAI_API_KEY env var.
|
||||
assert "provider" not in cfg
|
||||
# Top-level model selector is "<provider>/<model>".
|
||||
assert cfg["model"] == "xai/grok-build-0.1"
|
||||
# Gateway servers carried through.
|
||||
assert "roboco-flow" in cfg["mcp"]
|
||||
assert cfg["instructions"] == ["/app/system-prompt.md"]
|
||||
|
||||
|
||||
def test_build_opencode_config_has_no_plugin_array() -> None:
|
||||
# opencode 1.17.8 ignores config `plugin:`-array absolute paths for
|
||||
# registration; plugins live in the auto-discovery dir, baked into the images.
|
||||
cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
|
||||
assert "plugin" not in cfg
|
||||
|
||||
|
||||
def test_build_opencode_config_edit_permission_is_tunable() -> None:
|
||||
# Read-only roles (qa / pr_reviewer / auditor / PMs / board) get edit=deny so
|
||||
# a Grok agent can't write code on a role that must never touch the tree.
|
||||
cfg = build_opencode_config(
|
||||
{},
|
||||
_MODEL,
|
||||
instruction_paths=[],
|
||||
guards=OpencodeGuards(edit_permission="deny"),
|
||||
)
|
||||
assert cfg["permission"]["edit"] == "deny"
|
||||
|
||||
|
||||
def test_build_opencode_config_bash_permission_is_tunable() -> None:
|
||||
cfg = build_opencode_config(
|
||||
{},
|
||||
_MODEL,
|
||||
instruction_paths=[],
|
||||
guards=OpencodeGuards(bash_permission="deny"),
|
||||
)
|
||||
assert cfg["permission"]["bash"] == "deny"
|
||||
assert cfg["permission"]["edit"] == "allow"
|
||||
|
||||
|
||||
def test_build_opencode_config_allows_external_directory_by_default() -> None:
|
||||
# opencode auto-denies an "ask" external-dir read in headless mode (the
|
||||
# pr-reviewer couldn't read a diff it wrote to /tmp); default "allow".
|
||||
cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
|
||||
assert cfg["permission"]["external_directory"] == "allow"
|
||||
|
||||
|
||||
def test_build_opencode_config_external_directory_is_tunable() -> None:
|
||||
cfg = build_opencode_config(
|
||||
{},
|
||||
_MODEL,
|
||||
instruction_paths=[],
|
||||
guards=OpencodeGuards(external_directory_permission="deny"),
|
||||
)
|
||||
assert cfg["permission"]["external_directory"] == "deny"
|
||||
|
||||
|
||||
def test_build_opencode_config_disables_subagent_task_tool_by_default() -> None:
|
||||
# The subagent `task` tool must be hard-disabled: a RoboCo role never uses
|
||||
# opencode-internal subagents, and one spawned on grok-build-0.1 hung the run.
|
||||
cfg = build_opencode_config(_MCP, _MODEL, instruction_paths=[])
|
||||
assert cfg["tools"] == {"task": False}
|
||||
|
||||
|
||||
def test_build_opencode_config_subagents_can_be_re_enabled() -> None:
|
||||
cfg = build_opencode_config(
|
||||
_MCP,
|
||||
_MODEL,
|
||||
instruction_paths=[],
|
||||
guards=OpencodeGuards(disable_subagents=False),
|
||||
)
|
||||
assert "tools" not in cfg
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Tests for opencode usage capture (reading the opencode SQLite session table).
|
||||
|
||||
The fixture DB mirrors the real opencode v1.x ``session`` table columns observed
|
||||
from a local run (cost + tokens_input/output/reasoning/cache_read/cache_write).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.llm.providers.opencode_usage import (
|
||||
cost_for_session,
|
||||
read_session_usage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_M = 1_000_000
|
||||
_TOL = 1e-4
|
||||
_ZERO_COST = 0.0
|
||||
|
||||
# Single-session fixture: input, output, reasoning, cache_read, cache_write.
|
||||
_IN, _OUT, _REASON, _CREAD, _CWRITE = 100, 50, 10, 20, 5
|
||||
# Second session for the summation test.
|
||||
_S2_IN, _S2_OUT, _S2_CREAD = 200, 70, 10
|
||||
# grok-build-0.1: 1M input ($1.00) + 1M output ($2.00) = $3.00.
|
||||
_GROK_COST_1M_1M = 3.00
|
||||
|
||||
# A REAL grok-build-0.1 session row observed from a live opencode run. Our
|
||||
# pricing must reproduce opencode's own stored `cost` (= xAI authoritative).
|
||||
_REAL_IN, _REAL_OUT, _REAL_REASON, _REAL_CREAD = 6120, 1, 226, 1856
|
||||
_REAL_COST = 0.0069452
|
||||
|
||||
|
||||
def _make_db(
|
||||
path: Path, rows: list[tuple[str, int, int, int, int, int, float]]
|
||||
) -> None:
|
||||
con = sqlite3.connect(path)
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY,
|
||||
tokens_input integer DEFAULT 0 NOT NULL,
|
||||
tokens_output integer DEFAULT 0 NOT NULL,
|
||||
tokens_reasoning integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_write integer DEFAULT 0 NOT NULL,
|
||||
cost real DEFAULT 0 NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.executemany(
|
||||
"INSERT INTO session "
|
||||
"(id, tokens_input, tokens_output, tokens_reasoning, "
|
||||
"tokens_cache_read, tokens_cache_write, cost) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def test_read_missing_db_returns_none(tmp_path: Path) -> None:
|
||||
assert read_session_usage(tmp_path / "nope.db") is None
|
||||
|
||||
|
||||
def test_read_single_session(tmp_path: Path) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
# (id, input, output, reasoning, cache_read, cache_write, cost)
|
||||
_make_db(db, [("s1", _IN, _OUT, _REASON, _CREAD, _CWRITE, 0.0007)])
|
||||
usage = read_session_usage(db, session_id="s1")
|
||||
assert usage is not None
|
||||
assert usage.tokens_input == _IN
|
||||
assert usage.tokens_output == _OUT
|
||||
assert usage.tokens_cache_read == _CREAD
|
||||
assert usage.tokens_cache_write == _CWRITE
|
||||
assert usage.tokens_reasoning == _REASON
|
||||
|
||||
|
||||
def test_read_sums_all_sessions_when_no_id(tmp_path: Path) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
_make_db(
|
||||
db,
|
||||
[
|
||||
("s1", _IN, _OUT, 0, 0, 0, 0.0),
|
||||
("s2", _S2_IN, _S2_OUT, 0, _S2_CREAD, 0, 0.0),
|
||||
],
|
||||
)
|
||||
usage = read_session_usage(db)
|
||||
assert usage is not None
|
||||
assert usage.tokens_input == _IN + _S2_IN
|
||||
assert usage.tokens_output == _OUT + _S2_OUT
|
||||
assert usage.tokens_cache_read == _S2_CREAD
|
||||
|
||||
|
||||
def test_read_empty_table_returns_none(tmp_path: Path) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
_make_db(db, [])
|
||||
assert read_session_usage(db) is None
|
||||
|
||||
|
||||
def test_cost_for_session_uses_roboco_pricing(tmp_path: Path) -> None:
|
||||
db = tmp_path / "opencode.db"
|
||||
# 1M input + 1M output for grok-build-0.1 → our $3.00, not opencode's 99.0.
|
||||
_make_db(db, [("s1", _M, _M, 0, 0, 0, 99.0)])
|
||||
usage, cost = cost_for_session("grok-build-0.1", db, session_id="s1")
|
||||
assert usage is not None
|
||||
assert abs(cost - _GROK_COST_1M_1M) < _TOL
|
||||
|
||||
|
||||
def test_cost_for_session_missing_db(tmp_path: Path) -> None:
|
||||
usage, cost = cost_for_session("grok-build-0.1", tmp_path / "nope.db")
|
||||
assert usage is None
|
||||
assert cost == _ZERO_COST
|
||||
|
||||
|
||||
def test_cost_reproduces_opencode_authoritative_cost(tmp_path: Path) -> None:
|
||||
"""Real observed row: our pricing must match opencode's stored USD cost.
|
||||
|
||||
Proves the column semantics (non-cached input disjoint from cache_read;
|
||||
reasoning separate, billed at output rate).
|
||||
"""
|
||||
db = tmp_path / "opencode.db"
|
||||
# (id, input, output, reasoning, cache_read, cache_write, cost)
|
||||
_make_db(
|
||||
db,
|
||||
[("real", _REAL_IN, _REAL_OUT, _REAL_REASON, _REAL_CREAD, 0, _REAL_COST)],
|
||||
)
|
||||
usage, cost = cost_for_session("grok-build-0.1", db, session_id="real")
|
||||
assert usage is not None
|
||||
assert abs(cost - _REAL_COST) < _TOL
|
||||
assert abs(cost - usage.opencode_cost) < _TOL
|
||||
@@ -29,14 +29,10 @@ from roboco.models.runtime import OrchestratorAgentConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_grok_auth(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Path:
|
||||
def _isolate_grok_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point GROK_AUTH_HOST_PATH at a fresh tmp dir so tests never mount the real
|
||||
~/.grok. Tests that exercise the auth mount create ``auth.json`` themselves."""
|
||||
monkeypatch.setattr(
|
||||
"roboco.llm.providers.grok.GROK_AUTH_HOST_PATH", str(tmp_path)
|
||||
)
|
||||
monkeypatch.setattr("roboco.llm.providers.grok.GROK_AUTH_HOST_PATH", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@@ -81,7 +77,7 @@ class _FakeHost:
|
||||
async def _remove_container(self, container_name: str) -> None:
|
||||
self.removed.append(container_name)
|
||||
|
||||
def _ensure_opencode_data_dir(self, agent_id: str) -> None:
|
||||
def _ensure_grok_usage_dir(self, agent_id: str) -> None:
|
||||
self.data_dirs_ensured.append(agent_id)
|
||||
|
||||
def _resolve_host_paths(
|
||||
@@ -92,7 +88,7 @@ class _FakeHost:
|
||||
if config.mcp_config_path
|
||||
else None,
|
||||
"settings": str(agent_settings_path) if agent_settings_path else None,
|
||||
"opencode": f"/host/data/opencode/{config.agent_id}",
|
||||
"grok_usage": f"/host/data/grok-usage/{config.agent_id}",
|
||||
}
|
||||
|
||||
def _build_mount_args(
|
||||
@@ -218,11 +214,12 @@ async def test_grok_spawn_wires_gateway_env_and_image_last() -> None:
|
||||
assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd
|
||||
assert "ROBOCO_AGENT_ID=be-dev-1" in cmd # renderer computes per-role flags
|
||||
assert "ROBOCO_AGENT_MODEL=grok-build" in cmd
|
||||
# Fixed session id so usage capture can locate the run's session store.
|
||||
assert "ROBOCO_AGENT_SESSION_ID=sess-1" in cmd
|
||||
# No session id is injected: grok ignores a requested id, so the entrypoint
|
||||
# reads the real one back from the run log for usage capture.
|
||||
assert not any(c.startswith("ROBOCO_AGENT_SESSION_ID=") for c in cmd)
|
||||
# Usage capture: per-agent data dir mounted + the entrypoint's usage file.
|
||||
assert host.data_dirs_ensured == ["be-dev-1"]
|
||||
assert "/host/data/opencode/be-dev-1:/home/agent/.grok-usage" in cmd
|
||||
assert "/host/data/grok-usage/be-dev-1:/home/agent/.grok-usage" in cmd
|
||||
assert "ROBOCO_GROK_USAGE_FILE=/home/agent/.grok-usage/usage.json" in cmd
|
||||
# Identity wiring from the shared host helpers is present.
|
||||
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""roboco-intake MCP server — propose_draft delivers the draft to the relay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.mcp import intake_server
|
||||
|
||||
|
||||
def _client(handler: Any) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_draft_posts_to_the_relay(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_API_URL", "http://orch:8000")
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["json"] = __import__("json").loads(request.content)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.post_draft(
|
||||
"sess-1", {"title": "Build X"}, client=client
|
||||
)
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert seen["url"] == "http://orch:8000/api/prompter/live/sess-1/events"
|
||||
assert seen["json"]["kind"] == "draft"
|
||||
assert seen["json"]["tool"] == "propose_draft"
|
||||
assert seen["json"]["data"] == {"title": "Build X"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_draft_reports_http_error() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503)
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.post_draft("s", {}, client=client)
|
||||
assert result == {"error": "http_503"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_draft_reports_request_failure() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.post_draft("s", {}, client=client)
|
||||
assert result["error"] == "request_failed"
|
||||
assert "boom" in result["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_draft_requires_a_live_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ROBOCO_PROMPTER_SESSION_ID", raising=False)
|
||||
msg = await intake_server.propose_draft({"title": "X"})
|
||||
assert "No live session id" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_draft_acks_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
|
||||
|
||||
async def _ok(_sid: str, _draft: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(intake_server, "post_draft", _ok)
|
||||
msg = await intake_server.propose_draft({"title": "X"})
|
||||
assert "Draft submitted" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_draft_reports_relay_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
|
||||
|
||||
async def _fail(_sid: str, _draft: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"error": "http_503"}
|
||||
|
||||
monkeypatch.setattr(intake_server, "post_draft", _fail)
|
||||
msg = await intake_server.propose_draft({"title": "X"})
|
||||
assert "Could not submit the draft" in msg
|
||||
assert "http_503" in msg
|
||||
@@ -0,0 +1,75 @@
|
||||
"""roboco-secretary MCP server — tools wrap the shared backend helpers as JSON.
|
||||
|
||||
The backend-calling logic (``secretary_driver._do_*``) is covered by the secretary
|
||||
driver tests; here we only assert the MCP wrappers forward the right args and
|
||||
return the backend result as a JSON string the model reads back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from roboco.mcp import secretary_server
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_company_state_returns_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _state() -> dict[str, Any]:
|
||||
return {"charter": "ship it", "tasks": {"pending": 3}}
|
||||
|
||||
monkeypatch.setattr(secretary_server, "_do_read_state", _state)
|
||||
out = await secretary_server.read_company_state()
|
||||
assert json.loads(out) == {"charter": "ship it", "tasks": {"pending": 3}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_task_forwards_the_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
async def _task(task_id: str) -> dict[str, Any]:
|
||||
seen["id"] = task_id
|
||||
return {"id": task_id, "title": "T"}
|
||||
|
||||
monkeypatch.setattr(secretary_server, "_do_read_task", _task)
|
||||
out = await secretary_server.read_task("task-9")
|
||||
assert seen["id"] == "task-9"
|
||||
assert json.loads(out)["title"] == "T"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_directive_forwards_kind_and_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
async def _submit(kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
seen["kind"] = kind
|
||||
seen["payload"] = payload
|
||||
return {"queued": True}
|
||||
|
||||
monkeypatch.setattr(secretary_server, "_do_submit_directive", _submit)
|
||||
out = await secretary_server.submit_directive(
|
||||
"relay_message", {"channel": "announcements", "text": "hi"}
|
||||
)
|
||||
assert seen["kind"] == "relay_message"
|
||||
assert seen["payload"] == {"channel": "announcements", "text": "hi"}
|
||||
assert json.loads(out) == {"queued": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_directive_tolerates_missing_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
async def _submit(_kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
seen["payload"] = payload
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(secretary_server, "_do_submit_directive", _submit)
|
||||
await secretary_server.submit_directive("announce", None)
|
||||
assert seen["payload"] == {}
|
||||
@@ -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