mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 21:14:31 +00:00
fix(agents): harden tool-name normalization and transcript repair
Landed from contributor PRs #30620 and #30735 by @Sid-Qin, plus #30881 by @liuxiaopai-ai. Co-authored-by: SidQin-cyber <sidqin0410@gmail.com> Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
This commit is contained in:
@@ -177,6 +177,50 @@ describe("wrapStreamFnTrimToolCallNames", () => {
|
||||
expect(result).toBe(finalMessage);
|
||||
expect(baseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("normalizes common tool aliases when the canonical name is allowed", async () => {
|
||||
const finalToolCall = { type: "toolCall", name: " BASH " };
|
||||
const finalMessage = { role: "assistant", content: [finalToolCall] };
|
||||
const baseFn = vi.fn(() =>
|
||||
createFakeStream({
|
||||
events: [],
|
||||
resultMessage: finalMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["exec"]));
|
||||
const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
|
||||
ReturnType<typeof wrappedFn>
|
||||
>;
|
||||
const result = await stream.result();
|
||||
|
||||
expect(finalToolCall.name).toBe("exec");
|
||||
expect(result).toBe(finalMessage);
|
||||
});
|
||||
|
||||
it("does not collapse whitespace-only tool names to empty strings", async () => {
|
||||
const partialToolCall = { type: "toolCall", name: " " };
|
||||
const finalToolCall = { type: "toolCall", name: "\t " };
|
||||
const event = {
|
||||
type: "toolcall_delta",
|
||||
partial: { role: "assistant", content: [partialToolCall] },
|
||||
};
|
||||
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>
|
||||
>;
|
||||
|
||||
for await (const _item of stream) {
|
||||
// drain
|
||||
}
|
||||
await stream.result();
|
||||
|
||||
expect(partialToolCall.name).toBe(" ");
|
||||
expect(finalToolCall.name).toBe("\t ");
|
||||
expect(baseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOllamaCompatProvider", () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||
import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
||||
import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js";
|
||||
import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js";
|
||||
import { normalizeToolName } from "../../tool-policy.js";
|
||||
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
|
||||
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
|
||||
import { isRunnerAbortError } from "../abort.js";
|
||||
@@ -226,7 +227,41 @@ export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: num
|
||||
});
|
||||
}
|
||||
|
||||
function trimWhitespaceFromToolCallNamesInMessage(message: unknown): void {
|
||||
function normalizeToolCallNameForDispatch(rawName: string, allowedToolNames?: Set<string>): string {
|
||||
const trimmed = rawName.trim();
|
||||
if (!trimmed) {
|
||||
// Keep whitespace-only placeholders unchanged so they do not collapse to
|
||||
// empty names (which can later surface as toolName="" loops).
|
||||
return rawName;
|
||||
}
|
||||
if (!allowedToolNames || allowedToolNames.size === 0) {
|
||||
return trimmed;
|
||||
}
|
||||
if (allowedToolNames.has(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
const normalized = normalizeToolName(trimmed);
|
||||
if (allowedToolNames.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
const folded = trimmed.toLowerCase();
|
||||
let caseInsensitiveMatch: string | null = null;
|
||||
for (const name of allowedToolNames) {
|
||||
if (name.toLowerCase() !== folded) {
|
||||
continue;
|
||||
}
|
||||
if (caseInsensitiveMatch && caseInsensitiveMatch !== name) {
|
||||
return trimmed;
|
||||
}
|
||||
caseInsensitiveMatch = name;
|
||||
}
|
||||
return caseInsensitiveMatch ?? trimmed;
|
||||
}
|
||||
|
||||
function trimWhitespaceFromToolCallNamesInMessage(
|
||||
message: unknown,
|
||||
allowedToolNames?: Set<string>,
|
||||
): void {
|
||||
if (!message || typeof message !== "object") {
|
||||
return;
|
||||
}
|
||||
@@ -242,20 +277,21 @@ function trimWhitespaceFromToolCallNamesInMessage(message: unknown): void {
|
||||
if (typedBlock.type !== "toolCall" || typeof typedBlock.name !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = typedBlock.name.trim();
|
||||
if (trimmed !== typedBlock.name) {
|
||||
typedBlock.name = trimmed;
|
||||
const normalized = normalizeToolCallNameForDispatch(typedBlock.name, allowedToolNames);
|
||||
if (normalized !== typedBlock.name) {
|
||||
typedBlock.name = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapStreamTrimToolCallNames(
|
||||
stream: ReturnType<typeof streamSimple>,
|
||||
allowedToolNames?: Set<string>,
|
||||
): ReturnType<typeof streamSimple> {
|
||||
const originalResult = stream.result.bind(stream);
|
||||
stream.result = async () => {
|
||||
const message = await originalResult();
|
||||
trimWhitespaceFromToolCallNamesInMessage(message);
|
||||
trimWhitespaceFromToolCallNamesInMessage(message, allowedToolNames);
|
||||
return message;
|
||||
};
|
||||
|
||||
@@ -271,8 +307,8 @@ function wrapStreamTrimToolCallNames(
|
||||
partial?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
trimWhitespaceFromToolCallNamesInMessage(event.partial);
|
||||
trimWhitespaceFromToolCallNamesInMessage(event.message);
|
||||
trimWhitespaceFromToolCallNamesInMessage(event.partial, allowedToolNames);
|
||||
trimWhitespaceFromToolCallNamesInMessage(event.message, allowedToolNames);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
@@ -288,13 +324,18 @@ function wrapStreamTrimToolCallNames(
|
||||
return stream;
|
||||
}
|
||||
|
||||
export function wrapStreamFnTrimToolCallNames(baseFn: StreamFn): StreamFn {
|
||||
export function wrapStreamFnTrimToolCallNames(
|
||||
baseFn: StreamFn,
|
||||
allowedToolNames?: Set<string>,
|
||||
): 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 Promise.resolve(maybeStream).then((stream) =>
|
||||
wrapStreamTrimToolCallNames(stream, allowedToolNames),
|
||||
);
|
||||
}
|
||||
return wrapStreamTrimToolCallNames(maybeStream);
|
||||
return wrapStreamTrimToolCallNames(maybeStream, allowedToolNames);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -974,7 +1015,10 @@ 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);
|
||||
activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames(
|
||||
activeSession.agent.streamFn,
|
||||
allowedToolNames,
|
||||
);
|
||||
|
||||
if (anthropicPayloadLogger) {
|
||||
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
|
||||
|
||||
Reference in New Issue
Block a user