Skip to content

Commit 5bc6e02

Browse files
author
SidQin-cyber
committed
fix(agents): trim whitespace from LLM tool call names to prevent lookup failures
Some models return tool call names with leading/trailing whitespace (e.g. " read " instead of "read"). The agent loop in pi-agent-core uses an exact-match lookup (`tools.find(t => t.name === toolCall.name)`) which fails on these names, producing "Tool not found" errors. Wrap the stream function to intercept response events and trim toolCall content block names in-place before they reach the agent loop's tool dispatch. Closes #27045
1 parent e35fe78 commit 5bc6e02

File tree

2 files changed

+60
-2
lines changed

2 files changed

+60
-2
lines changed

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
3-
import type { AgentMessage } from "@mariozechner/pi-agent-core";
3+
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
44
import type { ImageContent } from "@mariozechner/pi-ai";
55
import { streamSimple } from "@mariozechner/pi-ai";
66
import {
@@ -128,6 +128,58 @@ type PromptBuildHookRunner = {
128128
) => Promise<PluginHookBeforeAgentStartResult | undefined>;
129129
};
130130

131+
/**
132+
* Trim leading/trailing whitespace from tool call names in the LLM response
133+
* stream. Some models return tool names like " read " which causes the
134+
* agent loop's exact-match lookup to fail with "Tool not found" (#27045).
135+
*
136+
* We intercept the async iterator to mutate toolCall content blocks in-place
137+
* as they flow through. Because the partial message objects are the same
138+
* references used by the final result, the trimmed names propagate to the
139+
* `done` event automatically.
140+
*/
141+
function wrapStreamFnTrimToolNames(baseFn: StreamFn): StreamFn {
142+
return (model, context, options) => {
143+
const stream = baseFn(model, context, options);
144+
const origIterator = stream[Symbol.asyncIterator].bind(stream);
145+
(stream as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = function () {
146+
const iter = origIterator();
147+
return {
148+
async next() {
149+
const result = await iter.next();
150+
if (!result.done && result.value) {
151+
const event = result.value as {
152+
type?: string;
153+
partial?: { content?: unknown[] };
154+
message?: { content?: unknown[] };
155+
};
156+
const contents: unknown[][] = [];
157+
if (event.partial?.content) contents.push(event.partial.content);
158+
if (event.message?.content) contents.push(event.message.content);
159+
for (const content of contents) {
160+
for (const block of content) {
161+
const b = block as { type?: string; name?: string };
162+
if (b.type === "toolCall" && typeof b.name === "string") {
163+
const trimmed = b.name.trim();
164+
if (trimmed !== b.name) b.name = trimmed;
165+
}
166+
}
167+
}
168+
}
169+
return result;
170+
},
171+
async return(value?: unknown) {
172+
return iter.return?.(value) ?? { done: true as const, value: undefined };
173+
},
174+
async throw(err?: unknown) {
175+
return iter.throw?.(err) ?? { done: true as const, value: undefined };
176+
},
177+
};
178+
};
179+
return stream;
180+
};
181+
}
182+
131183
export function injectHistoryImagesIntoMessages(
132184
messages: AgentMessage[],
133185
historyImagesByIndex: Map<number, ImageContent[]>,
@@ -823,6 +875,11 @@ export async function runEmbeddedAttempt(
823875
);
824876
}
825877

878+
// Normalize tool call names from LLM responses: some models return
879+
// tool names with leading/trailing whitespace, causing "Tool not found"
880+
// errors in the agent loop's exact-match lookup (#27045).
881+
activeSession.agent.streamFn = wrapStreamFnTrimToolNames(activeSession.agent.streamFn);
882+
826883
try {
827884
const prior = await sanitizeSessionHistory({
828885
messages: activeSession.messages,

src/agents/pi-tool-definition-adapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ function splitToolExecuteArgs(args: ToolExecuteArgsAny): {
137137
}
138138

139139
export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
140-
return tools.map((tool) => {
140+
const defs = tools.map((tool) => {
141141
const name = tool.name || "tool";
142142
const normalizedName = normalizeToolName(name);
143143
const beforeHookWrapped = isToolWrappedWithBeforeToolCallHook(tool);
@@ -240,6 +240,7 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
240240
},
241241
} satisfies ToolDefinition;
242242
});
243+
return defs;
243244
}
244245

245246
// Convert client tools (OpenResponses hosted tools) to ToolDefinition format

0 commit comments

Comments
 (0)