llm-tool-parser 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # llm-tool-parser
2
+
3
+ A TypeScript utility for extracting and normalizing tool calls from large language model outputs.
4
+
5
+ It supports both native tool calling formats (OpenAI `tool_calls`, Claude `tool_use`, etc.) and “text-based tool calls” commonly produced by open-source or fine-tuned models.
6
+
7
+ The library converts inconsistent, streaming, or partially corrupted LLM outputs into a clean and unified `ToolCall` structure, making it easier to build reliable agent runtimes.
8
+
9
+ ### Key features
10
+
11
+ * Supports multiple LLM ecosystems (OpenAI, Claude, DeepSeek, Qwen, etc.)
12
+ * Extracts tool calls from raw text, JSON, XML, and hybrid formats
13
+ * Handles streaming, trailing text, and malformed outputs
14
+ * Normalizes all inputs into a unified `ToolCall` schema
15
+ * Works with both structured tool calling and prompt-based agents
16
+
17
+ ## Simple usage
18
+
19
+ Examples below are based on `src/parser.test.ts`.
20
+
21
+ ```ts
22
+ import { parseLlmToolCalls } from 'llm-tool-parser'
23
+
24
+ const text =
25
+ '{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/tmp/a.ts"}}]}'
26
+
27
+ const { content, toolCalls } = parseLlmToolCalls(text)
28
+
29
+ console.log(content)
30
+ console.log(toolCalls[0]?.name)
31
+ console.log(toolCalls[0]?.arguments)
32
+ ```
33
+
34
+ You can also parse model outputs that contain extra text:
35
+
36
+ ```ts
37
+ const text =
38
+ '让我来查看一下\n{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/a.ts"}}]}'
39
+
40
+ const { content, toolCalls } = parseLlmToolCalls(text)
41
+
42
+ console.log(content) // 让我来查看一下
43
+ console.log(toolCalls)
44
+ /**
45
+ [
46
+ {
47
+ id: 'call_1780567643675_6a8b',
48
+ name: 'fileRead',
49
+ arguments: { filePath: '/a.ts' }
50
+ }
51
+ ]
52
+ */
53
+ ```
54
+
55
+ And code-fenced JSON is supported too:
56
+
57
+ ```ts
58
+ const text =
59
+ '我来读取文件\n```json\n{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/a.ts"}}]}\n```'
60
+
61
+ const { content, toolCalls } = parseLlmToolCalls(text)
62
+ ```
63
+
64
+ ## Supported parsable formats
65
+
66
+ Based on all test cases in `src/parser.test.ts`, the parser currently supports these input patterns:
67
+
68
+ ### 1. OpenAI-style `tool_calls`
69
+
70
+ ```json
71
+ {"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/tmp/a.ts"}}]}
72
+ ```
73
+
74
+ - single or multiple calls in one `tool_calls` array
75
+ - multiple consecutive JSON blocks
76
+ - JSON at the start with trailing text
77
+ - plain text before the JSON block
78
+ - JSON inside fenced code blocks
79
+
80
+ ### 2. Single `tool_name` object
81
+
82
+ ```json
83
+ {"tool_name":"bash","arguments":{"command":"ls"}}
84
+ ```
85
+
86
+ ### 3. `[tool_calls]` marker + JSON objects
87
+
88
+ ```txt
89
+ [tool_calls]{"tool":"grep","args":{"pattern":"hello"}}{"tool":"glob","args":{"pattern":"*.ts"}}
90
+ ```
91
+
92
+ ### 4. `Action` / `Arguments` text format
93
+
94
+ ```txt
95
+ Action: fileRead
96
+ Arguments: {"filePath":"/tmp/test.ts"}
97
+ ```
98
+
99
+ Also supports non-JSON arguments:
100
+
101
+ ```txt
102
+ Action: grep
103
+ Arguments: foo
104
+ ```
105
+
106
+ ### 5. XML-style tool tags
107
+
108
+ ```xml
109
+ <fileRead>{"filePath":"/tmp/a.ts"}</fileRead>
110
+ ```
111
+
112
+ - ignores non-tool tags like `<thinking>`
113
+ - supports XML inside fenced code blocks
114
+
115
+ ### 6. CLI log style `[Called tools: ...]`
116
+
117
+ ```txt
118
+ ● [Called tools: fileRead({"filePath":"/tmp/a.ts"})]
119
+ ```
120
+
121
+ Also supports multiple calls:
122
+
123
+ ```txt
124
+ ● [Called tools: grep({"pattern":"foo"}), glob({"pattern":"*.ts"})]
125
+ ```
126
+
127
+ ### 7. OpenAI legacy `function_call`
128
+
129
+ ```json
130
+ {
131
+ "function_call": {
132
+ "name": "fileRead",
133
+ "arguments": "{\"filePath\":\"/tmp/a.ts\"}"
134
+ }
135
+ }
136
+ ```
137
+
138
+ ### 8. Claude `tool_use`
139
+
140
+ Content array form:
141
+
142
+ ```json
143
+ {
144
+ "content": [
145
+ {
146
+ "type": "tool_use",
147
+ "name": "fileRead",
148
+ "input": {"filePath": "/tmp/a.ts"}
149
+ }
150
+ ]
151
+ }
152
+ ```
153
+
154
+ Standalone form:
155
+
156
+ ```json
157
+ {
158
+ "type": "tool_use",
159
+ "name": "fileRead",
160
+ "input": {"filePath": "/tmp/a.ts"}
161
+ }
162
+ ```
163
+
164
+ ### 9. OpenAI Responses API `function_call`
165
+
166
+ ```json
167
+ {
168
+ "type": "function_call",
169
+ "name": "fileRead",
170
+ "arguments": "{\"filePath\":\"/tmp/a.ts\"}"
171
+ }
172
+ ```
173
+
174
+ ### 10. Simple `tool` + `args` object
175
+
176
+ ```json
177
+ {"tool":"fileRead","args":{"filePath":"/tmp/a.ts"}}
178
+ ```
179
+
180
+ ### 11. Root array of tool calls
181
+
182
+ ```json
183
+ [
184
+ {"tool":"grep","args":{"pattern":"foo"}},
185
+ {"tool":"glob","args":{"pattern":"*.ts"}}
186
+ ]
187
+ ```
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './parser';
package/lib/index.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./parser"), exports);
@@ -0,0 +1,26 @@
1
+ export interface LlmToolCall {
2
+ id?: string;
3
+ name: string;
4
+ arguments: Record<string, unknown>;
5
+ }
6
+ export interface LlmToolCallParseResult {
7
+ content: string;
8
+ toolCalls: LlmToolCall[];
9
+ }
10
+ /**
11
+ * Parse tool calls from LLM response text.
12
+ * Supports:
13
+ * 1. [tool_calls] marker format: [tool_calls]{"tool":"name","args":{...}}...
14
+ * 2. Standard JSON tool_calls embedded in text
15
+ * 3. Action/Arguments text format
16
+ * 4. XML tag format: <tool_name>{...}</tool_name>
17
+ *
18
+ * Returns the cleaned content (with tool call syntax removed) and parsed calls.
19
+ */
20
+ export declare function parseLlmToolCalls(text: string): LlmToolCallParseResult;
21
+ /**
22
+ * Quick heuristic to detect if text is likely a tool call response.
23
+ * Used to suppress intermediate streaming of raw JSON that will be parsed
24
+ * as tool calls.
25
+ */
26
+ export declare function looksLikeToolCall(text: string): boolean;
package/lib/parser.js ADDED
@@ -0,0 +1,565 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseLlmToolCalls = parseLlmToolCalls;
4
+ exports.looksLikeToolCall = looksLikeToolCall;
5
+ const IGNORED_XML_TAGS = new Set([
6
+ "think", "thinking", "reflection", "output", "result", "answer", "response",
7
+ ]);
8
+ /**
9
+ * Parse tool calls from LLM response text.
10
+ * Supports:
11
+ * 1. [tool_calls] marker format: [tool_calls]{"tool":"name","args":{...}}...
12
+ * 2. Standard JSON tool_calls embedded in text
13
+ * 3. Action/Arguments text format
14
+ * 4. XML tag format: <tool_name>{...}</tool_name>
15
+ *
16
+ * Returns the cleaned content (with tool call syntax removed) and parsed calls.
17
+ */
18
+ function parseLlmToolCalls(text) {
19
+ if (!text)
20
+ return { content: "", toolCalls: [] };
21
+ const trimmedText = text.trim();
22
+ if (trimmedText.startsWith("[")) {
23
+ const parsed = tryParseLooseJson(trimmedText);
24
+ if (Array.isArray(parsed) && parsed.length > 0) {
25
+ const toolCalls = extractToolCallsFromArray(parsed);
26
+ if (toolCalls.length > 0) {
27
+ return { content: "", toolCalls };
28
+ }
29
+ }
30
+ }
31
+ if (trimmedText.startsWith("{")) {
32
+ try {
33
+ const parsed = tryParseLooseJson(trimmedText);
34
+ if (!parsed || typeof parsed !== "object") {
35
+ throw new Error("Invalid JSON object");
36
+ }
37
+ const result = tryParseJsonToolCalls(parsed);
38
+ if (result)
39
+ return result;
40
+ if (typeof parsed.tool_calls === "string") {
41
+ const nested = parseLlmToolCalls(parsed.tool_calls);
42
+ if (nested.toolCalls.length > 0)
43
+ return nested;
44
+ }
45
+ }
46
+ catch {
47
+ const looseRoot = tryExtractToolCallsFromToolCallsArrayText(trimmedText);
48
+ if (looseRoot) {
49
+ const remainderResult = looseRoot.remainder
50
+ ? parseLlmToolCalls(looseRoot.remainder)
51
+ : { content: "", toolCalls: [] };
52
+ return {
53
+ content: remainderResult.content,
54
+ toolCalls: [...looseRoot.toolCalls, ...remainderResult.toolCalls],
55
+ };
56
+ }
57
+ const allToolCalls = [];
58
+ let pos = 0;
59
+ let lastJsonEnd = 0;
60
+ while (pos < trimmedText.length) {
61
+ while (pos < trimmedText.length && trimmedText[pos] !== "{")
62
+ pos++;
63
+ if (pos >= trimmedText.length)
64
+ break;
65
+ const jsonStr = extractBalancedJson(trimmedText, pos);
66
+ if (!jsonStr)
67
+ break;
68
+ try {
69
+ const parsed = tryParseLooseJson(jsonStr);
70
+ if (!parsed || typeof parsed !== "object") {
71
+ throw new Error("Invalid JSON object");
72
+ }
73
+ const result = tryParseJsonToolCalls(parsed);
74
+ if (result && result.toolCalls.length > 0) {
75
+ allToolCalls.push(...result.toolCalls);
76
+ pos += jsonStr.length;
77
+ lastJsonEnd = pos;
78
+ continue;
79
+ }
80
+ }
81
+ catch {
82
+ // Not valid JSON.
83
+ }
84
+ break;
85
+ }
86
+ if (allToolCalls.length > 0) {
87
+ const content = trimmedText.substring(lastJsonEnd).trim();
88
+ return { content, toolCalls: allToolCalls };
89
+ }
90
+ }
91
+ }
92
+ const toolCallsJsonIdx = trimmedText.lastIndexOf('{"tool_calls"');
93
+ if (toolCallsJsonIdx >= 0) {
94
+ const jsonStr = extractBalancedJson(trimmedText, toolCallsJsonIdx);
95
+ if (jsonStr) {
96
+ const parsed = tryParseLooseJson(jsonStr);
97
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.tool_calls)) {
98
+ const toolCalls = parsed.tool_calls.map((tc) => ({
99
+ id: tc.id || generateId(),
100
+ name: tc.tool_name || tc.tool || tc.name || tc.function?.name,
101
+ arguments: parseToolArgs(tc.arguments || tc.args || tc.input || tc.function?.arguments),
102
+ }));
103
+ if (toolCalls.length > 0) {
104
+ const content = trimmedText.substring(0, toolCallsJsonIdx).trim();
105
+ return { content, toolCalls };
106
+ }
107
+ }
108
+ }
109
+ }
110
+ const toolCallsMarkerIdx = text.indexOf("[tool_calls]");
111
+ if (toolCallsMarkerIdx !== -1) {
112
+ const content = text.substring(0, toolCallsMarkerIdx).trim();
113
+ const toolCallsStr = text.substring(toolCallsMarkerIdx + "[tool_calls]".length).trim();
114
+ const toolCalls = parseConsecutiveJsonObjects(toolCallsStr);
115
+ if (toolCalls.length > 0) {
116
+ return { content, toolCalls };
117
+ }
118
+ }
119
+ const jsonToolCallsMatch = text.match(/```json\s*\n?\s*\{[\s\S]*?"tool_calls"\s*:\s*\[[\s\S]*?\]\s*\}?\s*\n?\s*```/);
120
+ if (jsonToolCallsMatch) {
121
+ const jsonStr = jsonToolCallsMatch[0]
122
+ .replace(/```json\s*\n?/, "")
123
+ .replace(/\n?\s*```$/, "")
124
+ .trim();
125
+ const parsed = tryParseLooseJson(jsonStr);
126
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.tool_calls)) {
127
+ const toolCalls = parsed.tool_calls.map((tc) => ({
128
+ id: tc.id,
129
+ name: tc.function?.name || tc.name || tc.tool,
130
+ arguments: parseToolArgs(tc.function?.arguments || tc.arguments || tc.args),
131
+ }));
132
+ const content = text.replace(jsonToolCallsMatch[0], "").trim();
133
+ return { content, toolCalls };
134
+ }
135
+ }
136
+ const actionMatch = text.match(/Action:\s*(\w+)/);
137
+ if (actionMatch) {
138
+ let args = {};
139
+ const argsMatch = text.match(/Arguments:\s*([\s\S]*)/);
140
+ if (argsMatch) {
141
+ const argsBody = argsMatch[1].trim();
142
+ if (argsBody.startsWith("{")) {
143
+ const jsonStr = extractBalancedJson(argsBody, 0);
144
+ if (jsonStr) {
145
+ const parsedArgs = tryParseLooseJson(jsonStr);
146
+ if (parsedArgs && typeof parsedArgs === "object") {
147
+ args = parsedArgs;
148
+ }
149
+ else {
150
+ args = { value: argsBody };
151
+ }
152
+ }
153
+ else {
154
+ args = { value: argsBody };
155
+ }
156
+ }
157
+ else if (argsBody) {
158
+ args = { value: argsBody };
159
+ }
160
+ }
161
+ const content = text
162
+ .replace(/Action:\s*\w+/, "")
163
+ .replace(/Arguments:\s*[\s\S]*/, "")
164
+ .trim();
165
+ return {
166
+ content,
167
+ toolCalls: [
168
+ {
169
+ id: generateId(),
170
+ name: actionMatch[1],
171
+ arguments: args,
172
+ },
173
+ ],
174
+ };
175
+ }
176
+ const xmlCodeBlockMatch = text.match(/```(?:xml|)\s*\n([\s\S]*?)\n\s*```/);
177
+ if (xmlCodeBlockMatch) {
178
+ const innerXml = xmlCodeBlockMatch[1];
179
+ const xmlInBlockPattern = /<(\w+)>\s*([\s\S]*?)\s*<\/\1>/g;
180
+ const xmlInBlockResults = [];
181
+ let xmlInBlockMatch;
182
+ while ((xmlInBlockMatch = xmlInBlockPattern.exec(innerXml)) !== null) {
183
+ const tagName = xmlInBlockMatch[1];
184
+ if (IGNORED_XML_TAGS.has(tagName.toLowerCase()))
185
+ continue;
186
+ const innerContent = xmlInBlockMatch[2].trim();
187
+ const args = tryParseLooseJson(innerContent);
188
+ if (args && typeof args === "object") {
189
+ xmlInBlockResults.push({
190
+ id: generateId(),
191
+ name: tagName,
192
+ arguments: args,
193
+ });
194
+ }
195
+ }
196
+ if (xmlInBlockResults.length > 0) {
197
+ const content = text.replace(xmlCodeBlockMatch[0], "").trim();
198
+ return { content, toolCalls: xmlInBlockResults };
199
+ }
200
+ }
201
+ const xmlPattern = /<(\w+)>\s*(\{[\s\S]*?\})\s*<\/\1>/g;
202
+ const xmlResults = [];
203
+ let xmlMatch;
204
+ let cleanText = text;
205
+ while ((xmlMatch = xmlPattern.exec(text)) !== null) {
206
+ const tagName = xmlMatch[1];
207
+ if (IGNORED_XML_TAGS.has(tagName.toLowerCase())) {
208
+ continue;
209
+ }
210
+ const innerContent = xmlMatch[2].trim();
211
+ const args = tryParseLooseJson(innerContent);
212
+ if (args && typeof args === "object") {
213
+ xmlResults.push({
214
+ id: generateId(),
215
+ name: tagName,
216
+ arguments: args,
217
+ });
218
+ cleanText = cleanText.replace(xmlMatch[0], "");
219
+ }
220
+ }
221
+ if (xmlResults.length > 0) {
222
+ return { content: cleanText.trim(), toolCalls: xmlResults };
223
+ }
224
+ const calledToolsIdx = text.search(/\[Called tools?:\s*/);
225
+ if (calledToolsIdx !== -1) {
226
+ const markerMatch = text.substring(calledToolsIdx).match(/^\[Called tools?:\s*/);
227
+ if (markerMatch) {
228
+ const innerStart = calledToolsIdx + markerMatch[0].length;
229
+ const calledToolCalls = parseCalledToolsFormat(text.substring(innerStart));
230
+ if (calledToolCalls.length > 0) {
231
+ const content = text.substring(0, calledToolsIdx).replace(/●\s*$/, "").trim();
232
+ return { content, toolCalls: calledToolCalls };
233
+ }
234
+ }
235
+ }
236
+ return { content: text, toolCalls: [] };
237
+ }
238
+ /**
239
+ * Quick heuristic to detect if text is likely a tool call response.
240
+ * Used to suppress intermediate streaming of raw JSON that will be parsed
241
+ * as tool calls.
242
+ */
243
+ function looksLikeToolCall(text) {
244
+ const t = text.trimStart();
245
+ if (t.startsWith("{") || t.startsWith("[tool_calls]") || t.startsWith("[{"))
246
+ return true;
247
+ if (/^Action:\s*\w+/.test(t))
248
+ return true;
249
+ if (t.includes('{"tool_calls"'))
250
+ return true;
251
+ if (t.includes('"function_call"'))
252
+ return true;
253
+ if (t.includes('"tool_use"'))
254
+ return true;
255
+ if (/\[Called tools?:/.test(t))
256
+ return true;
257
+ return false;
258
+ }
259
+ function generateId() {
260
+ return `call_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
261
+ }
262
+ function tryParseJsonToolCalls(parsed) {
263
+ if (Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
264
+ const toolCalls = parsed.tool_calls.map((tc) => ({
265
+ id: tc.id || generateId(),
266
+ name: tc.tool_name || tc.tool || tc.name || tc.function?.name,
267
+ arguments: parseToolArgs(tc.arguments || tc.args || tc.input || tc.function?.arguments),
268
+ }));
269
+ return { content: "", toolCalls };
270
+ }
271
+ if (parsed.function_call?.name) {
272
+ return {
273
+ content: "",
274
+ toolCalls: [{
275
+ id: generateId(),
276
+ name: parsed.function_call.name,
277
+ arguments: parseToolArgs(parsed.function_call.arguments),
278
+ }],
279
+ };
280
+ }
281
+ if (Array.isArray(parsed.content)) {
282
+ const toolUseBlocks = parsed.content.filter((b) => b.type === "tool_use" && b.name);
283
+ if (toolUseBlocks.length > 0) {
284
+ const toolCalls = toolUseBlocks.map((b) => ({
285
+ id: b.id || generateId(),
286
+ name: b.name,
287
+ arguments: parseToolArgs(b.input || b.arguments || b.args),
288
+ }));
289
+ return { content: "", toolCalls };
290
+ }
291
+ }
292
+ if (parsed.type === "tool_use" && parsed.name) {
293
+ return {
294
+ content: "",
295
+ toolCalls: [{
296
+ id: parsed.id || generateId(),
297
+ name: parsed.name,
298
+ arguments: parseToolArgs(parsed.input || parsed.arguments || parsed.args),
299
+ }],
300
+ };
301
+ }
302
+ if (parsed.type === "function_call" && parsed.name) {
303
+ return {
304
+ content: "",
305
+ toolCalls: [{
306
+ id: parsed.call_id || generateId(),
307
+ name: parsed.name,
308
+ arguments: parseToolArgs(parsed.arguments || parsed.args),
309
+ }],
310
+ };
311
+ }
312
+ const name = parsed.tool_name || parsed.tool || parsed.function?.name || parsed.name;
313
+ if (name && typeof name === "string") {
314
+ const args = parseToolArgs(parsed.arguments || parsed.args || parsed.input || parsed.function?.arguments);
315
+ return {
316
+ content: "",
317
+ toolCalls: [{ id: generateId(), name, arguments: args }],
318
+ };
319
+ }
320
+ return null;
321
+ }
322
+ function extractToolCallsFromArray(arr) {
323
+ const results = [];
324
+ for (const item of arr) {
325
+ if (typeof item !== "object" || item === null)
326
+ continue;
327
+ const name = item.tool_name || item.tool || item.name || item.function?.name;
328
+ if (!name || typeof name !== "string")
329
+ continue;
330
+ results.push({
331
+ id: item.id || item.call_id || generateId(),
332
+ name,
333
+ arguments: parseToolArgs(item.arguments || item.args || item.input || item.function?.arguments),
334
+ });
335
+ }
336
+ return results;
337
+ }
338
+ function parseConsecutiveJsonObjects(text) {
339
+ const results = [];
340
+ let pos = 0;
341
+ while (pos < text.length) {
342
+ while (pos < text.length && /\s/.test(text[pos]))
343
+ pos++;
344
+ if (pos >= text.length)
345
+ break;
346
+ if (text[pos] !== "{")
347
+ break;
348
+ const jsonStr = extractBalancedJson(text, pos);
349
+ if (!jsonStr)
350
+ break;
351
+ const obj = tryParseLooseJson(jsonStr);
352
+ if (!obj || typeof obj !== "object") {
353
+ break;
354
+ }
355
+ const toolName = obj.tool || obj.name || obj.function?.name;
356
+ const toolArgs = obj.args || obj.arguments || obj.function?.arguments || {};
357
+ if (toolName) {
358
+ results.push({
359
+ id: generateId(),
360
+ name: toolName,
361
+ arguments: parseToolArgs(toolArgs),
362
+ });
363
+ }
364
+ pos += jsonStr.length;
365
+ }
366
+ return results;
367
+ }
368
+ function parseCalledToolsFormat(inner) {
369
+ const results = [];
370
+ let pos = 0;
371
+ while (pos < inner.length) {
372
+ while (pos < inner.length && /[\s,\]]/.test(inner[pos]))
373
+ pos++;
374
+ if (pos >= inner.length)
375
+ break;
376
+ if (!/[a-zA-Z_]/.test(inner[pos]))
377
+ break;
378
+ const nameStart = pos;
379
+ while (pos < inner.length && /\w/.test(inner[pos]))
380
+ pos++;
381
+ const toolName = inner.substring(nameStart, pos);
382
+ while (pos < inner.length && inner[pos] === " ")
383
+ pos++;
384
+ if (pos >= inner.length || inner[pos] !== "(")
385
+ break;
386
+ pos++;
387
+ const argsStart = pos;
388
+ let args = {};
389
+ let parsed = false;
390
+ if (pos < inner.length && inner[pos] === "{") {
391
+ const jsonStr = extractBalancedJson(inner, pos);
392
+ if (jsonStr) {
393
+ pos += jsonStr.length;
394
+ const parsedArgs = tryParseLooseJson(jsonStr);
395
+ if (parsedArgs && typeof parsedArgs === "object") {
396
+ args = parsedArgs;
397
+ parsed = true;
398
+ }
399
+ }
400
+ }
401
+ if (!parsed) {
402
+ while (pos < inner.length && inner[pos] !== ")")
403
+ pos++;
404
+ const rawArgs = inner.substring(argsStart, pos).trim();
405
+ if (rawArgs) {
406
+ const parsedArgs = tryParseLooseJson(rawArgs);
407
+ if (parsedArgs && typeof parsedArgs === "object") {
408
+ args = parsedArgs;
409
+ }
410
+ else {
411
+ args = {};
412
+ }
413
+ }
414
+ }
415
+ while (pos < inner.length && inner[pos] !== ")")
416
+ pos++;
417
+ if (pos < inner.length)
418
+ pos++;
419
+ results.push({
420
+ id: generateId(),
421
+ name: toolName,
422
+ arguments: args,
423
+ });
424
+ }
425
+ return results;
426
+ }
427
+ function parseToolArgs(args) {
428
+ if (!args)
429
+ return {};
430
+ if (typeof args === "string") {
431
+ return tryParseLooseJson(args) ?? {};
432
+ }
433
+ if (typeof args === "object")
434
+ return args;
435
+ return {};
436
+ }
437
+ function tryExtractToolCallsFromToolCallsArrayText(text) {
438
+ const markerMatch = /"tool_calls"\s*:\s*\[/.exec(text);
439
+ if (!markerMatch)
440
+ return null;
441
+ const toolCalls = [];
442
+ let pos = markerMatch.index + markerMatch[0].length;
443
+ while (pos < text.length) {
444
+ while (pos < text.length && /\s/.test(text[pos]))
445
+ pos++;
446
+ if (pos >= text.length)
447
+ break;
448
+ const ch = text[pos];
449
+ if (ch === "]") {
450
+ pos++;
451
+ while (pos < text.length && /[\s}]/.test(text[pos]))
452
+ pos++;
453
+ return {
454
+ toolCalls,
455
+ remainder: text.slice(pos).trim(),
456
+ };
457
+ }
458
+ if (ch === "," || ch === "}") {
459
+ pos++;
460
+ continue;
461
+ }
462
+ if (ch !== "{") {
463
+ break;
464
+ }
465
+ const jsonStr = extractBalancedJson(text, pos);
466
+ if (!jsonStr)
467
+ return null;
468
+ const parsed = tryParseLooseJson(jsonStr);
469
+ if (!parsed || typeof parsed !== "object")
470
+ return null;
471
+ const result = tryParseJsonToolCalls(parsed);
472
+ if (!result || result.toolCalls.length === 0)
473
+ return null;
474
+ toolCalls.push(...result.toolCalls);
475
+ pos += jsonStr.length;
476
+ }
477
+ return null;
478
+ }
479
+ function tryParseLooseJson(text) {
480
+ try {
481
+ return JSON.parse(text);
482
+ }
483
+ catch {
484
+ const sanitized = escapeLiteralControlCharsInJsonStrings(text);
485
+ if (sanitized === text) {
486
+ return null;
487
+ }
488
+ try {
489
+ return JSON.parse(sanitized);
490
+ }
491
+ catch {
492
+ return null;
493
+ }
494
+ }
495
+ }
496
+ function escapeLiteralControlCharsInJsonStrings(text) {
497
+ let result = "";
498
+ let inString = false;
499
+ let escape = false;
500
+ for (const ch of text) {
501
+ if (escape) {
502
+ result += ch;
503
+ escape = false;
504
+ continue;
505
+ }
506
+ if (ch === "\\" && inString) {
507
+ result += ch;
508
+ escape = true;
509
+ continue;
510
+ }
511
+ if (ch === "\"") {
512
+ result += ch;
513
+ inString = !inString;
514
+ continue;
515
+ }
516
+ if (inString) {
517
+ if (ch === "\n") {
518
+ result += "\\n";
519
+ continue;
520
+ }
521
+ if (ch === "\r") {
522
+ result += "\\r";
523
+ continue;
524
+ }
525
+ if (ch === "\t") {
526
+ result += "\\t";
527
+ continue;
528
+ }
529
+ }
530
+ result += ch;
531
+ }
532
+ return result;
533
+ }
534
+ function extractBalancedJson(text, start) {
535
+ if (start < 0 || start >= text.length || text[start] !== "{")
536
+ return null;
537
+ let depth = 0;
538
+ let inString = false;
539
+ let escape = false;
540
+ for (let i = start; i < text.length; i++) {
541
+ const ch = text[i];
542
+ if (escape) {
543
+ escape = false;
544
+ continue;
545
+ }
546
+ if (ch === "\\" && inString) {
547
+ escape = true;
548
+ continue;
549
+ }
550
+ if (ch === "\"") {
551
+ inString = !inString;
552
+ continue;
553
+ }
554
+ if (inString)
555
+ continue;
556
+ if (ch === "{")
557
+ depth++;
558
+ else if (ch === "}") {
559
+ depth--;
560
+ if (depth === 0)
561
+ return text.substring(start, i + 1);
562
+ }
563
+ }
564
+ return null;
565
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const parser_1 = require("./parser");
5
+ (0, vitest_1.describe)('parseLlmToolCalls', () => {
6
+ (0, vitest_1.describe)('Format A: pure JSON with tool_calls array', () => {
7
+ (0, vitest_1.it)('parses a single tool call', () => {
8
+ const text = '{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/tmp/a.ts"}}]}';
9
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
10
+ (0, vitest_1.expect)(content).toBe('');
11
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
12
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
13
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
14
+ });
15
+ (0, vitest_1.it)('parses multiple tool calls', () => {
16
+ const text = JSON.stringify({
17
+ tool_calls: [
18
+ { tool_name: 'grep', arguments: { pattern: 'foo', path: '/src' } },
19
+ { tool_name: 'glob', arguments: { pattern: '**/*.ts' } },
20
+ { tool_name: 'listFiles', arguments: { path: '/src' } },
21
+ ],
22
+ });
23
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
24
+ (0, vitest_1.expect)(content).toBe('');
25
+ (0, vitest_1.expect)(toolCalls).toHaveLength(3);
26
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
27
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('glob');
28
+ (0, vitest_1.expect)(toolCalls[2].name).toBe('listFiles');
29
+ });
30
+ });
31
+ (0, vitest_1.describe)('Format B: single tool call without array wrapper', () => {
32
+ (0, vitest_1.it)('parses single tool_name object', () => {
33
+ const text = '{"tool_name":"bash","arguments":{"command":"ls"}}';
34
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
35
+ (0, vitest_1.expect)(content).toBe('');
36
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
37
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('bash');
38
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ command: 'ls' });
39
+ });
40
+ });
41
+ (0, vitest_1.describe)('Multiple consecutive tool_calls JSON blocks', () => {
42
+ (0, vitest_1.it)('parses two consecutive tool_calls blocks', () => {
43
+ const text = '{"tool_calls":[{"tool_name":"glob","arguments":{"pattern":"README*"}}]}{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/tmp/a.ts"}}]}';
44
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
45
+ (0, vitest_1.expect)(toolCalls).toHaveLength(2);
46
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('glob');
47
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('fileRead');
48
+ (0, vitest_1.expect)(content).toBe('');
49
+ });
50
+ (0, vitest_1.it)('parses three consecutive tool_calls blocks (real log scenario)', () => {
51
+ const text = '{"tool_calls":[{"tool_name":"glob","arguments":{"pattern":"README*","path":"/Users/someone/gitlab/mini-kode"}},{"tool_name":"fileRead","arguments":{"filePath":"/Users/someone/gitlab/mini-kode/package.json"}}]}{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/Users/someone/gitlab/mini-kode/README.md"}}]}{"tool_calls":[{"tool_name":"fileEdit","arguments":{"filePath":"/Users/someone/gitlab/mini-kode/README.md","old_string":"# mini-kode\\n","new_string":"# mini-kode\\n\\n## Install\\n"}}]}';
52
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
53
+ (0, vitest_1.expect)(toolCalls).toHaveLength(4);
54
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('glob');
55
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('fileRead');
56
+ (0, vitest_1.expect)(toolCalls[2].name).toBe('fileRead');
57
+ (0, vitest_1.expect)(toolCalls[3].name).toBe('fileEdit');
58
+ (0, vitest_1.expect)(content).toBe('');
59
+ });
60
+ (0, vitest_1.it)('parses consecutive tool_calls blocks with trailing text', () => {
61
+ const text = '{"tool_calls":[{"tool_name":"glob","arguments":{"pattern":"*.ts"}}]}{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/a.ts"}}]}以上是文件内容';
62
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
63
+ (0, vitest_1.expect)(toolCalls).toHaveLength(2);
64
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('glob');
65
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('fileRead');
66
+ (0, vitest_1.expect)(content).toBe('以上是文件内容');
67
+ });
68
+ });
69
+ (0, vitest_1.describe)('JSON at start with trailing text (the s.log bug)', () => {
70
+ (0, vitest_1.it)('parses tool_calls JSON followed by trailing text without separator', () => {
71
+ const text = '{"tool_calls":[{"tool_name":"grep","arguments":{"pattern":"\\\\.xxxx/skills","path":"/Users/someone/gitlab/xxx-xxxx-agent-cli"}},{"tool_name":"glob","arguments":{"pattern":"**/.xxxx/skills/**"}},{"tool_name":"listFiles","arguments":{"path":"/Users/someone/gitlab/xxx-xxxx-agent-cli/.xxxx"}}]}`.xxxx/skills` 没发现。\n当前仓库里 skill 相关主要是:`.agents/skills/`。';
72
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
73
+ (0, vitest_1.expect)(toolCalls).toHaveLength(3);
74
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
75
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('glob');
76
+ (0, vitest_1.expect)(toolCalls[2].name).toBe('listFiles');
77
+ (0, vitest_1.expect)(content).toContain('.xxxx/skills');
78
+ });
79
+ (0, vitest_1.it)('parses tool_calls JSON followed by newline and text', () => {
80
+ const text = '{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/tmp/x.ts"}}]}\n文件内容如下...';
81
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
82
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
83
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
84
+ (0, vitest_1.expect)(content).toBe('文件内容如下...');
85
+ });
86
+ (0, vitest_1.it)('parses tool_calls JSON string with newline', () => {
87
+ const text = `{"tool_calls":[{"tool_name":"fileEdit","arguments":{"filePath":"/Users/someone/gitlab/tyyf6hyazx6l0sgrbr/src/components/bottom-bar/index.tsx","old_string":" <View\n className={styles.btns}\n style={{\n paddingBottom: isIphoneX ? '40rpx' : '0rpx',\n }}\n >\n {showButtons.map(item => {\n","new_string":" <View\n className={styles.btns}\n style={{\n paddingBottom: isIphoneX\n ? 'calc(40rpx + env(safe-area-inset-bottom))'\n : 'env(safe-area-inset-bottom)',\n }}\n >\n {showButtons.map(item => {\n"}}},{"tool_name":"glob","arguments":{"pattern":"package.json","path":"/Users/someone/gitlab/tyyf6hyazx6l0sgrbr"}},{"tool_name":"glob","arguments":{"pattern":"pnpm-lock.yaml","path":"/Users/someone/gitlab/tyyf6hyazx6l0sgrbr"}}]}`;
88
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
89
+ (0, vitest_1.expect)(content).toBe('');
90
+ (0, vitest_1.expect)(toolCalls).toHaveLength(3);
91
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileEdit');
92
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('glob');
93
+ (0, vitest_1.expect)(toolCalls[2].name).toBe('glob');
94
+ (0, vitest_1.expect)(toolCalls[0].arguments).toMatchObject({
95
+ filePath: '/Users/someone/gitlab/tyyf6hyazx6l0sgrbr/src/components/bottom-bar/index.tsx',
96
+ });
97
+ });
98
+ });
99
+ (0, vitest_1.describe)('Format 0.5: text followed by tool_calls JSON', () => {
100
+ (0, vitest_1.it)('parses text before JSON block', () => {
101
+ const text = '让我来查看一下\n{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/a.ts"}}]}';
102
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
103
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
104
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
105
+ (0, vitest_1.expect)(content).toBe('让我来查看一下');
106
+ });
107
+ });
108
+ (0, vitest_1.describe)('[tool_calls] marker format', () => {
109
+ (0, vitest_1.it)('parses [tool_calls] with consecutive JSON objects', () => {
110
+ const text = '我来搜索一下\n[tool_calls]{"tool":"grep","args":{"pattern":"hello"}}{"tool":"glob","args":{"pattern":"*.ts"}}';
111
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
112
+ (0, vitest_1.expect)(toolCalls).toHaveLength(2);
113
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
114
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('glob');
115
+ (0, vitest_1.expect)(content).toBe('我来搜索一下');
116
+ });
117
+ });
118
+ (0, vitest_1.describe)('Action/Arguments format', () => {
119
+ (0, vitest_1.it)('parses Action and Arguments text format', () => {
120
+ const text = 'Action: fileRead\nArguments: {"filePath":"/tmp/test.ts"}';
121
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
122
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
123
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
124
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/test.ts' });
125
+ });
126
+ });
127
+ (0, vitest_1.describe)('XML tag format', () => {
128
+ (0, vitest_1.it)('parses XML-style tool calls', () => {
129
+ const text = '<fileRead>{"filePath":"/tmp/a.ts"}</fileRead>';
130
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
131
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
132
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
133
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
134
+ });
135
+ (0, vitest_1.it)('ignores thinking/reflection tags', () => {
136
+ const text = '<thinking>{"internal":"reasoning"}</thinking>\n<fileRead>{"filePath":"/a.ts"}</fileRead>';
137
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
138
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
139
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
140
+ });
141
+ });
142
+ (0, vitest_1.describe)('[Called tools: ...] format', () => {
143
+ (0, vitest_1.it)('parses single called tool', () => {
144
+ const text = '● [Called tools: fileRead({"filePath":"/tmp/a.ts"})]';
145
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
146
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
147
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
148
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
149
+ });
150
+ (0, vitest_1.it)('parses multiple called tools', () => {
151
+ const text = '● [Called tools: grep({"pattern":"foo"}), glob({"pattern":"*.ts"})]';
152
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
153
+ (0, vitest_1.expect)(toolCalls).toHaveLength(2);
154
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
155
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('glob');
156
+ });
157
+ });
158
+ (0, vitest_1.describe)('function_call format (GPT3.5/4 legacy)', () => {
159
+ (0, vitest_1.it)('parses function_call format', () => {
160
+ const text = JSON.stringify({
161
+ function_call: {
162
+ name: 'fileRead',
163
+ arguments: JSON.stringify({ filePath: '/tmp/a.ts' }),
164
+ },
165
+ });
166
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
167
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
168
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
169
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
170
+ });
171
+ });
172
+ (0, vitest_1.describe)('Claude tool_use content block', () => {
173
+ (0, vitest_1.it)('parses claude tool_use block', () => {
174
+ const text = JSON.stringify({
175
+ content: [
176
+ {
177
+ type: 'tool_use',
178
+ id: 'toolu_123',
179
+ name: 'fileRead',
180
+ input: { filePath: '/tmp/a.ts' },
181
+ },
182
+ ],
183
+ });
184
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
185
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
186
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
187
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
188
+ });
189
+ (0, vitest_1.it)('parses multiple tool_use blocks in content array', () => {
190
+ const text = JSON.stringify({
191
+ content: [
192
+ {
193
+ type: 'tool_use',
194
+ id: 'toolu_1',
195
+ name: 'grep',
196
+ input: { pattern: 'foo' },
197
+ },
198
+ {
199
+ type: 'tool_use',
200
+ id: 'toolu_2',
201
+ name: 'glob',
202
+ input: { pattern: '*.ts' },
203
+ },
204
+ ],
205
+ });
206
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
207
+ (0, vitest_1.expect)(toolCalls).toHaveLength(2);
208
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
209
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('glob');
210
+ });
211
+ });
212
+ (0, vitest_1.describe)('Claude standalone tool_use', () => {
213
+ (0, vitest_1.it)('parses standalone tool_use object', () => {
214
+ const text = JSON.stringify({
215
+ type: 'tool_use',
216
+ name: 'fileRead',
217
+ input: { filePath: '/tmp/a.ts' },
218
+ });
219
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
220
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
221
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
222
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
223
+ });
224
+ });
225
+ (0, vitest_1.describe)('OpenAI Responses API format', () => {
226
+ (0, vitest_1.it)('parses responses api function_call', () => {
227
+ const text = JSON.stringify({
228
+ type: 'function_call',
229
+ call_id: 'call_123',
230
+ name: 'fileRead',
231
+ arguments: JSON.stringify({ filePath: '/tmp/a.ts' }),
232
+ });
233
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
234
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
235
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
236
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
237
+ });
238
+ });
239
+ (0, vitest_1.describe)('tool + args single object format', () => {
240
+ (0, vitest_1.it)('parses tool + args format', () => {
241
+ const text = JSON.stringify({
242
+ tool: 'fileRead',
243
+ args: { filePath: '/tmp/a.ts' },
244
+ });
245
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
246
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
247
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
248
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
249
+ });
250
+ });
251
+ (0, vitest_1.describe)('root array format', () => {
252
+ (0, vitest_1.it)('parses root array tool calls', () => {
253
+ const text = JSON.stringify([
254
+ { tool: 'grep', args: { pattern: 'foo' } },
255
+ { tool: 'glob', args: { pattern: '*.ts' } },
256
+ ]);
257
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
258
+ (0, vitest_1.expect)(toolCalls).toHaveLength(2);
259
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
260
+ (0, vitest_1.expect)(toolCalls[1].name).toBe('glob');
261
+ });
262
+ });
263
+ (0, vitest_1.describe)('XML inside code block', () => {
264
+ (0, vitest_1.it)('parses xml inside code block', () => {
265
+ const text = `
266
+ \`\`\`xml
267
+ <fileRead>{"filePath":"/tmp/a.ts"}</fileRead>
268
+ \`\`\`
269
+ `;
270
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
271
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
272
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
273
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ filePath: '/tmp/a.ts' });
274
+ });
275
+ });
276
+ (0, vitest_1.describe)('non-JSON arguments', () => {
277
+ (0, vitest_1.it)('handles non-json arguments as value string', () => {
278
+ const text = `
279
+ Action: grep
280
+ Arguments: foo
281
+ `;
282
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
283
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
284
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
285
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ value: 'foo' });
286
+ });
287
+ (0, vitest_1.it)('handles multiline non-json arguments', () => {
288
+ const text = `Action: grep
289
+ Arguments:
290
+ foo`;
291
+ const { toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
292
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
293
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('grep');
294
+ (0, vitest_1.expect)(toolCalls[0].arguments).toEqual({ value: 'foo' });
295
+ });
296
+ });
297
+ (0, vitest_1.describe)('plain text (no tool calls)', () => {
298
+ (0, vitest_1.it)('returns text as content with empty toolCalls', () => {
299
+ const text = '这是一段普通的回复文本,没有任何工具调用。';
300
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
301
+ (0, vitest_1.expect)(content).toBe(text);
302
+ (0, vitest_1.expect)(toolCalls).toHaveLength(0);
303
+ });
304
+ (0, vitest_1.it)('handles empty string', () => {
305
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)('');
306
+ (0, vitest_1.expect)(content).toBe('');
307
+ (0, vitest_1.expect)(toolCalls).toHaveLength(0);
308
+ });
309
+ });
310
+ (0, vitest_1.describe)('code block with tool_calls JSON', () => {
311
+ (0, vitest_1.it)('parses tool_calls inside ```json code fence', () => {
312
+ const text = '我来读取文件\n```json\n{"tool_calls":[{"tool_name":"fileRead","arguments":{"filePath":"/a.ts"}}]}\n```';
313
+ const { content, toolCalls } = (0, parser_1.parseLlmToolCalls)(text);
314
+ (0, vitest_1.expect)(toolCalls).toHaveLength(1);
315
+ (0, vitest_1.expect)(toolCalls[0].name).toBe('fileRead');
316
+ (0, vitest_1.expect)(content).toContain('我来读取文件');
317
+ });
318
+ });
319
+ });
320
+ (0, vitest_1.describe)('looksLikeToolCall', () => {
321
+ (0, vitest_1.it)('detects JSON starting with {', () => {
322
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('{"tool_calls":[...]}')).toBe(true);
323
+ });
324
+ (0, vitest_1.it)('detects [tool_calls] marker', () => {
325
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('[tool_calls]{...}')).toBe(true);
326
+ });
327
+ (0, vitest_1.it)('detects Action: format', () => {
328
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('Action: fileRead\nArguments: {...}')).toBe(true);
329
+ });
330
+ (0, vitest_1.it)('detects embedded tool_calls JSON', () => {
331
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('一些文本 {"tool_calls":[...]}')).toBe(true);
332
+ });
333
+ (0, vitest_1.it)('detects [Called tools:] format', () => {
334
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('● [Called tools: grep(...)]')).toBe(true);
335
+ });
336
+ (0, vitest_1.it)('detects root array format', () => {
337
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('[{"tool":"grep","args":{}}]')).toBe(true);
338
+ });
339
+ (0, vitest_1.it)('detects function_call keyword', () => {
340
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('{"function_call":{"name":"fileRead"}}')).toBe(true);
341
+ });
342
+ (0, vitest_1.it)('detects tool_use keyword', () => {
343
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('{"type":"tool_use","name":"fileRead"}')).toBe(true);
344
+ });
345
+ (0, vitest_1.it)('returns false for plain text', () => {
346
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('这是一段普通回复')).toBe(false);
347
+ });
348
+ (0, vitest_1.it)('returns false for markdown content', () => {
349
+ (0, vitest_1.expect)((0, parser_1.looksLikeToolCall)('## Heading\n\nSome content here')).toBe(false);
350
+ });
351
+ });
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "llm-tool-parser",
3
+ "version": "0.0.1",
4
+ "description": "Universal LLM tool call extractor that normalizes messy outputs into a unified ToolCall format.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Saber2pr/llm-tool-parser"
8
+ },
9
+ "author": "saber2pr",
10
+ "license": "MIT",
11
+ "files": [
12
+ "lib"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "registry": "https://registry.npmjs.org/"
17
+ },
18
+ "keywords": [
19
+ "llm",
20
+ "tool-calling",
21
+ "tool-calls",
22
+ "ai-tools",
23
+ "agent",
24
+ "ai-agent",
25
+ "llm-agent",
26
+ "tool-parser",
27
+ "tool-extraction",
28
+ "llm-output-parser"
29
+ ],
30
+ "main": "./lib/index.js",
31
+ "scripts": {
32
+ "start": "tsc --watch",
33
+ "build": "tsc",
34
+ "prepublishOnly": "yarn build",
35
+ "test": "vitest run src/*.test.ts",
36
+ "lint": "prettier --write ./src",
37
+ "prepare": "husky install"
38
+ },
39
+ "dependencies": {},
40
+ "devDependencies": {
41
+ "@types/node": "^25.9.1",
42
+ "husky": "^7.0.2",
43
+ "lint-staged": "^11.1.2",
44
+ "prettier": "^2.4.1",
45
+ "typescript": "^5.9.2",
46
+ "vitest": "^4.1.8"
47
+ },
48
+ "lint-staged": {
49
+ "*.{js,jsx,ts,tsx}": [
50
+ "yarn lint",
51
+ "git add ."
52
+ ]
53
+ }
54
+ }