From 7fe70817452c8d03c1620656e84e85029426e8de Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 27 Mar 2026 04:01:20 +0100 Subject: [PATCH] fix(coding-agent): document mutable tool_call input closes #2611 --- packages/agent/test/agent-loop.test.ts | 62 +++++++++++++++++++ packages/coding-agent/docs/extensions.md | 12 +++- .../coding-agent/src/core/extensions/types.ts | 8 ++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 5de15250..1e201e7d 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -307,6 +307,68 @@ describe("agentLoop with AgentMessage", () => { } }); + it("should execute mutated beforeToolCall args without revalidation", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + const executed: Array = []; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.value as string | number); + return { + content: [{ type: "text", text: `echoed: ${String(params.value)}` }], + details: { value: params.value as string | number }, + }; + }, + }; + + const context: AgentContext = { + systemPrompt: "", + messages: [], + tools: [tool], + }; + + const userPrompt: AgentMessage = createUserMessage("echo something"); + + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + beforeToolCall: async ({ args }) => { + const mutableArgs = args as { value: string | number }; + mutableArgs.value = 123; + return undefined; + }, + }; + + let callIndex = 0; + const streamFn = () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + if (callIndex === 0) { + const message = createAssistantMessage( + [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }], + "toolUse", + ); + stream.push({ type: "done", reason: "toolUse", message }); + } else { + const message = createAssistantMessage([{ type: "text", text: "done" }]); + stream.push({ type: "done", reason: "stop", message }); + } + callIndex++; + }); + return stream; + }; + + const stream = agentLoop([userPrompt], context, config, undefined, streamFn); + for await (const _event of stream) { + // consume + } + + expect(executed).toEqual([123]); + }); + it("should execute tool calls in parallel and emit tool results in source order", async () => { const toolSchema = Type.Object({ value: Type.String() }); let firstResolved = false; diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5fde9ffb..4ea30e75 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -565,17 +565,27 @@ Before `tool_call` runs, pi waits for previously emitted Agent events to finish In the default parallel tool execution mode, sibling tool calls from the same assistant message are preflighted sequentially, then executed concurrently. `tool_call` is not guaranteed to see sibling tool results from that same assistant message in `ctx.sessionManager`. +`event.input` is mutable. Mutate it in place to patch tool arguments before execution. + +Behavior guarantees: +- Mutations to `event.input` affect the actual tool execution +- Later `tool_call` handlers see mutations made by earlier handlers +- No re-validation is performed after your mutation +- Return values from `tool_call` only control blocking via `{ block: true, reason?: string }` + ```typescript import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; pi.on("tool_call", async (event, ctx) => { // event.toolName - "bash", "read", "write", "edit", etc. // event.toolCallId - // event.input - tool parameters + // event.input - tool parameters (mutable) // Built-in tools: no type params needed if (isToolCallEventType("bash", event)) { // event.input is { command: string; timeout?: number } + event.input.command = `source ~/.profile\n${event.input.command}`; + if (event.input.command.includes("rm -rf")) { return { block: true, reason: "Dangerous command" }; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index d4a34f1a..42048733 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -717,7 +717,12 @@ export interface CustomToolCallEvent extends ToolCallEventBase { input: Record; } -/** Fired before a tool executes. Can block. */ +/** + * Fired before a tool executes. Can block. + * + * `event.input` is mutable. Mutate it in place to patch tool arguments before execution. + * Later `tool_call` handlers see earlier mutations. No re-validation is performed after mutation. + */ export type ToolCallEvent = | BashToolCallEvent | ReadToolCallEvent @@ -879,6 +884,7 @@ export interface ContextEventResult { export type BeforeProviderRequestEventResult = unknown; export interface ToolCallEventResult { + /** Block tool execution. To modify arguments, mutate `event.input` in place instead. */ block?: boolean; reason?: string; }