Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion core/tools/systemMessageTools/detectToolCallStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion core/tools/systemMessageTools/interceptSystemToolCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export async function* interceptSystemToolCalls(
): AsyncGenerator<ChatMessage[], PromptLog | undefined> {
let buffer = "";
let parseState: ToolCallParseState | undefined;
let sawNonWhitespaceAssistantText = false;

while (true) {
const result = await messageGenerator.next();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -102,6 +105,10 @@ export async function* interceptSystemToolCalls(
continue;
}

if (buffer.trim().length > 0) {
sawNonWhitespaceAssistantText = true;
}

// Yield normal assistant message
yield [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand All @@ -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");
Expand All @@ -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" }],
Expand Down
Loading