|
1 | 1 | import fs from "node:fs/promises"; |
2 | 2 | import os from "node:os"; |
3 | | -import type { AgentMessage } from "@mariozechner/pi-agent-core"; |
| 3 | +import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core"; |
4 | 4 | import type { ImageContent } from "@mariozechner/pi-ai"; |
5 | 5 | import { streamSimple } from "@mariozechner/pi-ai"; |
6 | 6 | import { |
@@ -128,6 +128,58 @@ type PromptBuildHookRunner = { |
128 | 128 | ) => Promise<PluginHookBeforeAgentStartResult | undefined>; |
129 | 129 | }; |
130 | 130 |
|
| 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 | + |
131 | 183 | export function injectHistoryImagesIntoMessages( |
132 | 184 | messages: AgentMessage[], |
133 | 185 | historyImagesByIndex: Map<number, ImageContent[]>, |
@@ -823,6 +875,11 @@ export async function runEmbeddedAttempt( |
823 | 875 | ); |
824 | 876 | } |
825 | 877 |
|
| 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 | + |
826 | 883 | try { |
827 | 884 | const prior = await sanitizeSessionHistory({ |
828 | 885 | messages: activeSession.messages, |
|
0 commit comments