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:
Renn F
2026-06-19 04:42:25 +02:00
parent 499f6fc509
commit a88045aacf
40 changed files with 1307 additions and 2200 deletions
+21 -14
View File
@@ -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
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+10 -19
View File
@@ -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
+10 -20
View File
@@ -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
-136
View File
@@ -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),
});
},
};
};
-70
View File
@@ -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.";
},
}),
},
});
-192
View File
@@ -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.",
);
}
}
},
};
};
-118
View File
@@ -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 || {},
}),
);
},
}),
},
});
-124
View File
@@ -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"
+14 -15
View File
@@ -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;" \
+256
View File
@@ -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),
)
+57 -21
View 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(
+55 -16
View File
@@ -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(
+1 -1
View File
@@ -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)
-284
View File
@@ -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
+4 -5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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,
+5 -51
View File
@@ -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)
+13 -8
View File
@@ -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:
+66 -12
View File
@@ -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
-213
View File
@@ -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())
-121
View File
@@ -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
+95
View File
@@ -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()
+64
View File
@@ -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
View File
@@ -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
-143
View File
@@ -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
-134
View File
@@ -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
+8 -11
View File
@@ -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"] == {}
+30 -32
View File
@@ -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()
+45 -67
View File
@@ -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.