diff --git a/.env.example b/.env.example index 1f151b70..0f65804f 100644 --- a/.env.example +++ b/.env.example @@ -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 /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 diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index 38ed3f48..ea018b42 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -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 /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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 7fda020a..66451a4d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 /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 /; 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 + # /; 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`. diff --git a/docker-compose.yml b/docker-compose.yml index 7fda020a..66451a4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 /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 /; 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 + # /; 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`. diff --git a/docker/agent-grok-prompter.Dockerfile b/docker/agent-grok-prompter.Dockerfile index 4b5e3749..6f0ec611 100644 --- a/docker/agent-grok-prompter.Dockerfile +++ b/docker/agent-grok-prompter.Dockerfile @@ -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 diff --git a/docker/agent-grok-secretary.Dockerfile b/docker/agent-grok-secretary.Dockerfile index d6e9bd4e..354c8914 100644 --- a/docker/agent-grok-secretary.Dockerfile +++ b/docker/agent-grok-secretary.Dockerfile @@ -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 diff --git a/docker/grok/budget-feed.js b/docker/grok/budget-feed.js deleted file mode 100644 index b772a9c7..00000000 --- a/docker/grok/budget-feed.js +++ /dev/null @@ -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 "_" (or "."); -// 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_" (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), - }); - }, - }; -}; diff --git a/docker/grok/intake-tools.js b/docker/grok/intake-tools.js deleted file mode 100644 index 712aff03..00000000 --- a/docker/grok/intake-tools.js +++ /dev/null @@ -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."; - }, - }), - }, -}); diff --git a/docker/grok/secret-scrub.js b/docker/grok/secret-scrub.js deleted file mode 100644 index eb86ccd3..00000000 --- a/docker/grok/secret-scrub.js +++ /dev/null @@ -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.", - ); - } - } - }, - }; -}; diff --git a/docker/grok/secretary-tools.js b/docker/grok/secretary-tools.js deleted file mode 100644 index 0900e55e..00000000 --- a/docker/grok/secretary-tools.js +++ /dev/null @@ -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 || {}, - }), - ); - }, - }), - }, -}); diff --git a/docker/scripts/grok-agent-entrypoint.sh b/docker/scripts/grok-agent-entrypoint.sh deleted file mode 100755 index 7a3a19e6..00000000 --- a/docker/scripts/grok-agent-entrypoint.sh +++ /dev/null @@ -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" diff --git a/docker/scripts/grok-cli-agent-entrypoint.sh b/docker/scripts/grok-cli-agent-entrypoint.sh index 36c1ea6e..33ee3ddd 100755 --- a/docker/scripts/grok-cli-agent-entrypoint.sh +++ b/docker/scripts/grok-cli-agent-entrypoint.sh @@ -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;" \ diff --git a/roboco/agent_sdk/grok_cli_session.py b/roboco/agent_sdk/grok_cli_session.py new file mode 100644 index 00000000..c2fcfdaf --- /dev/null +++ b/roboco/agent_sdk/grok_cli_session.py @@ -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 `` 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 "" --output-format json`` returns ``{text, sessionId, ...}``; + * ``grok -p "" -r `` 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), + ) diff --git a/roboco/agent_sdk/grok_intake_main.py b/roboco/agent_sdk/grok_intake_main.py index 68546e75..71b9ae8a 100644 --- a/roboco/agent_sdk/grok_intake_main.py +++ b/roboco/agent_sdk/grok_intake_main.py @@ -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( diff --git a/roboco/agent_sdk/grok_secretary_main.py b/roboco/agent_sdk/grok_secretary_main.py index 6b6349da..a4576fe5 100644 --- a/roboco/agent_sdk/grok_secretary_main.py +++ b/roboco/agent_sdk/grok_secretary_main.py @@ -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( diff --git a/roboco/agent_sdk/intake_driver.py b/roboco/agent_sdk/intake_driver.py index a1c4b79c..a7cf34b4 100644 --- a/roboco/agent_sdk/intake_driver.py +++ b/roboco/agent_sdk/intake_driver.py @@ -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) diff --git a/roboco/agent_sdk/opencode_session.py b/roboco/agent_sdk/opencode_session.py deleted file mode 100644 index 076df56b..00000000 --- a/roboco/agent_sdk/opencode_session.py +++ /dev/null @@ -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:, ``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 diff --git a/roboco/agent_sdk/prompt_guard.py b/roboco/agent_sdk/prompt_guard.py index 8c2e14f7..d1ff6c2a 100644 --- a/roboco/agent_sdk/prompt_guard.py +++ b/roboco/agent_sdk/prompt_guard.py @@ -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 diff --git a/roboco/billing/pricing.py b/roboco/billing/pricing.py index 50424555..0641d3b6 100644 --- a/roboco/billing/pricing.py +++ b/roboco/billing/pricing.py @@ -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 diff --git a/roboco/config.py b/roboco/config.py index 2eb2d30f..3bf721a5 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -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, diff --git a/roboco/llm/providers/grok.py b/roboco/llm/providers/grok.py index 353c9537..fc4f0efc 100644 --- a/roboco/llm/providers/grok.py +++ b/roboco/llm/providers/grok.py @@ -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 ` 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) diff --git a/roboco/llm/providers/grok_cli_config.py b/roboco/llm/providers/grok_cli_config.py index f7881157..5beebae1 100644 --- a/roboco/llm/providers/grok_cli_config.py +++ b/roboco/llm/providers/grok_cli_config.py @@ -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: diff --git a/roboco/llm/providers/grok_cli_usage.py b/roboco/llm/providers/grok_cli_usage.py index d138b4b0..1edf0d9f 100644 --- a/roboco/llm/providers/grok_cli_usage.py +++ b/roboco/llm/providers/grok_cli_usage.py @@ -3,9 +3,9 @@ The grok CLI persists each session under ``~/.grok/sessions// /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 diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py deleted file mode 100644 index 4fb1b4a2..00000000 --- a/roboco/llm/providers/opencode_config.py +++ /dev/null @@ -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/``. - * ``mcp.`` — ``{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()) diff --git a/roboco/llm/providers/opencode_usage.py b/roboco/llm/providers/opencode_usage.py deleted file mode 100644 index 7bf2c6ba..00000000 --- a/roboco/llm/providers/opencode_usage.py +++ /dev/null @@ -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 diff --git a/roboco/mcp/intake_server.py b/roboco/mcp/intake_server.py new file mode 100644 index 00000000..6ab972c4 --- /dev/null +++ b/roboco/mcp/intake_server.py @@ -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() diff --git a/roboco/mcp/secretary_server.py b/roboco/mcp/secretary_server.py new file mode 100644 index 00000000..7df88813 --- /dev/null +++ b/roboco/mcp/secretary_server.py @@ -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() diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 914f0bd2..7e8be848 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -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 /opencode/; 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 /grok-usage//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( diff --git a/tests/unit/agent_sdk/test_grok_cli_session.py b/tests/unit/agent_sdk/test_grok_cli_session.py new file mode 100644 index 00000000..ba64a5a7 --- /dev/null +++ b/tests/unit/agent_sdk/test_grok_cli_session.py @@ -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, "") diff --git a/tests/unit/agent_sdk/test_opencode_session.py b/tests/unit/agent_sdk/test_opencode_session.py deleted file mode 100644 index 29913077..00000000 --- a/tests/unit/agent_sdk/test_opencode_session.py +++ /dev/null @@ -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 diff --git a/tests/unit/llm/providers/test_grok_cli_usage.py b/tests/unit/llm/providers/test_grok_cli_usage.py index 51dc8107..b8e4e63b 100644 --- a/tests/unit/llm/providers/test_grok_cli_usage.py +++ b/tests/unit/llm/providers/test_grok_cli_usage.py @@ -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 diff --git a/tests/unit/llm/test_opencode_config.py b/tests/unit/llm/test_opencode_config.py deleted file mode 100644 index 47c4b6d0..00000000 --- a/tests/unit/llm/test_opencode_config.py +++ /dev/null @@ -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 "/". - 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 diff --git a/tests/unit/llm/test_opencode_usage.py b/tests/unit/llm/test_opencode_usage.py deleted file mode 100644 index d2f01ef2..00000000 --- a/tests/unit/llm/test_opencode_usage.py +++ /dev/null @@ -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 diff --git a/tests/unit/llm/test_providers.py b/tests/unit/llm/test_providers.py index bbd682de..93d2dd9f 100644 --- a/tests/unit/llm/test_providers.py +++ b/tests/unit/llm/test_providers.py @@ -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 diff --git a/tests/unit/mcp_servers/test_intake_server.py b/tests/unit/mcp_servers/test_intake_server.py new file mode 100644 index 00000000..4c93e0ef --- /dev/null +++ b/tests/unit/mcp_servers/test_intake_server.py @@ -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 diff --git a/tests/unit/mcp_servers/test_secretary_server.py b/tests/unit/mcp_servers/test_secretary_server.py new file mode 100644 index 00000000..24deb511 --- /dev/null +++ b/tests/unit/mcp_servers/test_secretary_server.py @@ -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"] == {} diff --git a/tests/unit/runtime/test_grok_cost_budget.py b/tests/unit/runtime/test_grok_cost_budget.py index ba1f44cc..f40aaa45 100644 --- a/tests/unit/runtime/test_grok_cost_budget.py +++ b/tests/unit/runtime/test_grok_cost_budget.py @@ -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() diff --git a/tests/unit/runtime/test_grok_usage_finalize.py b/tests/unit/runtime/test_grok_usage_finalize.py index 368ee001..1bebb033 100644 --- a/tests/unit/runtime/test_grok_usage_finalize.py +++ b/tests/unit/runtime/test_grok_usage_finalize.py @@ -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) diff --git a/tests/unit/runtime/test_interactive_grok_spawn.py b/tests/unit/runtime/test_interactive_grok_spawn.py index 72d6b2b0..6424d5a7 100644 --- a/tests/unit/runtime/test_interactive_grok_spawn.py +++ b/tests/unit/runtime/test_interactive_grok_spawn.py @@ -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 diff --git a/tests/unit/runtime/test_stale_claim_reaper.py b/tests/unit/runtime/test_stale_claim_reaper.py index a14a199f..72d21e77 100644 --- a/tests/unit/runtime/test_stale_claim_reaper.py +++ b/tests/unit/runtime/test_stale_claim_reaper.py @@ -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.