fix(grok): deliver intake draft via the relay + correct opencode-mechanism docs

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).
This commit is contained in:
Renn F
2026-06-18 21:59:25 +02:00
parent e29168653a
commit eac065e504
10 changed files with 119 additions and 96 deletions
+5 -6
View File
@@ -13,16 +13,15 @@ FROM roboco-agent-base
USER root USER root
# opencode — the OpenAI-protocol agent runtime. grok-build-0.1 runs on opencode's # 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), # BUILT-IN xai provider (no custom provider block / npm needed), so only
# so only opencode-ai is installed; it resolves the provider SDK at runtime. # opencode-ai is installed; it resolves the provider SDK at runtime.
RUN npm install -g opencode-ai \ RUN npm install -g opencode-ai \
&& npm cache clean --force \ && npm cache clean --force \
&& rm -rf /root/.npm /tmp/* && rm -rf /root/.npm /tmp/*
# opencode plugins, baked into the AUTO-DISCOVERY dir (~/.config/opencode/plugin/). # 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 # rather than referenced by a config `plugin:` path — the dir is the simplest
# `plugin:`-array absolute path — only from this directory (verified live). Each # registration route. Each plugin uses a NAMED export (opencode's convention).
# plugin uses a NAMED export.
# secret-scrub — bash-guard parity (PAT/credential deny on tool.execute.before) # secret-scrub — bash-guard parity (PAT/credential deny on tool.execute.before)
# budget-feed — POSTs budget/loop/terminal counters to the in-container SDK # budget-feed — POSTs budget/loop/terminal counters to the in-container SDK
# server (tool.execute.{before,after}); the entrypoint starts # server (tool.execute.{before,after}); the entrypoint starts
+3 -3
View File
@@ -77,9 +77,9 @@ function bareVerb(tool) {
return tool; return tool;
} }
// Named export + loaded from the plugin auto-discovery dir // Named export (opencode's plugin convention) + baked into the plugin
// (~/.config/opencode/plugin/) — opencode 1.17.8 ignores config `plugin:`-array // auto-discovery dir (~/.config/opencode/plugin/) at image build — the simplest
// absolute paths for hook/tool registration (verified live). // registration route (no config `plugin:` path needed).
export const RobocoBudgetFeed = async () => { export const RobocoBudgetFeed = async () => {
return { return {
"tool.execute.before": async (input) => { "tool.execute.before": async (input) => {
+45 -23
View File
@@ -1,29 +1,28 @@
// opencode plugin — the Intake interviewer's propose_draft tool, on Grok. // opencode plugin — the Intake interviewer's propose_draft tool, on Grok.
// //
// Parity with the Claude Intake's SDK tool (roboco.agent_sdk.intake_driver // The model calls propose_draft once the task spec is ready; this delivers the
// .build_intake_options): the model calls propose_draft once the task spec is // draft to the panel's reviewable draft card.
// 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.
// //
// Without this, propose_draft is a tool the prompter prompt tells the model to // WHY IT POSTS DIRECTLY (not via the driver): opencode's synchronous serve reply
// call but that does not exist on Grok, so no draft card ever appears and the // (POST /session/:id/message) returns only the final assistant text + step
// human can't launch a task from a Grok intake chat. The execute() only ACKs — // markers — NOT the tool-CALL parts. So OpencodeServeSession cannot intercept
// the payload that matters is the tool-CALL input, which the driver intercepts. // 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 // Loaded from the plugin auto-discovery dir (~/.config/opencode/plugin/), baked
// ROBOCO_OPENCODE_EXTRA_PLUGINS (the one-shot delivery roles never draft). // into the grok-prompter image only (the one-shot delivery roles never draft).
// // The container provides ROBOCO_API_URL + ROBOCO_PROMPTER_SESSION_ID.
// 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.
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
// Named export + loaded from the plugin auto-discovery dir const API_BASE = (
// (~/.config/opencode/plugin/) — opencode 1.17.8 only registers Hooks.tool from process.env.ROBOCO_API_URL || "http://roboco-orchestrator:8000"
// directory auto-discovery, not a config `plugin:`-array absolute path ).replace(/\/+$/, "");
// (verified live).
export const RobocoIntakeTools = async () => ({ export const RobocoIntakeTools = async () => ({
tool: { tool: {
propose_draft: tool({ propose_draft: tool({
@@ -38,10 +37,33 @@ export const RobocoIntakeTools = async () => ({
.record(tool.schema.string(), tool.schema.any()) .record(tool.schema.string(), tool.schema.any())
.describe("The task draft object"), .describe("The task draft object"),
}, },
async execute() { async execute(args) {
// The driver intercepts the tool CALL and emits the draft chunk; this const session = process.env.ROBOCO_PROMPTER_SESSION_ID || "";
// handler only acknowledges so the model knows the draft landed. if (!session) {
return "Draft submitted — the human can review it."; 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.";
}, },
}), }),
}, },
+4 -4
View File
@@ -164,10 +164,10 @@ function denyBash(command) {
return null; return null;
} }
// Named export + loaded from opencode's plugin auto-discovery dir // Named export (opencode's plugin convention) + loaded from opencode's plugin
// (~/.config/opencode/plugin/). opencode 1.17.8 does NOT register a plugin's // auto-discovery dir (~/.config/opencode/plugin/), where it's baked at image
// hooks/tools when it's listed by absolute path in the config `plugin:` array — // build — the simplest registration route (no config `plugin:` path needed).
// only directory auto-discovery works (verified live against grok-build-0.1). // Hook firing verified live against grok-build-0.1.
export const RobocoSecretScrub = async () => { export const RobocoSecretScrub = async () => {
return { return {
"tool.execute.before": async (input, output) => { "tool.execute.before": async (input, output) => {
+4 -5
View File
@@ -66,11 +66,10 @@ async function callBackend(method, path, body) {
const asText = (data) => JSON.stringify(data); const asText = (data) => JSON.stringify(data);
// Named export + loaded from the plugin auto-discovery dir // Named export (opencode's plugin convention) + baked into the plugin
// (~/.config/opencode/plugin/) — opencode 1.17.8 does NOT register tools from a // auto-discovery dir (~/.config/opencode/plugin/) at image build. Verified live
// config `plugin:`-array absolute path; only directory auto-discovery + a named // against grok-build-0.1: the model called read_company_state + submit_directive
// export registers Hooks.tool (verified live: the model called the tool and the // and the backend received both requests with the X-Agent-Token.
// backend received the request).
export const RobocoSecretaryTools = async () => ({ export const RobocoSecretaryTools = async () => ({
tool: { tool: {
read_company_state: tool({ read_company_state: tool({
+9 -7
View File
@@ -3,13 +3,15 @@
The Grok analogue of ``intake_main``: the same in-container ``POST /turn`` 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 receiver and the same relay sink to ``/api/prompter/live/{id}/events``, but the
held-open session is an :class:`OpencodeServeSession` (``opencode serve``) held-open session is an :class:`OpencodeServeSession` (``opencode serve``)
instead of a ``ClaudeSDKClient``. ``opencode.json`` (xAI provider + system instead of a ``ClaudeSDKClient``. ``opencode.json`` (model + system prompt) is
prompt) is rendered first. Intake is a human-only interviewer (no gateway verbs); rendered first. Intake is a human-only interviewer (no gateway verbs); its one
its one action tool, ``propose_draft``, is registered by the ``intake-tools.js`` action tool, ``propose_draft``, is registered by the ``intake-tools.js`` opencode
opencode plugin (baked into the grok-prompter image, wired via plugin (baked into the grok-prompter image's plugin auto-discovery dir). Because
``ROBOCO_OPENCODE_EXTRA_PLUGINS``) and the driver turns that tool call into the opencode's synchronous serve reply does NOT carry tool-call parts, that plugin
panel's draft card. The ``IntakeDriver`` loop, message source, and relay are POSTs the draft straight to the prompter-live relay (the same
reused unchanged — only the ``SessionFactory`` differs. ``/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 from __future__ import annotations
+9 -5
View File
@@ -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 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 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 text only here, so text parts ARE emitted. A turn-level ``info.error`` is
a fenced ```roboco-draft``` block in the assembled text — becomes a ``draft`` surfaced as an ``error`` chunk so a failed turn is never silently blank.
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 Draft note: opencode's synchronous serve reply does NOT include tool-call
silently blank. 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 [] parts = message.get("parts") or []
chunks: list[StreamChunk] = [] chunks: list[StreamChunk] = []
+11 -9
View File
@@ -70,13 +70,16 @@ _SYSTEM_PROMPT_IN_CONTAINER = "/app/system-prompt.md"
_OPENCODE_DATA_DIR_IN_CONTAINER = "/home/agent/.local/share/opencode" _OPENCODE_DATA_DIR_IN_CONTAINER = "/home/agent/.local/share/opencode"
# Reasoning effort by role. grok-build-0.1 reasons heavily by default, and # 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 # reasoning bills at the output rate, so it dominates cost. Code-quality roles
# emitted ~300 reasoning tokens). Code-quality roles (developer, qa, pr_reviewer) # (developer, qa, pr_reviewer) keep full reasoning; coordination / docs / board
# keep full reasoning; coordination / docs / board roles run "minimal" (a live # roles request "minimal". opencode receives this via its `--variant` flag (and
# test cut reasoning ~54% with no quality cost for that work). opencode applies # the serve message `variant` field). NOTE: whether opencode actually applies a
# this via its `--variant` flag. Operators can force one effort for ALL grok # named reasoning variant to grok-build-0.1 without a provider-defined `variants`
# agents with the ROBOCO_GROK_REASONING_EFFORT env on the orchestrator # block is UNVERIFIED — passing the flag does not error, but the reasoning-cost
# (value "minimal" | "high" | "max", or "default"/"full" to use full reasoning). # 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( _MINIMAL_REASONING_ROLES = frozenset(
{ {
"cell_pm", "cell_pm",
@@ -262,8 +265,7 @@ class GrokProvider(AgentProvider):
[ [
# opencode's BUILT-IN xai provider authenticates from XAI_API_KEY # opencode's BUILT-IN xai provider authenticates from XAI_API_KEY
# and reads XAI_BASE_URL for the endpoint — opencode_config emits # and reads XAI_BASE_URL for the endpoint — opencode_config emits
# no provider block (any provider.xai block breaks plugin-tool # no provider block, so these envs are the only LLM wiring needed.
# registration), so these envs are the only LLM wiring.
"-e", "-e",
f"XAI_API_KEY={config.provider_auth_token}", f"XAI_API_KEY={config.provider_auth_token}",
"-e", "-e",
+27 -32
View File
@@ -7,12 +7,10 @@ sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code
as importable Python (not a shell heredoc) makes the translation unit-testable. as importable Python (not a shell heredoc) makes the translation unit-testable.
Config shape per opencode docs (https://opencode.ai/docs/config): Config shape per opencode docs (https://opencode.ai/docs/config):
* NO ``provider`` block. opencode's BUILT-IN xai provider drives * NO ``provider`` block opencode's BUILT-IN xai provider already drives
grok-build-0.1; ANY custom ``provider.xai`` block (even just ``options``) grok-build-0.1 (model resolution + tool-calls verified live), so a custom
breaks plugin-tool registration, and a ``npm`` override additionally breaks block is unnecessary. The key + base URL reach the provider via the
model resolution (ProviderModelNotFoundError) — all verified live on opencode ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars; ``model`` selects ``xai/<model>``.
1.17.8. The key + base URL reach the provider via the ``XAI_API_KEY`` /
``XAI_BASE_URL`` env vars; ``model`` selects ``xai/<model>``.
* ``mcp.<name>`` — ``{type:"local", command:[...], environment:{...}}``; this * ``mcp.<name>`` — ``{type:"local", command:[...], environment:{...}}``; this
is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired, is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired,
translated from Claude Code's ``mcpServers`` (``command`` + ``args`` + ``env``). 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 review). This is the primary idle-stream defence (the orchestrator reaper is
the backstop). the backstop).
There is NO ``plugin`` key: opencode 1.17.8 does not register a plugin's There is NO ``plugin`` key: the plugins are baked into opencode's plugin
hooks/tools from a config ``plugin:``-array absolute path — only from the plugin AUTO-DISCOVERY dir (``~/.config/opencode/plugin/``, i.e.
AUTO-DISCOVERY dir (``~/.config/opencode/plugin/``). The plugins are baked there ``/home/agent/.config/opencode/plugin/`` in the image), so the generated config
in the images instead (secret-scrub + budget-feed in the base grok image; the doesn't need to reference them by path (secret-scrub + budget-feed in the base
Secretary's directive tools and the Intake's propose_draft in their interactive grok image; the Secretary's directive tools and the Intake's propose_draft in
images). 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`` GUARDRAIL PARITY: the bash-guard (PAT-scrub) is ported via ``secret-scrub.js``
(``tool.execute.before``); the per-session budget / loop / terminal-verb (``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" _OPENCODE_SCHEMA = "https://opencode.ai/config.json"
_PROVIDER_ID = "xai" _PROVIDER_ID = "xai"
# We do NOT override provider.<id>.npm. opencode's BUILT-IN xai provider already # No provider block at all: opencode's BUILT-IN xai provider already drives
# drives grok-build-0.1 with working tool-calls (verified live); a custom `npm` # grok-build-0.1 with working tool-calls (verified live), so no custom `npm` /
# (e.g. @ai-sdk/openai) is not resolvable from opencode's module path and makes # `models` override is needed. The xAI key is injected via the XAI_API_KEY env
# the model fail to resolve (ProviderModelNotFoundError). The xAI key is injected # var the built-in provider reads (set by GrokProvider / the orchestrator).
# 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.
# #
# Plugins (secret-scrub / budget-feed / the per-role tool plugins) are NOT listed # Plugins (secret-scrub / budget-feed / the per-role tool plugins) are baked into
# 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
# the plugin AUTO-DISCOVERY dir (~/.config/opencode/plugin/, i.e. # the plugin AUTO-DISCOVERY dir (~/.config/opencode/plugin/, i.e.
# /home/agent/.config/opencode/plugin/ in the image) instead, which registers # /home/agent/.config/opencode/plugin/ in the image) rather than referenced by a
# both tools and hooks (verified live against grok-build-0.1). # 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 # 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 # 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 # is the primary defence against the idle-stream hang; the orchestrator's
# reaper watchdog (_maybe_kill_wedged_grok) is the backstop. (Per-provider # reaper watchdog (_maybe_kill_wedged_grok) is the backstop. (Per-provider
# request/stream timeouts can't be set without a custom provider.npm, which # request/stream timeouts would need a custom provider block, which we don't
# breaks model resolution — see the module docstring — so they are not used.) # emit; the reaper + disabled subagents cover the idle-stream risk instead.)
_SUBAGENT_TOOL = "task" _SUBAGENT_TOOL = "task"
@@ -134,13 +131,11 @@ def build_opencode_config(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build the ``opencode.json`` dict for a Grok agent. """Build the ``opencode.json`` dict for a Grok agent.
Emits NO ``provider`` block: opencode's BUILT-IN xai provider drives Emits NO ``provider`` block opencode's BUILT-IN xai provider drives
grok-build-0.1, and ANY custom ``provider.xai`` block breaks plugin-tool grok-build-0.1 (verified live), so a custom block is unnecessary; the key +
registration AND (without ``XAI_API_KEY``) model resolution — all verified base URL are injected via the ``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars
live on opencode 1.17.8. The key + base URL are injected via the (set by GrokProvider / the orchestrator). No ``plugin`` array either —
``XAI_API_KEY`` / ``XAI_BASE_URL`` env vars (set by GrokProvider / the plugins live in the auto-discovery dir baked into the images.
orchestrator). No ``plugin`` array either — plugins live in the
auto-discovery dir baked into the images.
""" """
guards = guards or OpencodeGuards() guards = guards or OpencodeGuards()
config: dict[str, Any] = { config: dict[str, Any] = {
+2 -2
View File
@@ -3443,8 +3443,8 @@ class AgentOrchestrator:
cmd.extend( cmd.extend(
[ [
# Built-in xai provider authenticates from XAI_API_KEY and # Built-in xai provider authenticates from XAI_API_KEY and
# reads XAI_BASE_URL; opencode_config emits no provider block # reads XAI_BASE_URL; opencode_config emits no provider block,
# (any provider.xai block breaks plugin-tool registration). # so these envs are the only LLM wiring needed.
"-e", "-e",
f"XAI_API_KEY={auth_token or ''}", f"XAI_API_KEY={auth_token or ''}",
"-e", "-e",