@pedrofariasx/qwenproxy 1.2.2 → 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/package.json +1 -1
- package/src/api/server.ts +4 -4
- package/src/cache/memory-cache.ts +6 -5
- 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 +15 -30
- package/src/routes/upload.ts +1 -1
- package/src/services/playwright.ts +39 -120
- package/src/services/qwen.ts +7 -14
- 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 +88 -20
- package/src/utils/context-truncation.ts +1 -1
- package/src/utils/json.ts +9 -8
- package/src/utils/types.ts +1 -1
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
|
@@ -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
|
}
|
|
@@ -211,10 +282,8 @@ export class StreamingToolParser {
|
|
|
211
282
|
}
|
|
212
283
|
break;
|
|
213
284
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const lowerBuffer = this.buffer.toLowerCase();
|
|
217
|
-
const endIdx = lowerBuffer.indexOf(TOOL_END);
|
|
285
|
+
} else {
|
|
286
|
+
const endIdx = this.buffer.indexOf(TOOL_END);
|
|
218
287
|
if (endIdx !== -1) {
|
|
219
288
|
const content = this.buffer.substring(0, endIdx);
|
|
220
289
|
this.buffer = this.buffer.substring(endIdx + TOOL_END.length);
|
|
@@ -284,9 +353,8 @@ export class StreamingToolParser {
|
|
|
284
353
|
// ─── Internal Methods ──────────────────────────────────────────────────────
|
|
285
354
|
|
|
286
355
|
private processToolContent(content: string, result: ParserResult): void {
|
|
287
|
-
|
|
356
|
+
let t = content.trim();
|
|
288
357
|
if (!t) {
|
|
289
|
-
// Empty tool call - malformed. Restore lead-in if possible.
|
|
290
358
|
logger.warn('[parser] Dropping empty tool call block');
|
|
291
359
|
if (this.emittedToolCallCount === 0 && this.pendingLeadIn.trim().length > 0) {
|
|
292
360
|
result.text += this.pendingLeadIn;
|
|
@@ -295,7 +363,8 @@ export class StreamingToolParser {
|
|
|
295
363
|
return;
|
|
296
364
|
}
|
|
297
365
|
|
|
298
|
-
|
|
366
|
+
t = unescapeDoubleEscaped(t);
|
|
367
|
+
|
|
299
368
|
const xmlParsed = parseXmlParameterToolCall(t, this.currentOpenTag, this.tools);
|
|
300
369
|
if (xmlParsed) {
|
|
301
370
|
result.toolCalls.push({
|
|
@@ -358,8 +427,9 @@ export class StreamingToolParser {
|
|
|
358
427
|
}
|
|
359
428
|
|
|
360
429
|
private tryRecoverToolCall(block: string): ParsedToolCall | null {
|
|
361
|
-
|
|
362
|
-
|
|
430
|
+
const unescaped = unescapeDoubleEscaped(block);
|
|
431
|
+
|
|
432
|
+
const xmlParsed = parseXmlParameterToolCall(unescaped, this.currentOpenTag, this.tools);
|
|
363
433
|
if (xmlParsed) {
|
|
364
434
|
return {
|
|
365
435
|
id: `call_${crypto.randomUUID()}`,
|
|
@@ -368,8 +438,7 @@ export class StreamingToolParser {
|
|
|
368
438
|
};
|
|
369
439
|
}
|
|
370
440
|
|
|
371
|
-
|
|
372
|
-
const recovered = parseRecoverableXmlToolCall(block, this.currentOpenTag, this.tools);
|
|
441
|
+
const recovered = parseRecoverableXmlToolCall(unescaped, this.currentOpenTag, this.tools);
|
|
373
442
|
if (recovered) {
|
|
374
443
|
return {
|
|
375
444
|
id: `call_${crypto.randomUUID()}`,
|
|
@@ -378,11 +447,10 @@ export class StreamingToolParser {
|
|
|
378
447
|
};
|
|
379
448
|
}
|
|
380
449
|
|
|
381
|
-
|
|
382
|
-
const jsonParsed = this.parseToolContent(block);
|
|
450
|
+
const jsonParsed = this.parseToolContent(unescaped);
|
|
383
451
|
if (jsonParsed.length > 0) {
|
|
384
452
|
const first = jsonParsed[0];
|
|
385
|
-
const attrName = extractToolName(this.currentOpenTag,
|
|
453
|
+
const attrName = extractToolName(this.currentOpenTag, unescaped);
|
|
386
454
|
if (attrName && !first.name) first.name = attrName;
|
|
387
455
|
if (first.name) return first;
|
|
388
456
|
}
|
|
@@ -31,7 +31,7 @@ function truncateSemantically(content: string, maxChars: number): string {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function truncateMessages(
|
|
34
|
-
messages: Array<{ role: string; content: string | null | any[] }>,
|
|
34
|
+
messages: Array<{ role: string; content: string | null | any[] | Record<string, unknown> }>,
|
|
35
35
|
maxContextLength: number,
|
|
36
36
|
systemPrompt: string = ''
|
|
37
37
|
): Array<{ role: string; content: string }> {
|
package/src/utils/json.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Robust JSON parsing utilities
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
function sanitizeAndBalance(input: string): { result: string; openBraces: number; openBrackets: number } {
|
|
7
|
+
function sanitizeAndBalance(input: string): { result: string; openBraces: number; openBrackets: number; inString: boolean } {
|
|
8
8
|
let out = '';
|
|
9
9
|
let openBraces = 0;
|
|
10
10
|
let openBrackets = 0;
|
|
@@ -48,11 +48,12 @@ function sanitizeAndBalance(input: string): { result: string; openBraces: number
|
|
|
48
48
|
if (char === ']') openBrackets--;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
return { result: out, openBraces, openBrackets };
|
|
51
|
+
return { result: out, openBraces, openBrackets, inString };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function closeBraces(input: string, openBraces: number, openBrackets: number): string {
|
|
54
|
+
function closeBraces(input: string, openBraces: number, openBrackets: number, inString: boolean = false): string {
|
|
55
55
|
let out = input;
|
|
56
|
+
if (inString) out += '"';
|
|
56
57
|
if (openBrackets > 0) out += ']'.repeat(openBrackets);
|
|
57
58
|
if (openBraces > 0) out += '}'.repeat(openBraces);
|
|
58
59
|
return out;
|
|
@@ -79,7 +80,7 @@ export function robustParseJSON(str: string): any {
|
|
|
79
80
|
cleaned = cleaned.slice(0, -1).trim();
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
const { result: fixedJson, openBraces, openBrackets } = sanitizeAndBalance(cleaned);
|
|
83
|
+
const { result: fixedJson, openBraces, openBrackets, inString } = sanitizeAndBalance(cleaned);
|
|
83
84
|
let lastBalancedIndex = -1;
|
|
84
85
|
|
|
85
86
|
{ let ob = 0, bk = 0, ins = false, esc = false;
|
|
@@ -99,15 +100,15 @@ export function robustParseJSON(str: string): any {
|
|
|
99
100
|
let tempJson = fixedJson;
|
|
100
101
|
if (lastBalancedIndex !== -1 && (openBraces !== 0 || openBrackets !== 0 || fixedJson.length > lastBalancedIndex + 1)) {
|
|
101
102
|
tempJson = fixedJson.substring(0, lastBalancedIndex + 1);
|
|
102
|
-
} else if (openBraces > 0 || openBrackets > 0) {
|
|
103
|
-
tempJson = closeBraces(fixedJson, openBraces, openBrackets);
|
|
103
|
+
} else if (openBraces > 0 || openBrackets > 0 || inString) {
|
|
104
|
+
tempJson = closeBraces(fixedJson, openBraces, openBrackets, inString);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
try { return JSON.parse(tempJson); } catch (e) {
|
|
107
108
|
let aggressive = fixedJson.trim();
|
|
108
109
|
if (aggressive.endsWith(',')) aggressive = aggressive.slice(0, -1);
|
|
109
|
-
const { result: aggFixed, openBraces: ob, openBrackets: bk } = sanitizeAndBalance(aggressive);
|
|
110
|
-
try { return JSON.parse(closeBraces(aggFixed, ob, bk)); } catch {
|
|
110
|
+
const { result: aggFixed, openBraces: ob, openBrackets: bk, inString: aggInString } = sanitizeAndBalance(aggressive);
|
|
111
|
+
try { return JSON.parse(closeBraces(aggFixed, ob, bk, aggInString)); } catch {
|
|
111
112
|
return null;
|
|
112
113
|
}
|
|
113
114
|
}
|