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:
Peter Steinberger
2026-03-01 23:51:43 +00:00
parent 50e2674dfc
commit ee03ade0d6
7 changed files with 224 additions and 13 deletions

View File

@@ -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", () => {

View File

@@ -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(