diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ced0c5cda..9b41fd7719f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,7 @@ Docs: https://docs.openclaw.ai - Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. - Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025) - Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007) +- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool ... not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) - Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel. diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index f314353513b..7f2a05b02f7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -4,6 +4,7 @@ import { resolveAttemptFsWorkspaceOnly, resolvePromptBuildHookResult, resolvePromptModeForSession, + wrapStreamFnTrimToolCallNames, } from "./attempt.js"; describe("resolvePromptBuildHookResult", () => { @@ -103,3 +104,73 @@ describe("resolveAttemptFsWorkspaceOnly", () => { ).toBe(false); }); }); + +describe("wrapStreamFnTrimToolCallNames", () => { + function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): { + result: () => Promise; + [Symbol.asyncIterator]: () => AsyncIterator; + } { + return { + async result() { + return params.resultMessage; + }, + [Symbol.asyncIterator]() { + return (async function* () { + for (const event of params.events) { + yield event; + } + })(); + }, + }; + } + + it("trims whitespace from live streamed tool call names and final result message", async () => { + const partialToolCall = { type: "toolCall", name: " read " }; + const messageToolCall = { type: "toolCall", name: " exec " }; + const finalToolCall = { type: "toolCall", name: " write " }; + const event = { + type: "toolcall_delta", + partial: { role: "assistant", content: [partialToolCall] }, + message: { role: "assistant", content: [messageToolCall] }, + }; + const finalMessage = { role: "assistant", content: [finalToolCall] }; + const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage })); + + const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never); + const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited< + ReturnType + >; + + const seenEvents: unknown[] = []; + for await (const item of stream) { + seenEvents.push(item); + } + const result = await stream.result(); + + expect(seenEvents).toHaveLength(1); + expect(partialToolCall.name).toBe("read"); + expect(messageToolCall.name).toBe("exec"); + expect(finalToolCall.name).toBe("write"); + expect(result).toBe(finalMessage); + expect(baseFn).toHaveBeenCalledTimes(1); + }); + + it("supports async stream functions that return a promise", async () => { + const finalToolCall = { type: "toolCall", name: " browser " }; + const finalMessage = { role: "assistant", content: [finalToolCall] }; + const baseFn = vi.fn(async () => + createFakeStream({ + events: [], + resultMessage: finalMessage, + }), + ); + + const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never); + const stream = await wrappedFn({} as never, {} as never, {} as never); + const result = await stream.result(); + + expect(finalToolCall.name).toBe("browser"); + expect(result).toBe(finalMessage); + expect(baseFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 060c53e306a..08706eb57e7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; -import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core"; import { streamSimple } from "@mariozechner/pi-ai"; import { createAgentSession, @@ -127,6 +127,78 @@ type PromptBuildHookRunner = { ) => Promise; }; +function trimWhitespaceFromToolCallNamesInMessage(message: unknown): void { + if (!message || typeof message !== "object") { + return; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return; + } + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const typedBlock = block as { type?: unknown; name?: unknown }; + if (typedBlock.type !== "toolCall" || typeof typedBlock.name !== "string") { + continue; + } + const trimmed = typedBlock.name.trim(); + if (trimmed !== typedBlock.name) { + typedBlock.name = trimmed; + } + } +} + +function wrapStreamTrimToolCallNames( + stream: ReturnType, +): ReturnType { + const originalResult = stream.result.bind(stream); + stream.result = async () => { + const message = await originalResult(); + trimWhitespaceFromToolCallNamesInMessage(message); + return message; + }; + + const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream); + (stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] = + function () { + const iterator = originalAsyncIterator(); + return { + async next() { + const result = await iterator.next(); + if (!result.done && result.value && typeof result.value === "object") { + const event = result.value as { + partial?: unknown; + message?: unknown; + }; + trimWhitespaceFromToolCallNamesInMessage(event.partial); + trimWhitespaceFromToolCallNamesInMessage(event.message); + } + return result; + }, + async return(value?: unknown) { + return iterator.return?.(value) ?? { done: true as const, value: undefined }; + }, + async throw(error?: unknown) { + return iterator.throw?.(error) ?? { done: true as const, value: undefined }; + }, + }; + }; + + return stream; +} + +export function wrapStreamFnTrimToolCallNames(baseFn: StreamFn): StreamFn { + return (model, context, options) => { + const maybeStream = baseFn(model, context, options); + if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) { + return Promise.resolve(maybeStream).then((stream) => wrapStreamTrimToolCallNames(stream)); + } + return wrapStreamTrimToolCallNames(maybeStream); + }; +} + export async function resolvePromptBuildHookResult(params: { prompt: string; messages: unknown[]; @@ -769,6 +841,11 @@ export async function runEmbeddedAttempt( }; } + // Some models emit tool names with surrounding whitespace (e.g. " read "). + // pi-agent-core dispatches tool calls with exact string matching, so normalize + // names on the live response stream before tool execution. + activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames(activeSession.agent.streamFn); + if (anthropicPayloadLogger) { activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn( activeSession.agent.streamFn, diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index e1422f7ea40..1ff6b50ff22 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -314,4 +314,90 @@ describe("sanitizeToolCallInputs", () => { : []; expect(types).toEqual(["text", "toolUse"]); }); + + it("trims leading whitespace from tool names", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }], + }, + ] as unknown as AgentMessage[]; + + const out = sanitizeToolCallInputs(input); + const toolCalls = getAssistantToolCallBlocks(out); + + expect(toolCalls).toHaveLength(1); + expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); + }); + + it("trims trailing whitespace from tool names", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }], + }, + ] as unknown as AgentMessage[]; + + const out = sanitizeToolCallInputs(input); + const toolCalls = getAssistantToolCallBlocks(out); + + expect(toolCalls).toHaveLength(1); + expect((toolCalls[0] as { name?: unknown }).name).toBe("exec"); + }); + + it("trims both leading and trailing whitespace from tool names", () => { + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_1", name: " read ", arguments: {} }, + { type: "toolUse", id: "call_2", name: " exec ", input: {} }, + ], + }, + ] as unknown as AgentMessage[]; + + const out = sanitizeToolCallInputs(input); + const toolCalls = getAssistantToolCallBlocks(out); + + expect(toolCalls).toHaveLength(2); + expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); + expect((toolCalls[1] as { name?: unknown }).name).toBe("exec"); + }); + + it("trims tool names and matches against allowlist", () => { + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_1", name: " read ", arguments: {} }, + { type: "toolCall", id: "call_2", name: " write ", arguments: {} }, + ], + }, + ] as unknown as AgentMessage[]; + + const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] }); + const toolCalls = getAssistantToolCallBlocks(out); + + expect(toolCalls).toHaveLength(1); + expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); + }); + + it("preserves other block properties when trimming tool names", () => { + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } }, + ], + }, + ] as unknown as AgentMessage[]; + + const out = sanitizeToolCallInputs(input); + const toolCalls = getAssistantToolCallBlocks(out); + + expect(toolCalls).toHaveLength(1); + expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); + expect((toolCalls[0] as { id?: unknown }).id).toBe("call_1"); + expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({ path: "/tmp/test" }); + }); }); diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 31b9624874c..33d7fcc55ef 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -60,7 +60,7 @@ function hasToolCallName(block: ToolCallBlock, allowedToolNames: Set | n return false; } const trimmed = block.name.trim(); - if (!trimmed || trimmed !== block.name) { + if (!trimmed) { return false; } if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) { @@ -143,8 +143,9 @@ export function repairToolCallInputs( continue; } - const nextContent = []; + const nextContent: typeof msg.content = []; let droppedInMessage = 0; + let trimmedInMessage = 0; for (const block of msg.content) { if ( @@ -158,6 +159,19 @@ export function repairToolCallInputs( changed = true; continue; } + // Normalize tool call names by trimming whitespace so that downstream + // lookup (toolsByName map) matches correctly even when the model emits + // names with leading/trailing spaces (e.g. " read" → "read"). + if (isToolCallBlock(block) && typeof (block as ToolCallBlock).name === "string") { + const rawName = (block as ToolCallBlock).name as string; + if (rawName !== rawName.trim()) { + const normalized = { ...block, name: rawName.trim() } as typeof block; + nextContent.push(normalized); + trimmedInMessage += 1; + changed = true; + continue; + } + } nextContent.push(block); } @@ -171,6 +185,13 @@ export function repairToolCallInputs( continue; } + // When tool names were trimmed but nothing was dropped, + // we still need to emit the message with the normalized content. + if (trimmedInMessage > 0) { + out.push({ ...msg, content: nextContent }); + continue; + } + out.push(msg); }