mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 05:24:32 +00:00
fix(agents): normalize whitespace-padded tool call names before dispatch (#27094)
Fix tool-call lookup failures when models emit whitespace-padded names by normalizing both transcript history and live streamed embedded-runner tool calls before dispatch. Co-authored-by: wangchunyue <80630709+openperf@users.noreply.github.com> Co-authored-by: Sid <sidqin0410@gmail.com> Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
@@ -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<unknown>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<unknown>;
|
||||
} {
|
||||
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<typeof wrappedFn>
|
||||
>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PluginHookBeforeAgentStartResult | undefined>;
|
||||
};
|
||||
|
||||
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<typeof streamSimple>,
|
||||
): ReturnType<typeof streamSimple> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user