Skip to content

Commit e7da628

Browse files
authored
Merge pull request #3 from DaveZheng/fix-tool-execution
fix: enable tool execution with stop sequences, name mapping, and XML suppression
2 parents a14b6c1 + 78169f4 commit e7da628

File tree

6 files changed

+302
-44
lines changed

6 files changed

+302
-44
lines changed

src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface OpenAIChatRequest {
1010
temperature: number;
1111
top_p: number;
1212
stream?: boolean;
13+
stop?: string[];
1314
}
1415

1516
export interface OpenAIChatResponse {

src/parser.test.ts

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from "node:assert";
33
import { parseToolCalls, type ParsedToolCall } from "./parser.js";
44

55
describe("parseToolCalls", () => {
6-
it("parses a single tool call", () => {
6+
it("parses a single tool call and maps name to Claude Code format", () => {
77
const output = `Let me read that file.
88
99
<tool_call>
@@ -15,11 +15,11 @@ describe("parseToolCalls", () => {
1515
const result = parseToolCalls(output);
1616
assert.strictEqual(result.text, "Let me read that file.");
1717
assert.strictEqual(result.toolCalls.length, 1);
18-
assert.strictEqual(result.toolCalls[0].name, "read_file");
18+
assert.strictEqual(result.toolCalls[0].name, "Read");
1919
assert.deepStrictEqual(result.toolCalls[0].input, { file_path: "/src/main.ts" });
2020
});
2121

22-
it("parses multiple parameters", () => {
22+
it("parses multiple parameters and maps edit_file to Edit", () => {
2323
const output = `<tool_call>
2424
<function=edit_file>
2525
<parameter=file_path>/src/main.ts</parameter>
@@ -29,7 +29,7 @@ describe("parseToolCalls", () => {
2929
</tool_call>`;
3030

3131
const result = parseToolCalls(output);
32-
assert.strictEqual(result.toolCalls[0].name, "edit_file");
32+
assert.strictEqual(result.toolCalls[0].name, "Edit");
3333
assert.strictEqual(result.toolCalls[0].input.file_path, "/src/main.ts");
3434
assert.strictEqual(result.toolCalls[0].input.old_string, "const x = 1;");
3535
assert.strictEqual(result.toolCalls[0].input.new_string, "const x = 2;");
@@ -46,6 +46,7 @@ line three</parameter>
4646
</tool_call>`;
4747

4848
const result = parseToolCalls(output);
49+
assert.strictEqual(result.toolCalls[0].name, "Write");
4950
assert.strictEqual(result.toolCalls[0].input.content, "line one\nline two\nline three");
5051
});
5152

@@ -58,7 +59,7 @@ line three</parameter>
5859

5960
const result = parseToolCalls(output);
6061
assert.strictEqual(result.toolCalls.length, 1);
61-
assert.strictEqual(result.toolCalls[0].name, "read_file");
62+
assert.strictEqual(result.toolCalls[0].name, "Read");
6263
});
6364

6465
it("parses multiple tool calls in one response", () => {
@@ -93,7 +94,7 @@ line three</parameter>
9394

9495
const result = parseToolCalls(output);
9596
assert.strictEqual(result.toolCalls.length, 1);
96-
assert.strictEqual(result.toolCalls[0].name, "read_file");
97+
assert.strictEqual(result.toolCalls[0].name, "Read");
9798
assert.strictEqual(result.toolCalls[0].input.file_path, "/src/main.ts");
9899
});
99100

@@ -111,6 +112,81 @@ line three</parameter>
111112

112113
const result = parseToolCalls(output);
113114
assert.strictEqual(result.toolCalls.length, 1);
114-
assert.strictEqual(result.toolCalls[0].name, "read_file");
115+
assert.strictEqual(result.toolCalls[0].name, "Read");
116+
});
117+
118+
it("maps bash tool name to Bash", () => {
119+
const output = `<tool_call>
120+
<function=bash>
121+
<parameter=command>ls -la</parameter>
122+
</function>
123+
</tool_call>`;
124+
125+
const result = parseToolCalls(output);
126+
assert.strictEqual(result.toolCalls[0].name, "Bash");
127+
assert.strictEqual(result.toolCalls[0].input.command, "ls -la");
128+
});
129+
130+
it("maps grep tool name to Grep", () => {
131+
const output = `<tool_call>
132+
<function=grep>
133+
<parameter=pattern>TODO</parameter>
134+
<parameter=path>/src</parameter>
135+
</function>
136+
</tool_call>`;
137+
138+
const result = parseToolCalls(output);
139+
assert.strictEqual(result.toolCalls[0].name, "Grep");
140+
assert.strictEqual(result.toolCalls[0].input.pattern, "TODO");
141+
});
142+
143+
it("maps glob tool name to Glob", () => {
144+
const output = `<tool_call>
145+
<function=glob>
146+
<parameter=pattern>**/*.ts</parameter>
147+
</function>
148+
</tool_call>`;
149+
150+
const result = parseToolCalls(output);
151+
assert.strictEqual(result.toolCalls[0].name, "Glob");
152+
});
153+
154+
it("handles stop sequence truncation (missing </tool_call>)", () => {
155+
// When stop=["</tool_call>"], the model output ends before the closing tag
156+
const output = `Let me check.
157+
158+
<tool_call>
159+
<function=read_file>
160+
<parameter=file_path>/src/main.ts</parameter>
161+
</function>
162+
`;
163+
164+
const result = parseToolCalls(output);
165+
assert.strictEqual(result.toolCalls.length, 1);
166+
assert.strictEqual(result.toolCalls[0].name, "Read");
167+
assert.strictEqual(result.toolCalls[0].input.file_path, "/src/main.ts");
168+
});
169+
170+
it("handles stop sequence truncation (missing </function> and </tool_call>)", () => {
171+
// Extreme truncation — stop sequence fired before </function>
172+
const output = `<tool_call>
173+
<function=bash>
174+
<parameter=command>echo hello</parameter>`;
175+
176+
const result = parseToolCalls(output);
177+
assert.strictEqual(result.toolCalls.length, 1);
178+
assert.strictEqual(result.toolCalls[0].name, "Bash");
179+
assert.strictEqual(result.toolCalls[0].input.command, "echo hello");
180+
});
181+
182+
it("passes through unknown tool names unchanged", () => {
183+
const output = `<tool_call>
184+
<function=custom_tool>
185+
<parameter=arg>value</parameter>
186+
</function>
187+
</tool_call>`;
188+
189+
const result = parseToolCalls(output);
190+
assert.strictEqual(result.toolCalls[0].name, "custom_tool");
115191
});
116192
});

src/parser.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,44 @@ export interface ParseResult {
88
toolCalls: ParsedToolCall[];
99
}
1010

11+
/**
12+
* Map local tool names to Claude Code tool names.
13+
* The local model calls tools by the names in our injected definitions,
14+
* but Claude Code expects its own tool names.
15+
*/
16+
const TOOL_NAME_MAP: Record<string, string> = {
17+
read_file: "Read",
18+
write_file: "Write",
19+
edit_file: "Edit",
20+
bash: "Bash",
21+
glob: "Glob",
22+
grep: "Grep",
23+
};
24+
25+
/**
26+
* Map local parameter names to Claude Code parameter names where they differ.
27+
*/
28+
const PARAM_NAME_MAP: Record<string, Record<string, string>> = {
29+
Read: { file_path: "file_path", offset: "offset", limit: "limit" },
30+
Write: { file_path: "file_path", content: "content" },
31+
Edit: { file_path: "file_path", old_string: "old_string", new_string: "new_string" },
32+
Bash: { command: "command" },
33+
Glob: { pattern: "pattern", path: "path" },
34+
Grep: { pattern: "pattern", path: "path" },
35+
};
36+
37+
function mapToolCall(name: string, input: Record<string, string>): ParsedToolCall {
38+
const mappedName = TOOL_NAME_MAP[name] ?? name;
39+
const paramMap = PARAM_NAME_MAP[mappedName];
40+
if (!paramMap) return { name: mappedName, input };
41+
42+
const mappedInput: Record<string, string> = {};
43+
for (const [key, value] of Object.entries(input)) {
44+
mappedInput[paramMap[key] ?? key] = value;
45+
}
46+
return { name: mappedName, input: mappedInput };
47+
}
48+
1149
export function parseToolCalls(output: string): ParseResult {
1250
const toolCalls: ParsedToolCall[] = [];
1351

@@ -25,9 +63,16 @@ export function parseToolCalls(output: string): ParseResult {
2563
normalized = normalized.replace(new RegExp(sentinel.replace(/\0/g, "\\0"), "g"), "");
2664

2765
// Normalize: handle missing </tool_call> closing tag
28-
// Insert </tool_call> after </function> if not already followed by one
66+
// Also handles stop sequence cutting off </tool_call> — the model stops
67+
// generating at </tool_call> so it may not appear in the output
2968
normalized = normalized.replace(/<\/function>\s*(?!<\/tool_call>)/g, "</function>\n</tool_call>");
3069

70+
// Handle truncated tool calls where </function> is also missing
71+
// (stop sequence fired mid-generation). Close any unclosed function blocks.
72+
if (normalized.includes("<function=") && !normalized.includes("</function>")) {
73+
normalized += "\n</function>\n</tool_call>";
74+
}
75+
3176
// Extract all tool call blocks
3277
const blockRegex = /<tool_call>\s*<function=([^>]+)>([\s\S]*?)<\/function>\s*<\/tool_call>/g;
3378
let match: RegExpExecArray | null;
@@ -52,7 +97,7 @@ export function parseToolCalls(output: string): ParseResult {
5297
input[paramMatch[1].trim()] = paramMatch[2].trim();
5398
}
5499

55-
toolCalls.push({ name, input });
100+
toolCalls.push(mapToolCall(name, input));
56101
}
57102

58103
const text = textParts.join("").trim();

src/translate-request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,6 @@ export function translateRequest(req: AnthropicRequest, mlxModel: string): OpenA
118118
temperature: 0.7,
119119
top_p: 0.95,
120120
stream: req.stream,
121+
stop: ["</tool_call>"],
121122
};
122123
}

0 commit comments

Comments
 (0)