Files
roboco/docker/grok/secretary-tools.js
T
Renn F e29168653a fix(grok): make the opencode runtime actually load — proven live on grok-build-0.1
Live verification (opencode 1.17.8 + grok-build-0.1, funded key) showed the Grok
runtime was loading INERT, three ways:

1. The provider override `provider.xai.npm=@ai-sdk/openai` failed model
   resolution (ProviderModelNotFoundError) — opencode can't resolve that package
   from its module path. Worse, ANY custom `provider.xai` block (even just
   options) breaks plugin-tool registration. opencode's BUILT-IN xai provider
   drives grok-build-0.1 with working tool-calls, so emit NO provider block; the
   key + base reach it via XAI_API_KEY / XAI_BASE_URL env (provider.options.apiKey
   alone does NOT authenticate).
2. Plugins referenced by absolute path in the config `plugin:` array never
   registered their hooks/tools. opencode 1.17.8 only registers from the plugin
   AUTO-DISCOVERY dir (~/.config/opencode/plugin/). Bake all plugins there.
3. Plugins must use a NAMED export, not `export default`.

Changes:
- opencode_config: no `provider` block, no `plugin` array; drop the dead
  XaiTarget + timeout machinery; build_opencode_config now takes a model string.
- GrokProvider / orchestrator interactive env: inject XAI_API_KEY + XAI_BASE_URL
  (drop the now-unused OPENAI_*).
- secret-scrub / budget-feed / secretary-tools / intake-tools: named exports;
  baked into /home/agent/.config/opencode/plugin/ (drop the EXTRA_PLUGINS env).
- agent-grok* Dockerfiles: plugin dir + agent ownership; drop the unneeded
  @ai-sdk/openai global install.

Verified live end-to-end: grok-build-0.1 calls read_company_state AND
submit_directive through secretary-tools.js and the backend receives both with
the agent token; a tool.execute.before guard fires; built-in tool-calls work.
Targeted gate green (ruff/mypy/xenon + opencode_config/providers/interactive
tests; node --check the plugins).
2026-06-18 21:14:06 +02:00

120 lines
4.7 KiB
JavaScript

// 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).
//
// UNVERIFIED-LIVE: the @opencode-ai/plugin tool-registration path against a live
// opencode serve + grok-build-0.1 — confirm a submit_directive round-trips with
// the HMAC token on the NAS before routing real CEO directives through Grok.
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 + 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).
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 || {},
}),
);
},
}),
},
});