From 059a085ce9af91ec1ad93a60b70c7c162f9ac95a Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 6 Mar 2026 05:05:39 +0800 Subject: [PATCH] fix(tools): ignore quoted TOOL_NAME examples in assistant text --- .../systemMessageTools/detectToolCallStart.ts | 16 ++- .../interceptSystemToolCalls.ts | 9 +- .../interceptSystemToolCalls.vitest.ts | 100 +++++++++++++----- 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/core/tools/systemMessageTools/detectToolCallStart.ts b/core/tools/systemMessageTools/detectToolCallStart.ts index e143411dda6..cb772f1d13e 100644 --- a/core/tools/systemMessageTools/detectToolCallStart.ts +++ b/core/tools/systemMessageTools/detectToolCallStart.ts @@ -3,8 +3,22 @@ import { SystemMessageToolsFramework } from "./types"; export function detectToolCallStart( buffer: string, toolCallFramework: SystemMessageToolsFramework, + options?: { + /** + * When false, only the canonical ```tool start is accepted. + */ + allowNonCodeblockStarts?: boolean; + }, ) { - const starts = toolCallFramework.acceptedToolCallStarts; + const allowNonCodeblockStarts = options?.allowNonCodeblockStarts ?? true; + const canonicalStart = + toolCallFramework.acceptedToolCallStarts.find( + ([start, replacement]) => start === replacement, + ) ?? toolCallFramework.acceptedToolCallStarts[0]; + + const starts = allowNonCodeblockStarts + ? toolCallFramework.acceptedToolCallStarts + : [canonicalStart]; let modifiedBuffer = buffer; let isInToolCall = false; let isInPartialStart = false; diff --git a/core/tools/systemMessageTools/interceptSystemToolCalls.ts b/core/tools/systemMessageTools/interceptSystemToolCalls.ts index a89fbdcfbb5..7cf5c97734a 100644 --- a/core/tools/systemMessageTools/interceptSystemToolCalls.ts +++ b/core/tools/systemMessageTools/interceptSystemToolCalls.ts @@ -28,6 +28,7 @@ export async function* interceptSystemToolCalls( ): AsyncGenerator { let buffer = ""; let parseState: ToolCallParseState | undefined; + let sawNonWhitespaceAssistantText = false; while (true) { const result = await messageGenerator.next(); @@ -71,7 +72,9 @@ export async function* interceptSystemToolCalls( buffer += chunk; if (!parseState) { const { isInPartialStart, isInToolCall, modifiedBuffer } = - detectToolCallStart(buffer, systemToolFramework); + detectToolCallStart(buffer, systemToolFramework, { + allowNonCodeblockStarts: !sawNonWhitespaceAssistantText, + }); if (isInPartialStart) { continue; @@ -102,6 +105,10 @@ export async function* interceptSystemToolCalls( continue; } + if (buffer.trim().length > 0) { + sawNonWhitespaceAssistantText = true; + } + // Yield normal assistant message yield [ { diff --git a/core/tools/systemMessageTools/toolCodeblocks/interceptSystemToolCalls.vitest.ts b/core/tools/systemMessageTools/toolCodeblocks/interceptSystemToolCalls.vitest.ts index a636cc6a9da..7e848b5b030 100644 --- a/core/tools/systemMessageTools/toolCodeblocks/interceptSystemToolCalls.vitest.ts +++ b/core/tools/systemMessageTools/toolCodeblocks/interceptSystemToolCalls.vitest.ts @@ -179,9 +179,8 @@ describe("interceptSystemToolCalls", () => { ).toBe("}"); }); - it("processes tool_name without codeblock format", async () => { + it("processes tool_name without codeblock format at response start", async () => { const messages: ChatMessage[][] = [ - [{ role: "assistant", content: "I'll help you with that.\n" }], [{ role: "assistant", content: "TOOL_NAME: test_tool\n" }], [{ role: "assistant", content: "BEGIN_ARG: arg1\n" }], [{ role: "assistant", content: "value1\n" }], @@ -194,30 +193,8 @@ describe("interceptSystemToolCalls", () => { framework, ); - // First chunk should be normal text - let result = await generator.next(); - expect(result.value).toEqual([ - { - role: "assistant", - content: [{ type: "text", text: "I'll help you with that." }], - }, - ]); - - result = await generator.next(); - expect(result.value).toEqual([ - { - role: "assistant", - content: [ - { - type: "text", - text: "\n", - }, - ], - }, - ]); - // The system should detect the tool_name format and convert it - result = await generator.next(); + let result = await generator.next(); expect( (result.value as AssistantChatMessage[])[0].toolCalls?.[0].function?.name, ).toBe("test_tool"); @@ -242,6 +219,79 @@ describe("interceptSystemToolCalls", () => { ).toBe("}"); }); + it("still detects canonical ```tool blocks after assistant text", async () => { + const messages: ChatMessage[][] = [ + [{ role: "assistant", content: "Let me check that for you.\n" }], + [{ role: "assistant", content: "```tool\n" }], + [{ role: "assistant", content: "TOOL_NAME: test_tool\n" }], + ]; + + const generator = interceptSystemToolCalls( + createAsyncGenerator(messages), + abortController, + framework, + ); + + await generator.next(); + await generator.next(); + const result = await generator.next(); + + expect( + (result.value as AssistantChatMessage[])[0].toolCalls?.[0].function?.name, + ).toBe("test_tool"); + }); + + it("does not treat quoted TOOL_NAME lines as real tool calls", async () => { + const messages: ChatMessage[][] = [ + [ + { + role: "assistant", + content: + "Use this format when you call a tool:\nTOOL_NAME: read_file\nBEGIN_ARG: path\nREADME.md\nEND_ARG", + }, + ], + ]; + + const generator = interceptSystemToolCalls( + createAsyncGenerator(messages), + abortController, + framework, + ); + + const chunks: ChatMessage[][] = []; + while (true) { + const result = await generator.next(); + if (result.done) { + break; + } + if (result.value) { + chunks.push(result.value as ChatMessage[]); + } + } + + expect( + chunks.flatMap((group) => group).every((message) => !message.toolCalls), + ).toBe(true); + + const text = chunks + .flatMap((group) => group) + .flatMap((message) => { + if (typeof message.content === "string") { + return [message.content]; + } + if (Array.isArray(message.content)) { + return message.content + .filter((part) => part.type === "text") + .map((part) => part.text); + } + return []; + }) + .join(""); + + expect(text).toContain("TOOL_NAME: read_file"); + expect(text).toContain("BEGIN_ARG: path"); + }); + it("ignores content after a tool call", async () => { const messages: ChatMessage[][] = [ [{ role: "assistant", content: "```tool\n" }],