From eac065e5044b0a51cc8ed2c0e7b56851e4856be9 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 18 Jun 2026 21:59:25 +0200 Subject: [PATCH] fix(grok): deliver intake draft via the relay + correct opencode-mechanism docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live end-to-end verification (opencode 1.17.8 + grok-build-0.1) of the WHOLE integration, then fixes for what it surfaced: 1) Intake draft card (FUNCTIONAL): opencode's synchronous serve reply (POST /session/:id/message) returns only [step-start, text, step-finish] — it does NOT include tool-call parts, so the driver could never extract the propose_draft draft. intake-tools.js now POSTs the draft straight to the prompter-live relay (/api/prompter/live/{session}/events, the same endpoint the driver's relay sink uses), so the panel renders the card regardless. Verified live: grok calls propose_draft -> the relay receives the draft. 2) Correct misattributed opencode "bugs" (DOCS): earlier comments asserted as general opencode behavior that a provider.xai block / npm override / config plugin:-array "break" registration. Re-testing showed those were artifacts of a PROJECT-level .opencode/opencode.json; from the GLOBAL config (which opencode_config writes) the built-in provider, model resolution, the plugin array AND the auto-discovery dir all work, and MCP gateway verbs register (delivery agents verified). Reframed the comments as design choices (built-in provider + XAI_API_KEY env + plugins baked in the auto-discovery dir with named exports) and dropped the false claims. 3) Reasoning --variant: passing it does not error, but whether opencode applies a named reasoning variant to grok-build-0.1 (no provider-defined variants) is UNVERIFIED — comment softened from a "~54% cut" claim to best-effort, measure-on-NAS. Verified live this session: one-shot delivery (model + MCP verbs + plugins + hooks), secretary tools (read_company_state + submit_directive -> backend with token), intake draft (relay), grok built-in-provider tool-calling. Remaining NAS-only: full container assembly (SDK :9000 startup, entrypoint hooks, 429 parking) + the --variant cost measurement. Gate green (ruff/mypy + 51 tests; node --check the plugins). --- docker/agent-grok.Dockerfile | 11 ++-- docker/grok/budget-feed.js | 6 +-- docker/grok/intake-tools.js | 68 ++++++++++++++++--------- docker/grok/secret-scrub.js | 8 +-- docker/grok/secretary-tools.js | 9 ++-- roboco/agent_sdk/grok_intake_main.py | 16 +++--- roboco/agent_sdk/opencode_session.py | 14 +++-- roboco/llm/providers/grok.py | 20 ++++---- roboco/llm/providers/opencode_config.py | 59 ++++++++++----------- roboco/runtime/orchestrator.py | 4 +- 10 files changed, 119 insertions(+), 96 deletions(-) diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile index 26cbc185..b4098b0e 100644 --- a/docker/agent-grok.Dockerfile +++ b/docker/agent-grok.Dockerfile @@ -13,16 +13,15 @@ FROM roboco-agent-base USER root # opencode — the OpenAI-protocol agent runtime. grok-build-0.1 runs on opencode's -# BUILT-IN xai provider (no custom provider npm — that breaks model resolution), -# so only opencode-ai is installed; it resolves the provider SDK at runtime. +# BUILT-IN xai provider (no custom provider block / npm needed), so only +# opencode-ai is installed; it resolves the provider SDK at runtime. RUN npm install -g opencode-ai \ && npm cache clean --force \ && rm -rf /root/.npm /tmp/* -# opencode plugins, baked into the AUTO-DISCOVERY dir (~/.config/opencode/plugin/). -# opencode 1.17.8 does NOT register a plugin's hooks/tools from a config -# `plugin:`-array absolute path — only from this directory (verified live). Each -# plugin uses a NAMED export. +# opencode plugins, baked into the AUTO-DISCOVERY dir (~/.config/opencode/plugin/) +# rather than referenced by a config `plugin:` path — the dir is the simplest +# registration route. Each plugin uses a NAMED export (opencode's convention). # secret-scrub — bash-guard parity (PAT/credential deny on tool.execute.before) # budget-feed — POSTs budget/loop/terminal counters to the in-container SDK # server (tool.execute.{before,after}); the entrypoint starts diff --git a/docker/grok/budget-feed.js b/docker/grok/budget-feed.js index 09c6dc93..1f66d4af 100644 --- a/docker/grok/budget-feed.js +++ b/docker/grok/budget-feed.js @@ -77,9 +77,9 @@ function bareVerb(tool) { return tool; } -// Named export + loaded from the plugin auto-discovery dir -// (~/.config/opencode/plugin/) — opencode 1.17.8 ignores config `plugin:`-array -// absolute paths for hook/tool registration (verified live). +// 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) => { diff --git a/docker/grok/intake-tools.js b/docker/grok/intake-tools.js index 2c580f24..712aff03 100644 --- a/docker/grok/intake-tools.js +++ b/docker/grok/intake-tools.js @@ -1,29 +1,28 @@ // opencode plugin — the Intake interviewer's propose_draft tool, on Grok. // -// Parity with the Claude Intake's SDK tool (roboco.agent_sdk.intake_driver -// .build_intake_options): the model calls propose_draft once the task spec is -// ready, and the driver (OpencodeServeSession.normalize_opencode_message -> -// _is_propose_draft -> _draft_from_tool_input) turns that tool call into the -// `draft` chunk the panel renders as the reviewable draft card. +// The model calls propose_draft once the task spec is ready; this delivers the +// draft to the panel's reviewable draft card. // -// Without this, propose_draft is a tool the prompter prompt tells the model to -// call but that does not exist on Grok, so no draft card ever appears and the -// human can't launch a task from a Grok intake chat. The execute() only ACKs — -// the payload that matters is the tool-CALL input, which the driver intercepts. +// 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 ONLY into the roboco-agent-grok-prompter image via -// ROBOCO_OPENCODE_EXTRA_PLUGINS (the one-shot delivery roles never draft). -// -// UNVERIFIED-LIVE: opencode's exact tool-call Part shape in the synchronous -// serve reply — confirm a Grok intake spec yields a draft chunk -> panel card -// on the NAS before relying on Grok intake. +// 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"; -// Named export + loaded from the plugin auto-discovery dir -// (~/.config/opencode/plugin/) — opencode 1.17.8 only registers Hooks.tool from -// directory auto-discovery, not a config `plugin:`-array absolute path -// (verified live). +const API_BASE = ( + process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000" +).replace(/\/+$/, ""); + export const RobocoIntakeTools = async () => ({ tool: { propose_draft: tool({ @@ -38,10 +37,33 @@ export const RobocoIntakeTools = async () => ({ .record(tool.schema.string(), tool.schema.any()) .describe("The task draft object"), }, - async execute() { - // The driver intercepts the tool CALL and emits the draft chunk; this - // handler only acknowledges so the model knows the draft landed. - return "Draft submitted — the human can review it."; + 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 index ef80fd9f..b24386ba 100644 --- a/docker/grok/secret-scrub.js +++ b/docker/grok/secret-scrub.js @@ -164,10 +164,10 @@ function denyBash(command) { return null; } -// Named export + loaded from opencode's plugin auto-discovery dir -// (~/.config/opencode/plugin/). opencode 1.17.8 does NOT register a plugin's -// hooks/tools when it's listed by absolute path in the config `plugin:` array — -// only directory auto-discovery works (verified live against grok-build-0.1). +// 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) => { diff --git a/docker/grok/secretary-tools.js b/docker/grok/secretary-tools.js index 5c759b3d..fcabf3b0 100644 --- a/docker/grok/secretary-tools.js +++ b/docker/grok/secretary-tools.js @@ -66,11 +66,10 @@ async function callBackend(method, path, body) { const asText = (data) => JSON.stringify(data); -// Named export + loaded from the plugin auto-discovery dir -// (~/.config/opencode/plugin/) — opencode 1.17.8 does NOT register tools from a -// config `plugin:`-array absolute path; only directory auto-discovery + a named -// export registers Hooks.tool (verified live: the model called the tool and the -// backend received the request). +// 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({ diff --git a/roboco/agent_sdk/grok_intake_main.py b/roboco/agent_sdk/grok_intake_main.py index 3876062e..68546e75 100644 --- a/roboco/agent_sdk/grok_intake_main.py +++ b/roboco/agent_sdk/grok_intake_main.py @@ -3,13 +3,15 @@ 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`` (xAI provider + 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, wired via -``ROBOCO_OPENCODE_EXTRA_PLUGINS``) and the driver turns that tool call into the -panel's draft card. The ``IntakeDriver`` loop, message source, and relay are -reused unchanged — only the ``SessionFactory`` differs. +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. """ from __future__ import annotations diff --git a/roboco/agent_sdk/opencode_session.py b/roboco/agent_sdk/opencode_session.py index 93408ebf..076df56b 100644 --- a/roboco/agent_sdk/opencode_session.py +++ b/roboco/agent_sdk/opencode_session.py @@ -105,11 +105,15 @@ def normalize_opencode_message(message: dict[str, Any]) -> list[StreamChunk]: 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 ``propose_draft`` tool part — or - a fenced ```roboco-draft``` block in the assembled text — becomes a ``draft`` - chunk, matching the Claude intake's two draft paths. A turn-level - ``info.error`` is surfaced as an ``error`` chunk so a failed turn is never - silently blank. + 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] = [] diff --git a/roboco/llm/providers/grok.py b/roboco/llm/providers/grok.py index b02a7f6a..2f8e9452 100644 --- a/roboco/llm/providers/grok.py +++ b/roboco/llm/providers/grok.py @@ -70,13 +70,16 @@ _SYSTEM_PROMPT_IN_CONTAINER = "/app/system-prompt.md" _OPENCODE_DATA_DIR_IN_CONTAINER = "/home/agent/.local/share/opencode" # Reasoning effort by role. grok-build-0.1 reasons heavily by default, and -# reasoning bills at the output rate — it dominates cost (a live "say ok" call -# emitted ~300 reasoning tokens). Code-quality roles (developer, qa, pr_reviewer) -# keep full reasoning; coordination / docs / board roles run "minimal" (a live -# test cut reasoning ~54% with no quality cost for that work). opencode applies -# this via its `--variant` flag. Operators can force one effort for ALL grok -# agents with the ROBOCO_GROK_REASONING_EFFORT env on the orchestrator -# (value "minimal" | "high" | "max", or "default"/"full" to use full reasoning). +# reasoning bills at the output rate, so it dominates cost. Code-quality roles +# (developer, qa, pr_reviewer) keep full reasoning; coordination / docs / board +# roles request "minimal". opencode receives this via its `--variant` flag (and +# the serve message `variant` field). NOTE: whether opencode actually applies a +# named reasoning variant to grok-build-0.1 without a provider-defined `variants` +# block is UNVERIFIED — passing the flag does not error, but the reasoning-cost +# reduction must be measured on the NAS; treat the saving as best-effort, not +# guaranteed. Operators can force one effort for ALL grok agents with the +# ROBOCO_GROK_REASONING_EFFORT env on the orchestrator (value "minimal" | "high" +# | "max", or "default"/"full" to use full reasoning). _MINIMAL_REASONING_ROLES = frozenset( { "cell_pm", @@ -262,8 +265,7 @@ class GrokProvider(AgentProvider): [ # opencode's BUILT-IN xai provider authenticates from XAI_API_KEY # and reads XAI_BASE_URL for the endpoint — opencode_config emits - # no provider block (any provider.xai block breaks plugin-tool - # registration), so these envs are the only LLM wiring. + # no provider block, so these envs are the only LLM wiring needed. "-e", f"XAI_API_KEY={config.provider_auth_token}", "-e", diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py index 5da7ae31..4fb1b4a2 100644 --- a/roboco/llm/providers/opencode_config.py +++ b/roboco/llm/providers/opencode_config.py @@ -7,12 +7,10 @@ sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code 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 drives - grok-build-0.1; ANY custom ``provider.xai`` block (even just ``options``) - breaks plugin-tool registration, and a ``npm`` override additionally breaks - model resolution (ProviderModelNotFoundError) — all verified live on opencode - 1.17.8. The key + base URL reach the provider via the ``XAI_API_KEY`` / - ``XAI_BASE_URL`` env vars; ``model`` selects ``xai/``. + * 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``). @@ -25,12 +23,14 @@ Config shape per opencode docs (https://opencode.ai/docs/config): review). This is the primary idle-stream defence (the orchestrator reaper is the backstop). -There is NO ``plugin`` key: opencode 1.17.8 does not register a plugin's -hooks/tools from a config ``plugin:``-array absolute path — only from the plugin -AUTO-DISCOVERY dir (``~/.config/opencode/plugin/``). The plugins are baked there -in the images instead (secret-scrub + budget-feed in the base grok image; the -Secretary's directive tools and the Intake's propose_draft in their interactive -images). +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 @@ -57,19 +57,16 @@ from typing import Any _OPENCODE_SCHEMA = "https://opencode.ai/config.json" _PROVIDER_ID = "xai" -# We do NOT override provider..npm. opencode's BUILT-IN xai provider already -# drives grok-build-0.1 with working tool-calls (verified live); a custom `npm` -# (e.g. @ai-sdk/openai) is not resolvable from opencode's module path and makes -# the model fail to resolve (ProviderModelNotFoundError). The xAI key is injected -# via the XAI_API_KEY env var the built-in provider reads (set by GrokProvider / -# the orchestrator) — provider.options.apiKey alone does NOT authenticate it. +# 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 NOT listed -# in the config `plugin:` array — opencode 1.17.8 does not register a plugin's -# hooks/tools when it is referenced by absolute path there. They are baked into +# 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) instead, which registers -# both tools and hooks (verified live against grok-build-0.1). +# /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 @@ -77,8 +74,8 @@ _PROVIDER_ID = "xai" # 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 can't be set without a custom provider.npm, which -# breaks model resolution — see the module docstring — so they are not used.) +# 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" @@ -134,13 +131,11 @@ def build_opencode_config( ) -> 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, and ANY custom ``provider.xai`` block breaks plugin-tool - registration AND (without ``XAI_API_KEY``) model resolution — all verified - live on opencode 1.17.8. 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. + 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] = { diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 4d34406d..96715846 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -3443,8 +3443,8 @@ class AgentOrchestrator: cmd.extend( [ # Built-in xai provider authenticates from XAI_API_KEY and - # reads XAI_BASE_URL; opencode_config emits no provider block - # (any provider.xai block breaks plugin-tool registration). + # 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",