@pedrofariasx/qwenproxy 1.2.1 → 1.2.3
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 +3 -13
- package/package.json +1 -1
- package/src/api/server.ts +4 -6
- package/src/cache/memory-cache.ts +5 -3
- package/src/core/account-manager.ts +1 -1
- package/src/core/accounts.ts +1 -1
- package/src/login.ts +2 -2
- package/src/routes/chat.ts +122 -91
- package/src/routes/upload.ts +5 -5
- package/src/services/playwright.ts +40 -120
- package/src/services/qwen.ts +29 -27
- package/src/tests/concurrency.test.ts +1 -1
- package/src/tests/concurrentChat.test.ts +1 -1
- package/src/tests/contextTruncation.test.ts +142 -0
- package/src/tests/delta.test.ts +80 -10
- package/src/tests/jsonFix.test.ts +110 -98
- package/src/tests/multimodal.test.ts +1 -1
- package/src/tests/parser.test.ts +40 -2
- package/src/tools/parser.ts +98 -33
- package/src/utils/context-truncation.ts +1 -6
- package/src/utils/json.ts +9 -8
- package/src/utils/types.ts +1 -1
- package/src/linter/extraction-engine.ts +0 -165
- package/src/linter/index.ts +0 -258
- package/src/linter/repair-normalize.ts +0 -245
- package/src/linter/safety-gate.ts +0 -219
- package/src/linter/streaming-state-machine.ts +0 -252
- package/src/linter/structural-parser.ts +0 -352
- package/src/linter/types.ts +0 -74
- package/src/tests/linter.test.ts +0 -151
- package/src/tests/parallel.test.ts +0 -42
- package/src/tests/structureVerification.test.ts +0 -176
- package/src/tools/ast.ts +0 -15
- package/src/tools/coercion.ts +0 -67
- package/src/tools/confidence.ts +0 -48
- package/src/tools/detector.ts +0 -40
- package/src/tools/executor.ts +0 -236
- package/src/tools/pipeline.ts +0 -122
- package/src/tools/registry-runtime.ts +0 -34
- package/src/tools/repair.ts +0 -42
- package/src/tools/validator.ts +0 -33
package/src/tests/delta.test.ts
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import { getIncrementalDelta } from '../routes/chat.
|
|
3
|
+
import { getIncrementalDelta } from '../routes/chat.js';
|
|
4
4
|
|
|
5
5
|
test('getIncrementalDelta: handles strictly cumulative stream correctly', () => {
|
|
6
6
|
let accumulated = '';
|
|
7
7
|
|
|
8
|
-
// Step 1
|
|
9
8
|
let chunk1 = 'const x = 1;';
|
|
10
9
|
let res1 = getIncrementalDelta(accumulated, chunk1);
|
|
11
10
|
assert.strictEqual(res1.delta, 'const x = 1;');
|
|
12
11
|
accumulated = res1.matchedContent;
|
|
13
12
|
|
|
14
|
-
// Step 2
|
|
15
13
|
let chunk2 = 'const x = 1;\nconst y = 2;';
|
|
16
14
|
let res2 = getIncrementalDelta(accumulated, chunk2);
|
|
17
15
|
assert.strictEqual(res2.delta, '\nconst y = 2;');
|
|
18
16
|
accumulated = res2.matchedContent;
|
|
19
17
|
|
|
20
|
-
// Step 3
|
|
21
18
|
let chunk3 = 'const x = 1;\nconst y = 2;\nconst z = 3;';
|
|
22
19
|
let res3 = getIncrementalDelta(accumulated, chunk3);
|
|
23
20
|
assert.strictEqual(res3.delta, '\nconst z = 3;');
|
|
@@ -29,19 +26,16 @@ test('getIncrementalDelta: handles strictly cumulative stream correctly', () =>
|
|
|
29
26
|
test('getIncrementalDelta: handles strictly incremental stream correctly', () => {
|
|
30
27
|
let accumulated = '';
|
|
31
28
|
|
|
32
|
-
// Step 1
|
|
33
29
|
let chunk1 = 'const x = 1;';
|
|
34
30
|
let res1 = getIncrementalDelta(accumulated, chunk1);
|
|
35
31
|
assert.strictEqual(res1.delta, 'const x = 1;');
|
|
36
32
|
accumulated = res1.matchedContent;
|
|
37
33
|
|
|
38
|
-
// Step 2
|
|
39
34
|
let chunk2 = '\nconst y = 2;';
|
|
40
35
|
let res2 = getIncrementalDelta(accumulated, chunk2);
|
|
41
36
|
assert.strictEqual(res2.delta, '\nconst y = 2;');
|
|
42
37
|
accumulated = res2.matchedContent;
|
|
43
38
|
|
|
44
|
-
// Step 3
|
|
45
39
|
let chunk3 = '\nconst z = 3;';
|
|
46
40
|
let res3 = getIncrementalDelta(accumulated, chunk3);
|
|
47
41
|
assert.strictEqual(res3.delta, '\nconst z = 3;');
|
|
@@ -51,13 +45,89 @@ test('getIncrementalDelta: handles strictly incremental stream correctly', () =>
|
|
|
51
45
|
});
|
|
52
46
|
|
|
53
47
|
test('getIncrementalDelta: does not suffer from false-positive repetitive word overlap bugs', () => {
|
|
54
|
-
// Previously, if oldStr ended in a common keyword and newStr started/contained the same keyword,
|
|
55
|
-
// it would incorrectly match them and strip them. Let's verify this is fixed.
|
|
56
48
|
let accumulated = 'import { useState } from \'react\';\nimport {';
|
|
57
49
|
let nextChunk = ' Button } from \'@/components/ui/button\';';
|
|
58
50
|
|
|
59
51
|
let res = getIncrementalDelta(accumulated, nextChunk);
|
|
60
|
-
// It should treat the next chunk as strictly incremental and return it unchanged.
|
|
61
52
|
assert.strictEqual(res.delta, ' Button } from \'@/components/ui/button\';');
|
|
62
53
|
assert.strictEqual(res.matchedContent, 'import { useState } from \'react\';\nimport { Button } from \'@/components/ui/button\';');
|
|
63
54
|
});
|
|
55
|
+
|
|
56
|
+
test('getIncrementalDelta: empty oldStr returns newStr as delta', () => {
|
|
57
|
+
const res = getIncrementalDelta('', 'hello world');
|
|
58
|
+
assert.strictEqual(res.delta, 'hello world');
|
|
59
|
+
assert.strictEqual(res.matchedContent, 'hello world');
|
|
60
|
+
assert.strictEqual(res.contentLength, 11);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('getIncrementalDelta: identical strings return empty delta', () => {
|
|
64
|
+
const str = 'some content here';
|
|
65
|
+
const res = getIncrementalDelta(str, str, str.length, str.slice(-64));
|
|
66
|
+
assert.strictEqual(res.delta, '');
|
|
67
|
+
assert.strictEqual(res.matchedContent, str);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('getIncrementalDelta: completely different strings concatenate', () => {
|
|
71
|
+
const res = getIncrementalDelta('abc', 'xyz');
|
|
72
|
+
assert.strictEqual(res.delta, 'xyz');
|
|
73
|
+
assert.strictEqual(res.matchedContent, 'abcxyz');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('getIncrementalDelta: uses prevLength fast path when suffix matches', () => {
|
|
77
|
+
const oldStr = 'hello world';
|
|
78
|
+
const prevLength = oldStr.length;
|
|
79
|
+
const prevSuffix = oldStr.slice(-64);
|
|
80
|
+
const newStr = 'hello world extended';
|
|
81
|
+
|
|
82
|
+
const res = getIncrementalDelta(oldStr, newStr, prevLength, prevSuffix);
|
|
83
|
+
assert.strictEqual(res.delta, ' extended');
|
|
84
|
+
assert.strictEqual(res.matchedContent, newStr);
|
|
85
|
+
assert.strictEqual(res.contentLength, newStr.length);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('getIncrementalDelta: large string with tiny delta falls back to concatenation for safety', () => {
|
|
89
|
+
const oldStr = 'x'.repeat(3000);
|
|
90
|
+
const tinyDelta = 'a';
|
|
91
|
+
const newStr = oldStr + tinyDelta;
|
|
92
|
+
|
|
93
|
+
const res = getIncrementalDelta(oldStr, newStr, oldStr.length, oldStr.slice(-64));
|
|
94
|
+
assert.strictEqual(res.matchedContent, newStr);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('getIncrementalDelta: contentSuffix tracks last 64 characters', () => {
|
|
98
|
+
const longStr = 'a'.repeat(100);
|
|
99
|
+
const res = getIncrementalDelta('', longStr);
|
|
100
|
+
assert.strictEqual(res.contentSuffix.length, 64);
|
|
101
|
+
assert.strictEqual(res.contentSuffix, 'a'.repeat(64));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('getIncrementalDelta: handles segment-based prefix matching', () => {
|
|
105
|
+
const prefix = 'a'.repeat(200);
|
|
106
|
+
const oldStr = prefix + 'OLD';
|
|
107
|
+
const newStr = prefix + 'NEW';
|
|
108
|
+
|
|
109
|
+
const res = getIncrementalDelta(oldStr, newStr);
|
|
110
|
+
assert.ok(res.delta.length > 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('getIncrementalDelta: works through a realistic multi-chunk stream', () => {
|
|
114
|
+
let accumulated = '';
|
|
115
|
+
const chunks = [
|
|
116
|
+
'The',
|
|
117
|
+
'The quick',
|
|
118
|
+
'The quick brown',
|
|
119
|
+
'The quick brown fox',
|
|
120
|
+
'The quick brown fox jumps',
|
|
121
|
+
'The quick brown fox jumps over the lazy dog.',
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
let finalDelta = '';
|
|
125
|
+
for (const chunk of chunks) {
|
|
126
|
+
const res = getIncrementalDelta(accumulated, chunk, accumulated.length, accumulated.slice(-64));
|
|
127
|
+
finalDelta += res.delta;
|
|
128
|
+
accumulated = res.matchedContent;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
assert.strictEqual(accumulated, 'The quick brown fox jumps over the lazy dog.');
|
|
132
|
+
assert.strictEqual(finalDelta, 'The quick brown fox jumps over the lazy dog.');
|
|
133
|
+
});
|
|
@@ -1,98 +1,110 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { robustParseJSON } from '../utils/json.js';
|
|
4
|
+
|
|
5
|
+
test('robustParseJSON: valid JSON passes through directly', () => {
|
|
6
|
+
const result = robustParseJSON('{"name": "test", "arguments": {"a": 1}}');
|
|
7
|
+
assert.deepStrictEqual(result, { name: 'test', arguments: { a: 1 } });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('robustParseJSON: returns null for empty string', () => {
|
|
11
|
+
assert.strictEqual(robustParseJSON(''), null);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('robustParseJSON: returns null for non-object string', () => {
|
|
15
|
+
assert.strictEqual(robustParseJSON('just plain text'), null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('robustParseJSON: handles markdown code fence wrapping', () => {
|
|
19
|
+
const result = robustParseJSON('```json\n{"name": "test"}\n```');
|
|
20
|
+
assert.deepStrictEqual(result, { name: 'test' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('robustParseJSON: handles missing closing braces', () => {
|
|
24
|
+
const result = robustParseJSON('{"name": "test", "arguments": {"foo": "bar"');
|
|
25
|
+
assert.ok(result);
|
|
26
|
+
assert.strictEqual(result.arguments.foo, 'bar');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('robustParseJSON: handles missing closing brackets', () => {
|
|
30
|
+
const result = robustParseJSON('{"items": [1, 2, 3');
|
|
31
|
+
assert.ok(result);
|
|
32
|
+
assert.deepStrictEqual(result.items, [1, 2, 3]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('robustParseJSON: handles double key hallucination', () => {
|
|
36
|
+
const result = robustParseJSON('{"name": "name": "create_file", "arguments": {"path": "b.txt"}}');
|
|
37
|
+
assert.ok(result);
|
|
38
|
+
assert.strictEqual(result.name, 'create_file');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('robustParseJSON: handles unquoted keys', () => {
|
|
42
|
+
const result = robustParseJSON('{"name":"Read",arguments:{"file_path":"test.ts","limit":100}}');
|
|
43
|
+
assert.ok(result);
|
|
44
|
+
assert.strictEqual(result.arguments.limit, 100);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('robustParseJSON: handles control characters in string values', () => {
|
|
48
|
+
const literalNewline = '{"name": "control", "msg": "line 1\nline 2"}';
|
|
49
|
+
const result = robustParseJSON(literalNewline);
|
|
50
|
+
assert.ok(result);
|
|
51
|
+
assert.ok(result.msg.includes('line 1'));
|
|
52
|
+
assert.ok(result.msg.includes('line 2'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('robustParseJSON: handles Windows path backslashes', () => {
|
|
56
|
+
const result = robustParseJSON('{"path": "C:\\\\Users\\\\name\\\\Documents"}');
|
|
57
|
+
assert.ok(result);
|
|
58
|
+
assert.ok(
|
|
59
|
+
result.path === 'C:\\Users\\name\\Documents' || result.path === 'C:\\\\Users\\\\name\\\\Documents',
|
|
60
|
+
`Unexpected path: ${result.path}`
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('robustParseJSON: handles trailing comma', () => {
|
|
65
|
+
const result = robustParseJSON('{"name": "test", "value": 42,}');
|
|
66
|
+
assert.ok(result);
|
|
67
|
+
assert.strictEqual(result.name, 'test');
|
|
68
|
+
assert.strictEqual(result.value, 42);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('robustParseJSON: handles complex nested suggest payload', () => {
|
|
72
|
+
const payload = '{"name": "suggest", "arguments": {"suggest": "Landing page criada", "actions": [{"label": "Revisar", "description": "Review", "prompt": "/local-review-uncommitted"}]})';
|
|
73
|
+
const result = robustParseJSON(payload);
|
|
74
|
+
assert.ok(result);
|
|
75
|
+
assert.strictEqual(result.name, 'suggest');
|
|
76
|
+
assert.ok(result.arguments.actions.length >= 1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('robustParseJSON: handles deeply nested malformed JSON gracefully', () => {
|
|
80
|
+
const crazy = `{"name": "suggest", "arguments": {"suggest": "ok", "actions": [{"label": "test"<tool_call>\n{"name": "broken"}]}}`;
|
|
81
|
+
const result = robustParseJSON(crazy);
|
|
82
|
+
assert.ok(result === null || typeof result === 'object');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('robustParseJSON: strips leading text before first brace', () => {
|
|
86
|
+
const result = robustParseJSON('some text before {"name": "found"}');
|
|
87
|
+
assert.ok(result);
|
|
88
|
+
assert.strictEqual(result.name, 'found');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('robustParseJSON: handles array values correctly', () => {
|
|
92
|
+
const result = robustParseJSON('{"items": ["a", "b", "c"]}');
|
|
93
|
+
assert.ok(result);
|
|
94
|
+
assert.deepStrictEqual(result.items, ['a', 'b', 'c']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('robustParseJSON: handles numeric values', () => {
|
|
98
|
+
const result = robustParseJSON('{"count": 42, "ratio": 3.14}');
|
|
99
|
+
assert.ok(result);
|
|
100
|
+
assert.strictEqual(result.count, 42);
|
|
101
|
+
assert.strictEqual(result.ratio, 3.14);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('robustParseJSON: handles boolean and null values', () => {
|
|
105
|
+
const result = robustParseJSON('{"active": true, "deleted": false, "data": null}');
|
|
106
|
+
assert.ok(result);
|
|
107
|
+
assert.strictEqual(result.active, true);
|
|
108
|
+
assert.strictEqual(result.deleted, false);
|
|
109
|
+
assert.strictEqual(result.data, null);
|
|
110
|
+
});
|
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import net from 'node:net';
|
|
7
7
|
import { serve } from '@hono/node-server';
|
|
8
8
|
import { app } from '../api/server.js';
|
|
9
|
-
import { initPlaywright, closePlaywright } from '../services/playwright.
|
|
9
|
+
import { initPlaywright, closePlaywright } from '../services/playwright.js';
|
|
10
10
|
|
|
11
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
12
|
const __dirname = path.dirname(__filename);
|
package/src/tests/parser.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import { StreamingToolParser } from '../tools/parser.
|
|
3
|
+
import { StreamingToolParser } from '../tools/parser.js';
|
|
4
4
|
|
|
5
5
|
test('StreamingToolParser: basic tool call', () => {
|
|
6
6
|
const parser = new StreamingToolParser();
|
|
@@ -54,7 +54,7 @@ test('StreamingToolParser: flush partial content', () => {
|
|
|
54
54
|
test('StreamingToolParser: robust parsing of malformed JSON', () => {
|
|
55
55
|
const parser = new StreamingToolParser();
|
|
56
56
|
|
|
57
|
-
const res = parser.feed('<tool_call>{"name": "broken", "arguments": {"a": 1</tool_call>');
|
|
57
|
+
const res = parser.feed('<tool_call>{"name": "broken", "arguments": {"a": 1}</tool_call>');
|
|
58
58
|
assert.strictEqual(res.toolCalls.length, 1);
|
|
59
59
|
assert.strictEqual(res.toolCalls[0].name, 'broken');
|
|
60
60
|
assert.deepStrictEqual(res.toolCalls[0].arguments, { a: 1 });
|
|
@@ -87,3 +87,41 @@ test('StreamingToolParser: handles multiple tool calls in array format', () => {
|
|
|
87
87
|
assert.strictEqual(result.toolCalls[1].name, 'read');
|
|
88
88
|
assert.strictEqual(result.toolCalls[0].arguments.command, 'ls');
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
test('StreamingToolParser: double-escaped quotes in JSON', () => {
|
|
92
|
+
const parser = new StreamingToolParser();
|
|
93
|
+
|
|
94
|
+
const input = '<tool_call>{\\"name\\": \\"edit\\", \\"arguments\\": {\\"filePath\\": \\"/tmp/test.txt\\", \\"content\\": \\"hello\\"}}</tool_call>';
|
|
95
|
+
const res = parser.feed(input);
|
|
96
|
+
assert.strictEqual(res.toolCalls.length, 1);
|
|
97
|
+
assert.strictEqual(res.toolCalls[0].name, 'edit');
|
|
98
|
+
assert.strictEqual(res.toolCalls[0].arguments.filePath, '/tmp/test.txt');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('StreamingToolParser: double-escaped quotes in XML parameters', () => {
|
|
102
|
+
const parser = new StreamingToolParser();
|
|
103
|
+
|
|
104
|
+
const input = '<tool_call>\n<name>write</name>\n<parameter name=\\"content\\"><div>hello & world</div></parameter>\n</tool_call>';
|
|
105
|
+
const res = parser.feed(input);
|
|
106
|
+
assert.strictEqual(res.toolCalls.length, 1);
|
|
107
|
+
assert.strictEqual(res.toolCalls[0].name, 'write');
|
|
108
|
+
assert.strictEqual(res.toolCalls[0].arguments.content, '<div>hello & world</div>');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('StreamingToolParser: truncated JSON with unclosed string', () => {
|
|
112
|
+
const parser = new StreamingToolParser();
|
|
113
|
+
|
|
114
|
+
const res = parser.feed('<tool_call>{"name": "bash", "arguments": {"command": "echo hello</tool_call>');
|
|
115
|
+
assert.strictEqual(res.toolCalls.length, 1);
|
|
116
|
+
assert.strictEqual(res.toolCalls[0].name, 'bash');
|
|
117
|
+
assert.strictEqual(typeof res.toolCalls[0].arguments.command, 'string');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('StreamingToolParser: flush double-escaped tool call', () => {
|
|
121
|
+
const parser = new StreamingToolParser();
|
|
122
|
+
|
|
123
|
+
parser.feed('<tool_call>{\\"name\\": \\"recover\\",\\"arguments\\": {\\"a\\": \\"val');
|
|
124
|
+
const flushed = parser.flush();
|
|
125
|
+
assert.strictEqual(flushed.toolCalls.length, 1);
|
|
126
|
+
assert.strictEqual(flushed.toolCalls[0].name, 'recover');
|
|
127
|
+
});
|
package/src/tools/parser.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Supports both JSON and Hermes-style XML <parameter> formats.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
import crypto from 'crypto';
|
|
9
9
|
import { robustParseJSON } from '../utils/json.js';
|
|
10
10
|
import { logger } from '../core/logger.js';
|
|
11
11
|
import type { ParsedToolCall } from './types';
|
|
@@ -30,6 +30,29 @@ function decodeXmlEntities(value: string): string {
|
|
|
30
30
|
.replace(/&/g, '&');
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function unescapeDoubleEscaped(content: string): string {
|
|
34
|
+
const trimmed = content.trim();
|
|
35
|
+
if (!trimmed) return content;
|
|
36
|
+
|
|
37
|
+
const isJsonLike = trimmed.startsWith('{') || trimmed.startsWith('[');
|
|
38
|
+
const isXmlLike = trimmed.startsWith('<');
|
|
39
|
+
|
|
40
|
+
if (!isJsonLike && !isXmlLike) return content;
|
|
41
|
+
|
|
42
|
+
const firstQuoteIdx = trimmed.indexOf('"');
|
|
43
|
+
if (firstQuoteIdx === -1) return content;
|
|
44
|
+
|
|
45
|
+
const firstEscapedQuoteIdx = trimmed.indexOf('\\"');
|
|
46
|
+
|
|
47
|
+
if (firstEscapedQuoteIdx !== -1 && (firstQuoteIdx === -1 || firstEscapedQuoteIdx < firstQuoteIdx)) {
|
|
48
|
+
return content
|
|
49
|
+
.replace(/\\"/g, '"')
|
|
50
|
+
.replace(/\\\\/g, '\\');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function coerceParameterValue(rawValue: string): unknown {
|
|
34
57
|
const value = decodeXmlEntities(rawValue.trim());
|
|
35
58
|
if (value === 'true') return true;
|
|
@@ -136,19 +159,67 @@ function parseRecoverableXmlToolCall(
|
|
|
136
159
|
return { name: toolName, arguments: args };
|
|
137
160
|
}
|
|
138
161
|
|
|
162
|
+
// ─── String-Aware Tag Detection ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function findToolEndIndex(buffer: string): number {
|
|
165
|
+
const tagLen = TOOL_END.length;
|
|
166
|
+
const limit = buffer.length - tagLen;
|
|
167
|
+
let inString = false;
|
|
168
|
+
let escaped = false;
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i <= limit; i++) {
|
|
171
|
+
const ch = buffer[i];
|
|
172
|
+
if (escaped) { escaped = false; continue; }
|
|
173
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
174
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
175
|
+
if (inString || ch !== '<') continue;
|
|
176
|
+
let match = true;
|
|
177
|
+
for (let j = 1; j < tagLen; j++) {
|
|
178
|
+
const c = buffer.charCodeAt(i + j);
|
|
179
|
+
const t = TOOL_END.charCodeAt(j);
|
|
180
|
+
if (c !== t && (c | 0x20) !== (t | 0x20)) { match = false; break; }
|
|
181
|
+
}
|
|
182
|
+
if (match) return i;
|
|
183
|
+
}
|
|
184
|
+
return -1;
|
|
185
|
+
}
|
|
186
|
+
|
|
139
187
|
// ─── Partial Tag Detection ─────────────────────────────────────────────────────
|
|
140
188
|
|
|
141
189
|
const TOOL_START_LITERAL = '<tool_call>';
|
|
142
190
|
|
|
143
191
|
function findPartialToolOpenIndex(buffer: string): number {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
192
|
+
const prefix = '<tool_call';
|
|
193
|
+
const prefixLen = prefix.length;
|
|
194
|
+
const bufLen = buffer.length;
|
|
195
|
+
|
|
196
|
+
let lastPartialIdx = -1;
|
|
197
|
+
for (let i = bufLen - 1; i >= Math.max(0, bufLen - prefixLen - 1); i--) {
|
|
198
|
+
if (buffer[i] !== '<') continue;
|
|
199
|
+
let match = true;
|
|
200
|
+
for (let j = 1; j < prefixLen && i + j < bufLen; j++) {
|
|
201
|
+
const c = buffer.charCodeAt(i + j);
|
|
202
|
+
const t = prefix.charCodeAt(j);
|
|
203
|
+
if (c !== t && ((c | 0x20) !== (t | 0x20))) { match = false; break; }
|
|
204
|
+
}
|
|
205
|
+
if (match) {
|
|
206
|
+
lastPartialIdx = i;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (lastPartialIdx !== -1 && buffer.indexOf('>', lastPartialIdx) === -1) return lastPartialIdx;
|
|
148
211
|
|
|
149
|
-
// Check for partial prefix at end (e.g. `<tool`, `<tool_`, `<tool_c`)
|
|
150
212
|
for (let i = 1; i < TOOL_START_LITERAL.length; i++) {
|
|
151
|
-
|
|
213
|
+
const sub = TOOL_START_LITERAL.substring(0, i);
|
|
214
|
+
const subLen = sub.length;
|
|
215
|
+
if (bufLen < subLen) continue;
|
|
216
|
+
let match = true;
|
|
217
|
+
for (let j = 0; j < subLen; j++) {
|
|
218
|
+
const c = buffer.charCodeAt(bufLen - subLen + j);
|
|
219
|
+
const t = sub.charCodeAt(j);
|
|
220
|
+
if (c !== t && (c | 0x20) !== (t | 0x20)) { match = false; break; }
|
|
221
|
+
}
|
|
222
|
+
if (match) return bufLen - i;
|
|
152
223
|
}
|
|
153
224
|
return -1;
|
|
154
225
|
}
|
|
@@ -183,6 +254,11 @@ export class StreamingToolParser {
|
|
|
183
254
|
|
|
184
255
|
while (this.buffer.length > 0) {
|
|
185
256
|
if (!this.insideTool) {
|
|
257
|
+
if (this.buffer.indexOf('<') === -1) {
|
|
258
|
+
if (this.emittedToolCallCount === 0) result.text += this.buffer;
|
|
259
|
+
this.buffer = '';
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
186
262
|
const match = this.buffer.match(TOOL_OPEN_RE);
|
|
187
263
|
if (match && match.index !== undefined) {
|
|
188
264
|
// Text before the tool call tag
|
|
@@ -206,10 +282,8 @@ export class StreamingToolParser {
|
|
|
206
282
|
}
|
|
207
283
|
break;
|
|
208
284
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const lowerBuffer = this.buffer.toLowerCase();
|
|
212
|
-
const endIdx = lowerBuffer.indexOf(TOOL_END);
|
|
285
|
+
} else {
|
|
286
|
+
const endIdx = this.buffer.indexOf(TOOL_END);
|
|
213
287
|
if (endIdx !== -1) {
|
|
214
288
|
const content = this.buffer.substring(0, endIdx);
|
|
215
289
|
this.buffer = this.buffer.substring(endIdx + TOOL_END.length);
|
|
@@ -276,20 +350,11 @@ export class StreamingToolParser {
|
|
|
276
350
|
return this.insideTool;
|
|
277
351
|
}
|
|
278
352
|
|
|
279
|
-
/**
|
|
280
|
-
* Get any lead-in text that was captured before tool calls.
|
|
281
|
-
* Useful for fallback content when tool calls fail to parse.
|
|
282
|
-
*/
|
|
283
|
-
getPendingLeadIn(): string {
|
|
284
|
-
return this.pendingLeadIn;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
353
|
// ─── Internal Methods ──────────────────────────────────────────────────────
|
|
288
354
|
|
|
289
355
|
private processToolContent(content: string, result: ParserResult): void {
|
|
290
|
-
|
|
356
|
+
let t = content.trim();
|
|
291
357
|
if (!t) {
|
|
292
|
-
// Empty tool call - malformed. Restore lead-in if possible.
|
|
293
358
|
logger.warn('[parser] Dropping empty tool call block');
|
|
294
359
|
if (this.emittedToolCallCount === 0 && this.pendingLeadIn.trim().length > 0) {
|
|
295
360
|
result.text += this.pendingLeadIn;
|
|
@@ -298,11 +363,12 @@ export class StreamingToolParser {
|
|
|
298
363
|
return;
|
|
299
364
|
}
|
|
300
365
|
|
|
301
|
-
|
|
366
|
+
t = unescapeDoubleEscaped(t);
|
|
367
|
+
|
|
302
368
|
const xmlParsed = parseXmlParameterToolCall(t, this.currentOpenTag, this.tools);
|
|
303
369
|
if (xmlParsed) {
|
|
304
370
|
result.toolCalls.push({
|
|
305
|
-
id: `call_${
|
|
371
|
+
id: `call_${crypto.randomUUID()}`,
|
|
306
372
|
name: xmlParsed.name,
|
|
307
373
|
arguments: xmlParsed.arguments,
|
|
308
374
|
});
|
|
@@ -361,31 +427,30 @@ export class StreamingToolParser {
|
|
|
361
427
|
}
|
|
362
428
|
|
|
363
429
|
private tryRecoverToolCall(block: string): ParsedToolCall | null {
|
|
364
|
-
|
|
365
|
-
|
|
430
|
+
const unescaped = unescapeDoubleEscaped(block);
|
|
431
|
+
|
|
432
|
+
const xmlParsed = parseXmlParameterToolCall(unescaped, this.currentOpenTag, this.tools);
|
|
366
433
|
if (xmlParsed) {
|
|
367
434
|
return {
|
|
368
|
-
id: `call_${
|
|
435
|
+
id: `call_${crypto.randomUUID()}`,
|
|
369
436
|
name: xmlParsed.name,
|
|
370
437
|
arguments: xmlParsed.arguments,
|
|
371
438
|
};
|
|
372
439
|
}
|
|
373
440
|
|
|
374
|
-
|
|
375
|
-
const recovered = parseRecoverableXmlToolCall(block, this.currentOpenTag, this.tools);
|
|
441
|
+
const recovered = parseRecoverableXmlToolCall(unescaped, this.currentOpenTag, this.tools);
|
|
376
442
|
if (recovered) {
|
|
377
443
|
return {
|
|
378
|
-
id: `call_${
|
|
444
|
+
id: `call_${crypto.randomUUID()}`,
|
|
379
445
|
name: recovered.name,
|
|
380
446
|
arguments: recovered.arguments,
|
|
381
447
|
};
|
|
382
448
|
}
|
|
383
449
|
|
|
384
|
-
|
|
385
|
-
const jsonParsed = this.parseToolContent(block);
|
|
450
|
+
const jsonParsed = this.parseToolContent(unescaped);
|
|
386
451
|
if (jsonParsed.length > 0) {
|
|
387
452
|
const first = jsonParsed[0];
|
|
388
|
-
const attrName = extractToolName(this.currentOpenTag,
|
|
453
|
+
const attrName = extractToolName(this.currentOpenTag, unescaped);
|
|
389
454
|
if (attrName && !first.name) first.name = attrName;
|
|
390
455
|
if (first.name) return first;
|
|
391
456
|
}
|
|
@@ -438,7 +503,7 @@ export class StreamingToolParser {
|
|
|
438
503
|
if (typeof args !== 'object' || args === null) args = {};
|
|
439
504
|
|
|
440
505
|
return {
|
|
441
|
-
id: parsed.id || parsed.tool_call_id || `call_${
|
|
506
|
+
id: parsed.id || parsed.tool_call_id || `call_${crypto.randomUUID()}`,
|
|
442
507
|
name,
|
|
443
508
|
arguments: args,
|
|
444
509
|
};
|