diff --git a/.changeset/fn-7724-grok-cli-tool-bridging.md b/.changeset/fn-7724-grok-cli-tool-bridging.md new file mode 100644 index 0000000000..86ae1618fa --- /dev/null +++ b/.changeset/fn-7724-grok-cli-tool-bridging.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Grok CLI runtime now bridges tool execution events (name/args/result) from the NDJSON stream, not just text. +category: feature +dev: GrokRuntimeAdapter.promptWithFallback now bridges tool_use NDJSON events into onToolStart/onToolEnd; tool name/args/result pass through unchanged (no Grok→pi name mapping — the verified docs/grok-cli-contract.md schema does not pin a tool-name vocabulary). step_finish/error remain non-terminal per-step events and are not bridged to a callback; only subprocess close/error finalizes, unchanged from FN-7722. Fixture-tested (no live binary). End-to-end runtimeHint="grok" routing remains FN-7725's scope; the direct xAI path (FN-7711/7714) is unchanged. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index 316b46372f..7b5e2faa30 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -253,10 +253,18 @@ See the task's `fn_task_create` calls (linked from FN-7722) for: 1. ~~End-to-end routing wiring~~ — **closed by FN-7725** (see "Wiring" above): the agent Runtime-mode picker path was formalized, documented, and covered by `packages/engine/src/__tests__/grok-runtime-routing.test.ts`. -2. Full tool-call/break-early bridging for `tool_use` NDJSON events, if a - future need for Grok-CLI-driven tool execution arises (out of scope for - the scoped text/no-thinking adapter landed here; tracked separately as - FN-7724). +2. ~~Full tool-call bridging for `tool_use` NDJSON events~~ — **closed by + FN-7724**: `GrokRuntimeAdapter` now bridges `tool_use` into + `onToolStart`/`onToolEnd` (no Grok→pi tool-name mapping was added — the + verified schema does not pin grok-cli's tool-name vocabulary, so + names/args pass through unchanged). Break-early on `step_finish` was + deliberately NOT adopted: this doc's own "Verified NDJSON event schema" + notes above establish `step_finish` is a per-step boundary (a run can + contain multiple `step_start`/`step_finish` pairs), not the run + terminal — the adapter's terminal signal remains subprocess + `close`/`error`, unchanged from FN-7722. See + `plugins/fusion-plugin-grok-runtime/README.md`'s "Tool execution + bridging (FN-7724)" section. 3. (Filed by FN-7725, if warranted) Preserving a specific `grok-cli/*` model selection when routing through the CLI runtime (Runtime-mode is currently model-agnostic — see "Known limitation" in "Wiring" above). This is the diff --git a/plugins/fusion-plugin-grok-runtime/README.md b/plugins/fusion-plugin-grok-runtime/README.md index 8b09dd3409..820c3a7ed5 100644 --- a/plugins/fusion-plugin-grok-runtime/README.md +++ b/plugins/fusion-plugin-grok-runtime/README.md @@ -59,6 +59,18 @@ grok --prompt "" --format json - The adapter parses that stream (`src/stream-parser.ts`) and drives `onText` as `text` events arrive. There is no `thinking`/`reasoning` event in the verified schema, so `onThinking` is never invoked for this path. +- **Tool execution bridging (FN-7724):** each verified `tool_use` event + (`toolCall`/`toolResult`/`timing`) additionally drives `onToolStart(toolName, + args)` / `onToolEnd(toolName, isError, result)`, mirroring the Droid + plugin's `DroidCallbacks` shape. `toolName`/`args` are + `toolCall.function.name` / parsed `toolCall.function.arguments`; + `isError` derives from `toolResult.success === false`. No Grok→pi + tool-name/arg translation is applied — the verified contract does not pin + grok-cli's specific tool-name vocabulary (unlike Droid's Claude-shaped + names), so names/args pass through unchanged. `step_finish` is a per-step + boundary (a run can contain multiple), not the run terminal, so it does + not finalize the adapter's promise; only subprocess `close`/`error` does, + unchanged from FN-7722. - **Auth implication:** because the `grok` binary resolves its own credentials for this path (env var, project `.env`, `grok -k`, or `~/.grok/user-settings.json`), a CLI-routed selection needs **no diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts index fda1ff36ca..685442a4a6 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts @@ -85,6 +85,126 @@ describe("GrokRuntimeAdapter", () => { await expect(promise).resolves.toBeUndefined(); }); + // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — tool_use bridging coverage. + it("bridges tool_use events into onToolStart/onToolEnd in order with translated args", async () => { + const { proc, stdout } = makeFakeProc(); + const spawn = vi.fn().mockReturnValue(proc); + const adapter = new GrokRuntimeAdapter({ spawn }); + + const onToolStart = vi.fn(); + const onToolEnd = vi.fn(); + const { session } = await adapter.createSession({ onToolStart, onToolEnd }); + + const promise = adapter.promptWithFallback(session, "list files"); + + stdout.write(`${JSON.stringify({ type: "step_start", stepNumber: 1, timestamp: 1 })}\n`); + stdout.write( + `${JSON.stringify({ + type: "tool_use", + stepNumber: 1, + timestamp: 2, + toolCall: { id: "tc-1", type: "function", function: { name: "bash", arguments: '{"command":"ls"}' } }, + toolResult: { success: true, output: "a.ts\nb.ts" }, + timing: { startedAt: 1, finishedAt: 2, durationMs: 1 }, + })}\n`, + ); + stdout.write( + `${JSON.stringify({ + type: "step_finish", + stepNumber: 1, + timestamp: 3, + finishReason: "tool_calls", + usage: {}, + })}\n`, + ); + proc.emit("close", 0, null); + + await promise; + + expect(onToolStart).toHaveBeenCalledTimes(1); + expect(onToolStart).toHaveBeenCalledWith("bash", { command: "ls" }); + expect(onToolEnd).toHaveBeenCalledTimes(1); + expect(onToolEnd).toHaveBeenCalledWith("bash", false, { success: true, output: "a.ts\nb.ts" }); + // onToolStart must fire before onToolEnd for the same tool call. + expect(onToolStart.mock.invocationCallOrder[0]).toBeLessThan(onToolEnd.mock.invocationCallOrder[0]); + }); + + it("marks onToolEnd as an error when toolResult.success is false", async () => { + const { proc, stdout } = makeFakeProc(); + const spawn = vi.fn().mockReturnValue(proc); + const adapter = new GrokRuntimeAdapter({ spawn }); + const onToolStart = vi.fn(); + const onToolEnd = vi.fn(); + const { session } = await adapter.createSession({ onToolStart, onToolEnd }); + + const promise = adapter.promptWithFallback(session, "read missing file"); + stdout.write( + `${JSON.stringify({ + type: "tool_use", + stepNumber: 1, + timestamp: 2, + toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"x"}' } }, + toolResult: { success: false, output: "ENOENT" }, + })}\n`, + ); + proc.emit("close", 0, null); + + await promise; + + expect(onToolEnd).toHaveBeenCalledWith("read_file", true, { success: false, output: "ENOENT" }); + }); + + it("handles malformed tool_use arguments without throwing, passing the raw string through", async () => { + const { proc, stdout } = makeFakeProc(); + const spawn = vi.fn().mockReturnValue(proc); + const adapter = new GrokRuntimeAdapter({ spawn }); + const onToolStart = vi.fn(); + const { session } = await adapter.createSession({ onToolStart }); + + const promise = adapter.promptWithFallback(session, "hi"); + stdout.write( + `${JSON.stringify({ + type: "tool_use", + stepNumber: 1, + timestamp: 2, + toolCall: { id: "tc-3", type: "function", function: { name: "bash", arguments: "not-json" } }, + toolResult: { success: true }, + })}\n`, + ); + proc.emit("close", 0, null); + + await expect(promise).resolves.toBeUndefined(); + expect(onToolStart).toHaveBeenCalledWith("bash", "not-json"); + }); + + it("does not finalize on step_finish alone (per-step, not run-terminal); only close/error finalizes", async () => { + const { proc, stdout } = makeFakeProc(); + const spawn = vi.fn().mockReturnValue(proc); + const adapter = new GrokRuntimeAdapter({ spawn }); + const onText = vi.fn(); + const { session } = await adapter.createSession({ onText }); + + const promise = adapter.promptWithFallback(session, "multi-round"); + let resolved = false; + void promise.then(() => { + resolved = true; + }); + + stdout.write( + `${JSON.stringify({ type: "step_finish", stepNumber: 1, timestamp: 1, finishReason: "tool_calls", usage: {} })}\n`, + ); + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toBe(false); + + stdout.write(`${JSON.stringify({ type: "text", stepNumber: 2, text: "done", timestamp: 2 })}\n`); + proc.emit("close", 0, null); + await promise; + + expect(resolved).toBe(true); + expect(onText).toHaveBeenCalledWith("done"); + }); + it("never invokes onThinking: the verified grok-cli NDJSON schema has no thinking/reasoning event", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts index 51674cd1de..60146ff5f7 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts @@ -76,4 +76,37 @@ describe("parseLine (Grok CLI NDJSON)", () => { it("skips a JSON array (not an object)", () => { expect(parseLine(JSON.stringify([{ type: "text" }]))).toBeNull(); }); + + // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additional tool_use/step_finish/ + // error coverage for the runtime-adapter bridge (Step 3). The parser itself + // needed no change (see stream-parser.ts's FN-7724 comment); these prove + // the full toolCall/toolResult/timing shape round-trips and malformed tool + // lines are still skipped without throwing. + it("parses a tool_use event with full toolCall/toolResult/timing fields", () => { + const line = JSON.stringify({ + type: "tool_use", + sessionID: "sess-2", + stepNumber: 2, + timestamp: 300, + toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }, + toolResult: { success: false, output: "ENOENT" }, + timing: { startedAt: 280, finishedAt: 300, durationMs: 20 }, + }); + const parsed = parseLine(line); + expect(parsed).toEqual({ + type: "tool_use", + sessionID: "sess-2", + stepNumber: 2, + timestamp: 300, + toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }, + toolResult: { success: false, output: "ENOENT" }, + timing: { startedAt: 280, finishedAt: 300, durationMs: 20 }, + }); + }); + + it("skips a malformed tool_use line (broken JSON) without throwing", () => { + const line = '{"type":"tool_use","toolCall":{"function":{"name":'; + expect(() => parseLine(line)).not.toThrow(); + expect(parseLine(line)).toBeNull(); + }); }); diff --git a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index 191eedc38b..3e318b52dc 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -12,15 +12,26 @@ prose: src/index.ts's CLI parsing + src/headless/output.ts's `createHeadlessJsonlEmitter` + its fixture tests). Contract captured in docs/grok-cli-contract.md. This adapter spawns that command via the `cli-stream` seam, parses NDJSON via `stream-parser.parseLine`, and drives -`onText` as `text` events arrive. Scoped deliberately narrow, mirroring the -Droid plugin's parser+text-bridge pattern but NOT its full tool-call/ -break-early machinery: the verified schema has no thinking/reasoning event -(onThinking is therefore never invoked here — kept only for AgentRuntime -interface parity), and tool_use bridging is filed as a follow-up (see -docs/grok-cli-contract.md "Follow-ups"). This adapter is only reached when -an agent's `runtimeConfig.runtimeHint === "grok"`, which nothing in the -product sets today (recorded as the wiring gap in the contract doc) — this -task lands the adapter without wiring an end-to-end exercised path. +`onText` as `text` events arrive. + +FNXC:GrokCli 2026-07-09-00:10: +FN-7724: extends the above with `tool_use` (and terminal `step_finish`/ +`error`) bridging, per docs/grok-cli-contract.md's verified NDJSON schema. +`onToolStart`/`onToolEnd` fire from each `tool_use` event's +`toolCall`/`toolResult`, mirroring the Droid plugin's `DroidCallbacks` +shape. No Grok→pi tool-name/arg mapping is applied: the verified contract +does not pin grok-cli's specific tool-name vocabulary (unlike Droid's +Claude-shaped names), so `toolCall.function.name`/parsed `.arguments` pass +through unchanged (decision recorded in the FN-7724 `research` task +document). `onThinking` is still never invoked — the verified schema has no +thinking/reasoning event (confirmed absence, not a gap). The terminal +lifecycle is UNCHANGED from FN-7722: the doc states `step_finish` is a +per-step boundary (multiple can occur per run for multi-round tool use), so +it does NOT finalize the promise here; only subprocess `close`/`error` +does, same `streamEnded`-guarded (via the existing `settled` flag) +resolve-never-reject lifecycle as before. This adapter is only reached when +an agent's `runtimeConfig.runtimeHint === "grok"` (wired end-to-end by +FN-7725). */ /** @@ -41,6 +52,25 @@ const FIRST_LINE_TIMEOUT_MS = 60_000; */ const INACTIVITY_TIMEOUT_MS = 30 * 60_000; +/** + * FNXC:GrokCli 2026-07-09-00:10: + * FN-7724: `toolCall.function.arguments` is a JSON-encoded string per the + * verified `ToolCall` shape (docs/grok-cli-contract.md / types.ts's + * `GrokToolCallLike`). Parse it defensively — malformed/missing arguments + * must never throw inside the NDJSON read loop; fall back to the raw string + * (or undefined) so callers still see something rather than losing the + * event, mirroring the Droid event-bridge's empty-args guard. + */ +function parseToolArguments(raw: string | undefined): unknown { + if (raw === undefined) return undefined; + if (raw === "") return {}; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + export interface GrokRuntimeAdapterOptions { /** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */ binary?: string; @@ -59,7 +89,16 @@ export class GrokRuntimeAdapter implements AgentRuntime { this.spawnFn = options?.spawn ?? spawnGrokStream; } - async createSession(options: { defaultModelId?: string; systemPrompt?: string; onText?: (text: string) => void; onThinking?: (text: string) => void } = {}): Promise { + async createSession( + options: { + defaultModelId?: string; + systemPrompt?: string; + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; + } = {}, + ): Promise { const model = options.defaultModelId ?? "grok/default"; const session: GrokSession = { model, @@ -70,6 +109,8 @@ export class GrokRuntimeAdapter implements AgentRuntime { callbacks: { onText: options.onText, onThinking: options.onThinking, + onToolStart: options.onToolStart, + onToolEnd: options.onToolEnd, }, }; return { session, sessionFile: undefined }; @@ -133,10 +174,26 @@ export class GrokRuntimeAdapter implements AgentRuntime { if (event.type === "text") { grokSession.callbacks.onText?.(event.text); + } else if (event.type === "tool_use") { + // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — bridge the verified + // tool_use event. toolCall.function.name/arguments and + // toolResult.success/output are the verified fields + // (docs/grok-cli-contract.md); pass-through, no name/arg mapping + // (see FN-7724 research task document for the decision). + const toolName = event.toolCall?.function?.name ?? event.toolCall?.type ?? "unknown"; + const args = parseToolArguments(event.toolCall?.function?.arguments); + grokSession.callbacks.onToolStart?.(toolName, args); + const isError = event.toolResult?.success === false; + grokSession.callbacks.onToolEnd?.(toolName, isError, event.toolResult); } - // step_start / tool_use / step_finish / error: intentionally not - // bridged by this scoped adapter (text-only). tool_use bridging is - // a follow-up (docs/grok-cli-contract.md). + // step_start / step_finish / error: step_finish is a per-step + // boundary (not run-terminal, per docs/grok-cli-contract.md — a run + // can have multiple step_start/step_finish pairs for multi-round + // tool use), so it is intentionally NOT bridged into a callback or + // treated as the finalize signal; only subprocess close/error + // finalizes (see finish() below). `error` events carry no dedicated + // callback in this scoped adapter (mirrors FN-7722: they can appear + // inline without ending the process, per the verified contract). }); proc.on("error", () => { diff --git a/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts b/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts index 5a5fcc29e4..4c7d4bf24c 100644 --- a/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts +++ b/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts @@ -11,6 +11,16 @@ throws. Debug noise, empty lines, and malformed/unrecognized JSON all return null so the streaming pipeline can safely skip them and continue. */ +/* +FNXC:GrokCli 2026-07-09-00:10: +FN-7724: confirmed at execution time — FN-7722 already typed `tool_use` / +`step_finish` / `error` into the `GrokNdjsonEvent` union (types.ts) and this +parser already accepts them via KNOWN_EVENT_TYPES below, so no parser change +was needed to "surface" them; only runtime-adapter.ts's bridge (previously +intentionally dropping tool_use/step_finish/error, see FN-7722 comment +above) needed extending. See docs/grok-cli-contract.md for the verified +schema this parser accepts unmodified. +*/ const KNOWN_EVENT_TYPES = new Set(["step_start", "text", "tool_use", "step_finish", "error"]); /** diff --git a/plugins/fusion-plugin-grok-runtime/src/types.ts b/plugins/fusion-plugin-grok-runtime/src/types.ts index f8963cbf66..9ec3318119 100644 --- a/plugins/fusion-plugin-grok-runtime/src/types.ts +++ b/plugins/fusion-plugin-grok-runtime/src/types.ts @@ -82,6 +82,25 @@ export interface GrokCallbacks { * event to bridge (see docs/grok-cli-contract.md). */ onThinking?: (text: string) => void; + /** + * FNXC:GrokCli 2026-07-09-00:10: + * FN-7724: bridged from the verified `tool_use` NDJSON event's + * `toolCall.function.name` / parsed `toolCall.function.arguments`. + * Mirrors the Droid plugin's `DroidCallbacks.onToolStart` signature. No + * Grok→pi tool-name mapping is applied — the verified contract + * (docs/grok-cli-contract.md) does not pin grok-cli's specific tool-name + * vocabulary, so names/args pass through unchanged (see FN-7724 research + * task document for the decision). + */ + onToolStart?: (toolName: string, args?: unknown) => void; + /** + * FNXC:GrokCli 2026-07-09-00:10: + * FN-7724: bridged from the same `tool_use` event's `toolResult` field — + * `isError` derives from the verified `toolResult.success === false`, + * `result` is the full `toolResult` object (includes `output` plus any + * other verified/unverified passthrough fields). + */ + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; } export interface GrokSession { @@ -101,6 +120,10 @@ export interface AgentRuntimeOptions { defaultModelId?: string; onText?: (text: string) => void; onThinking?: (text: string) => void; + /** FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additive, mirrors GrokCallbacks.onToolStart. */ + onToolStart?: (toolName: string, args?: unknown) => void; + /** FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additive, mirrors GrokCallbacks.onToolEnd. */ + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; signal?: AbortSignal; }