@librechat/agents 3.1.70 → 3.1.71-dev.0
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/dist/cjs/graphs/Graph.cjs +45 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/main.cjs +4 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +9 -2
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +4 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +43 -0
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +453 -45
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +475 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
- package/dist/cjs/utils/truncation.cjs +28 -0
- package/dist/cjs/utils/truncation.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +45 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/main.mjs +2 -2
- package/dist/esm/messages/prune.mjs +9 -2
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +4 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +42 -1
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +453 -45
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +468 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
- package/dist/esm/utils/truncation.mjs +27 -1
- package/dist/esm/utils/truncation.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +21 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/BashExecutor.d.ts +31 -0
- package/dist/types/tools/ToolNode.d.ts +86 -3
- package/dist/types/tools/toolOutputReferences.d.ts +205 -0
- package/dist/types/types/run.d.ts +9 -1
- package/dist/types/types/tools.d.ts +70 -0
- package/dist/types/utils/truncation.d.ts +21 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +48 -0
- package/src/messages/prune.ts +9 -2
- package/src/run.ts +4 -0
- package/src/specs/prune.test.ts +413 -0
- package/src/tools/BashExecutor.ts +45 -0
- package/src/tools/ToolNode.ts +618 -55
- package/src/tools/__tests__/BashExecutor.test.ts +36 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1395 -0
- package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
- package/src/tools/toolOutputReferences.ts +590 -0
- package/src/types/run.ts +9 -1
- package/src/types/tools.ts +71 -0
- package/src/utils/__tests__/truncation.test.ts +66 -0
- package/src/utils/truncation.ts +30 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import {
|
|
3
|
+
ToolOutputReferenceRegistry,
|
|
4
|
+
annotateToolOutputWithReference,
|
|
5
|
+
buildReferenceKey,
|
|
6
|
+
buildReferencePrefix,
|
|
7
|
+
TOOL_OUTPUT_REF_KEY,
|
|
8
|
+
TOOL_OUTPUT_REF_PATTERN,
|
|
9
|
+
} from '../toolOutputReferences';
|
|
10
|
+
|
|
11
|
+
describe('ToolOutputReferenceRegistry', () => {
|
|
12
|
+
describe('buildReferenceKey', () => {
|
|
13
|
+
it('formats keys as tool<idx>turn<turn>', () => {
|
|
14
|
+
expect(buildReferenceKey(0, 0)).toBe('tool0turn0');
|
|
15
|
+
expect(buildReferenceKey(3, 7)).toBe('tool3turn7');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('set / get', () => {
|
|
20
|
+
it('stores and retrieves outputs by key', () => {
|
|
21
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
22
|
+
reg.set('r', 'tool0turn0', 'hello world');
|
|
23
|
+
expect(reg.get('r', 'tool0turn0')).toBe('hello world');
|
|
24
|
+
expect(reg.size).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('clips stored values to the per-output limit', () => {
|
|
28
|
+
const reg = new ToolOutputReferenceRegistry({ maxOutputSize: 5 });
|
|
29
|
+
reg.set('r', 'tool0turn0', 'abcdefghij');
|
|
30
|
+
expect(reg.get('r', 'tool0turn0')).toBe('abcde');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('replaces existing entries under the same key without double-counting size', () => {
|
|
34
|
+
const reg = new ToolOutputReferenceRegistry({
|
|
35
|
+
maxOutputSize: 100,
|
|
36
|
+
maxTotalSize: 20,
|
|
37
|
+
});
|
|
38
|
+
reg.set('r', 'tool0turn0', 'hello');
|
|
39
|
+
reg.set('r', 'tool0turn0', 'world-longer');
|
|
40
|
+
expect(reg.get('r', 'tool0turn0')).toBe('world-longer');
|
|
41
|
+
expect(reg.size).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('clear / releaseRun', () => {
|
|
46
|
+
it('clear() drops every run bucket', () => {
|
|
47
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
48
|
+
reg.set('run-A', 'tool0turn0', 'A');
|
|
49
|
+
reg.set('run-B', 'tool0turn0', 'B');
|
|
50
|
+
expect(reg.size).toBe(2);
|
|
51
|
+
reg.clear();
|
|
52
|
+
expect(reg.size).toBe(0);
|
|
53
|
+
expect(reg.get('run-A', 'tool0turn0')).toBeUndefined();
|
|
54
|
+
expect(reg.get('run-B', 'tool0turn0')).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('releaseRun() drops only the named run', () => {
|
|
58
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
59
|
+
reg.set('run-A', 'tool0turn0', 'A');
|
|
60
|
+
reg.set('run-B', 'tool0turn0', 'B');
|
|
61
|
+
reg.releaseRun('run-A');
|
|
62
|
+
expect(reg.get('run-A', 'tool0turn0')).toBeUndefined();
|
|
63
|
+
expect(reg.get('run-B', 'tool0turn0')).toBe('B');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('FIFO eviction', () => {
|
|
68
|
+
it('evicts oldest entries when the aggregate cap is exceeded', () => {
|
|
69
|
+
const reg = new ToolOutputReferenceRegistry({
|
|
70
|
+
maxOutputSize: 10,
|
|
71
|
+
maxTotalSize: 12,
|
|
72
|
+
});
|
|
73
|
+
reg.set('r', 'tool0turn0', '1234567'); // 7 chars
|
|
74
|
+
reg.set('r', 'tool1turn0', '89'); // 9 total
|
|
75
|
+
reg.set('r', 'tool2turn0', 'abc'); // 12 total — at limit
|
|
76
|
+
reg.set('r', 'tool3turn0', 'XY'); // 14 → must evict oldest
|
|
77
|
+
expect(reg.get('r', 'tool0turn0')).toBeUndefined();
|
|
78
|
+
expect(reg.get('r', 'tool1turn0')).toBe('89');
|
|
79
|
+
expect(reg.get('r', 'tool2turn0')).toBe('abc');
|
|
80
|
+
expect(reg.get('r', 'tool3turn0')).toBe('XY');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps evicting oldest entries until the aggregate fits', () => {
|
|
84
|
+
const reg = new ToolOutputReferenceRegistry({
|
|
85
|
+
maxOutputSize: 10,
|
|
86
|
+
maxTotalSize: 8,
|
|
87
|
+
});
|
|
88
|
+
reg.set('r', 'tool0turn0', 'aaa');
|
|
89
|
+
reg.set('r', 'tool1turn0', 'bbb');
|
|
90
|
+
reg.set('r', 'tool2turn0', 'ccccccc'); // total 3+3+7=13 > 8, evict aaa then bbb
|
|
91
|
+
expect(reg.get('r', 'tool0turn0')).toBeUndefined();
|
|
92
|
+
expect(reg.get('r', 'tool1turn0')).toBeUndefined();
|
|
93
|
+
expect(reg.get('r', 'tool2turn0')).toBe('ccccccc');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('clamps the per-output cap to maxTotalSize so no entry exceeds the aggregate', () => {
|
|
97
|
+
const reg = new ToolOutputReferenceRegistry({
|
|
98
|
+
maxOutputSize: 1000,
|
|
99
|
+
maxTotalSize: 10,
|
|
100
|
+
});
|
|
101
|
+
reg.set('r', 'tool0turn0', 'x'.repeat(500));
|
|
102
|
+
const stored = reg.get('r', 'tool0turn0');
|
|
103
|
+
expect(stored).toBeDefined();
|
|
104
|
+
expect(stored!.length).toBeLessThanOrEqual(10);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('clamps a caller-supplied maxTotalSize to the documented hard cap', () => {
|
|
108
|
+
// 50 MB requested; should be clamped to the 5 MB hard cap.
|
|
109
|
+
const reg = new ToolOutputReferenceRegistry({
|
|
110
|
+
maxTotalSize: 50_000_000,
|
|
111
|
+
});
|
|
112
|
+
// `totalLimit` getter exposes the effective post-clamp value.
|
|
113
|
+
expect(reg.totalLimit).toBeLessThanOrEqual(5_000_000);
|
|
114
|
+
// Per-output is also bound by the same effective total.
|
|
115
|
+
expect(reg.perOutputLimit).toBeLessThanOrEqual(5_000_000);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('evicts the oldest run bucket when maxActiveRuns is exceeded', () => {
|
|
119
|
+
const reg = new ToolOutputReferenceRegistry({ maxActiveRuns: 2 });
|
|
120
|
+
reg.set('run-A', 'tool0turn0', 'A');
|
|
121
|
+
reg.set('run-B', 'tool0turn0', 'B');
|
|
122
|
+
reg.set('run-C', 'tool0turn0', 'C');
|
|
123
|
+
// run-A was the oldest insertion; LRU evicted it when run-C
|
|
124
|
+
// pushed the bucket count above the cap.
|
|
125
|
+
expect(reg.get('run-A', 'tool0turn0')).toBeUndefined();
|
|
126
|
+
expect(reg.get('run-B', 'tool0turn0')).toBe('B');
|
|
127
|
+
expect(reg.get('run-C', 'tool0turn0')).toBe('C');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('resolve', () => {
|
|
132
|
+
it('replaces placeholders in string args', () => {
|
|
133
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
134
|
+
reg.set('r', 'tool0turn0', 'HELLO');
|
|
135
|
+
const { resolved, unresolved } = reg.resolve('r', 'echo {{tool0turn0}}');
|
|
136
|
+
expect(resolved).toBe('echo HELLO');
|
|
137
|
+
expect(unresolved).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('replaces placeholders in nested object args', () => {
|
|
141
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
142
|
+
reg.set('r', 'tool0turn0', 'DATA');
|
|
143
|
+
const input = {
|
|
144
|
+
command: 'cat {{tool0turn0}}',
|
|
145
|
+
meta: { note: 'uses {{tool0turn0}} twice' },
|
|
146
|
+
};
|
|
147
|
+
const { resolved } = reg.resolve('r', input);
|
|
148
|
+
expect(resolved).toEqual({
|
|
149
|
+
command: 'cat DATA',
|
|
150
|
+
meta: { note: 'uses DATA twice' },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('replaces placeholders inside array values', () => {
|
|
155
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
156
|
+
reg.set('r', 'tool1turn2', '42');
|
|
157
|
+
const { resolved } = reg.resolve('r', {
|
|
158
|
+
args: ['--id', '{{tool1turn2}}', 'plain'],
|
|
159
|
+
});
|
|
160
|
+
expect(resolved).toEqual({ args: ['--id', '42', 'plain'] });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('reports unresolved references and leaves the placeholder in place', () => {
|
|
164
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
165
|
+
reg.set('r', 'tool0turn0', 'known');
|
|
166
|
+
const { resolved, unresolved } = reg.resolve(
|
|
167
|
+
'r',
|
|
168
|
+
'use {{tool0turn0}} and {{tool5turn9}}'
|
|
169
|
+
);
|
|
170
|
+
expect(resolved).toBe('use known and {{tool5turn9}}');
|
|
171
|
+
expect(unresolved).toEqual(['tool5turn9']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('deduplicates repeated unresolved keys', () => {
|
|
175
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
176
|
+
const { unresolved } = reg.resolve(
|
|
177
|
+
'r',
|
|
178
|
+
'{{tool7turn0}} and {{tool7turn0}} again'
|
|
179
|
+
);
|
|
180
|
+
expect(unresolved).toEqual(['tool7turn0']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('does not touch non-placeholder strings', () => {
|
|
184
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
185
|
+
reg.set('r', 'tool0turn0', 'X');
|
|
186
|
+
const { resolved } = reg.resolve('r', 'nothing to see here');
|
|
187
|
+
expect(resolved).toBe('nothing to see here');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('passes through primitive values untouched', () => {
|
|
191
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
192
|
+
const { resolved } = reg.resolve('r', {
|
|
193
|
+
count: 3,
|
|
194
|
+
enabled: true,
|
|
195
|
+
note: null,
|
|
196
|
+
});
|
|
197
|
+
expect(resolved).toEqual({ count: 3, enabled: true, note: null });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('snapshot', () => {
|
|
202
|
+
it('resolves against the captured state and ignores later mutations', () => {
|
|
203
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
204
|
+
reg.set('r', 'tool0turn0', 'OLD');
|
|
205
|
+
const view = reg.snapshot('r');
|
|
206
|
+
// Mutate after taking the snapshot.
|
|
207
|
+
reg.set('r', 'tool0turn0', 'NEW');
|
|
208
|
+
reg.set('r', 'tool1turn0', 'LATER');
|
|
209
|
+
// Snapshot still resolves to the captured value and treats
|
|
210
|
+
// post-snapshot additions as unresolved.
|
|
211
|
+
expect(view.resolve('echo {{tool0turn0}}').resolved).toBe('echo OLD');
|
|
212
|
+
const { resolved, unresolved } = view.resolve('see {{tool1turn0}}');
|
|
213
|
+
expect(resolved).toBe('see {{tool1turn0}}');
|
|
214
|
+
expect(unresolved).toEqual(['tool1turn0']);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('returns an empty view for a runId with no bucket', () => {
|
|
218
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
219
|
+
const view = reg.snapshot('never-touched');
|
|
220
|
+
const { resolved, unresolved } = view.resolve('see {{tool0turn0}}');
|
|
221
|
+
expect(resolved).toBe('see {{tool0turn0}}');
|
|
222
|
+
expect(unresolved).toEqual(['tool0turn0']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns an isolated view per snapshot call', () => {
|
|
226
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
227
|
+
reg.set('r', 'tool0turn0', 'A');
|
|
228
|
+
const view1 = reg.snapshot('r');
|
|
229
|
+
reg.set('r', 'tool0turn0', 'B');
|
|
230
|
+
const view2 = reg.snapshot('r');
|
|
231
|
+
expect(view1.resolve('{{tool0turn0}}').resolved).toBe('A');
|
|
232
|
+
expect(view2.resolve('{{tool0turn0}}').resolved).toBe('B');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('annotateToolOutputWithReference', () => {
|
|
237
|
+
it('injects _ref into plain JSON objects', () => {
|
|
238
|
+
const content = '{"a":1,"b":"x"}';
|
|
239
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
240
|
+
const parsed = JSON.parse(annotated);
|
|
241
|
+
expect(parsed[TOOL_OUTPUT_REF_KEY]).toBe('tool0turn0');
|
|
242
|
+
expect(parsed.a).toBe(1);
|
|
243
|
+
expect(parsed.b).toBe('x');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('preserves pretty-printed formatting when the original was pretty', () => {
|
|
247
|
+
const content = '{\n "a": 1\n}';
|
|
248
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
249
|
+
expect(annotated).toContain('\n "');
|
|
250
|
+
const parsed = JSON.parse(annotated);
|
|
251
|
+
expect(parsed[TOOL_OUTPUT_REF_KEY]).toBe('tool0turn0');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('uses the [ref: …] prefix for JSON arrays', () => {
|
|
255
|
+
const content = '[1,2,3]';
|
|
256
|
+
const annotated = annotateToolOutputWithReference(content, 'tool1turn0');
|
|
257
|
+
expect(annotated).toBe(`${buildReferencePrefix('tool1turn0')}\n[1,2,3]`);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('uses the [ref: …] prefix for JSON primitives', () => {
|
|
261
|
+
expect(annotateToolOutputWithReference('42', 'tool0turn0')).toBe(
|
|
262
|
+
'[ref: tool0turn0]\n42'
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('uses the [ref: …] prefix for plain strings', () => {
|
|
267
|
+
expect(annotateToolOutputWithReference('hello', 'tool0turn0')).toBe(
|
|
268
|
+
'[ref: tool0turn0]\nhello'
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('falls back to the prefix on JSON _ref collision', () => {
|
|
273
|
+
const content = '{"_ref":"other-value","data":1}';
|
|
274
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
275
|
+
expect(annotated.startsWith('[ref: tool0turn0]\n')).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('injects when the existing _ref matches the target key', () => {
|
|
279
|
+
const content = '{"_ref":"tool0turn0","data":1}';
|
|
280
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
281
|
+
const parsed = JSON.parse(annotated);
|
|
282
|
+
expect(parsed._ref).toBe('tool0turn0');
|
|
283
|
+
expect(parsed.data).toBe(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('overwrites an existing _ref:null with the injected key', () => {
|
|
287
|
+
const content = '{"_ref":null,"data":1}';
|
|
288
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
289
|
+
const parsed = JSON.parse(annotated);
|
|
290
|
+
expect(parsed._ref).toBe('tool0turn0');
|
|
291
|
+
expect(parsed.data).toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('places the injected _ref as the first key in the serialized JSON', () => {
|
|
295
|
+
const content = '{"a":1,"b":"x"}';
|
|
296
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
297
|
+
expect(annotated.indexOf('"_ref"')).toBe(1);
|
|
298
|
+
const annotatedFromNull = annotateToolOutputWithReference(
|
|
299
|
+
'{"_ref":null,"a":1}',
|
|
300
|
+
'tool0turn0'
|
|
301
|
+
);
|
|
302
|
+
expect(annotatedFromNull.indexOf('"_ref"')).toBe(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('falls back to the prefix when parsing fails', () => {
|
|
306
|
+
const content = '{ not actually json';
|
|
307
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
308
|
+
expect(annotated).toBe(`[ref: tool0turn0]\n${content}`);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('carries unresolved refs as a JSON field on parseable objects', () => {
|
|
312
|
+
const content = '{"a":1}';
|
|
313
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0', [
|
|
314
|
+
'tool9turn9',
|
|
315
|
+
'tool7turn0',
|
|
316
|
+
]);
|
|
317
|
+
const parsed = JSON.parse(annotated);
|
|
318
|
+
expect(parsed._ref).toBe('tool0turn0');
|
|
319
|
+
expect(parsed._unresolved_refs).toEqual(['tool9turn9', 'tool7turn0']);
|
|
320
|
+
expect(parsed.a).toBe(1);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('appends unresolved refs as a trailer line on non-object content', () => {
|
|
324
|
+
const annotated = annotateToolOutputWithReference(
|
|
325
|
+
'plain text',
|
|
326
|
+
'tool0turn0',
|
|
327
|
+
['tool9turn9']
|
|
328
|
+
);
|
|
329
|
+
expect(annotated).toBe(
|
|
330
|
+
'[ref: tool0turn0]\nplain text\n[unresolved refs: tool9turn9]'
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('supports unresolved-only annotation (no ref key)', () => {
|
|
335
|
+
const annotated = annotateToolOutputWithReference(
|
|
336
|
+
'error text',
|
|
337
|
+
undefined,
|
|
338
|
+
['tool9turn9']
|
|
339
|
+
);
|
|
340
|
+
expect(annotated).toBe('error text\n[unresolved refs: tool9turn9]');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('keeps JSON parseable for unresolved-only annotation on object content', () => {
|
|
344
|
+
const annotated = annotateToolOutputWithReference(
|
|
345
|
+
'{"error":"bad"}',
|
|
346
|
+
undefined,
|
|
347
|
+
['tool9turn9']
|
|
348
|
+
);
|
|
349
|
+
const parsed = JSON.parse(annotated);
|
|
350
|
+
expect(parsed._unresolved_refs).toEqual(['tool9turn9']);
|
|
351
|
+
expect(parsed.error).toBe('bad');
|
|
352
|
+
expect(parsed._ref).toBeUndefined();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('returns content unchanged when there is nothing to annotate', () => {
|
|
356
|
+
expect(annotateToolOutputWithReference('plain', undefined, [])).toBe(
|
|
357
|
+
'plain'
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('preserves an existing _unresolved_refs payload when only injecting a ref key', () => {
|
|
362
|
+
const content = '{"data":1,"_unresolved_refs":["user-supplied"]}';
|
|
363
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0');
|
|
364
|
+
const parsed = JSON.parse(annotated);
|
|
365
|
+
expect(parsed._ref).toBe('tool0turn0');
|
|
366
|
+
expect(parsed._unresolved_refs).toEqual(['user-supplied']);
|
|
367
|
+
expect(parsed.data).toBe(1);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('preserves an existing _ref payload on the unresolved-only path', () => {
|
|
371
|
+
const content = '{"data":1,"_ref":"preserved-value"}';
|
|
372
|
+
const annotated = annotateToolOutputWithReference(content, undefined, [
|
|
373
|
+
'tool9turn9',
|
|
374
|
+
]);
|
|
375
|
+
const parsed = JSON.parse(annotated);
|
|
376
|
+
expect(parsed._ref).toBe('preserved-value');
|
|
377
|
+
expect(parsed._unresolved_refs).toEqual(['tool9turn9']);
|
|
378
|
+
expect(parsed.data).toBe(1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('falls back to the prefix when _unresolved_refs conflicts with a non-matching array', () => {
|
|
382
|
+
const content = '{"data":1,"_unresolved_refs":["legacy"]}';
|
|
383
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0', [
|
|
384
|
+
'tool9turn9',
|
|
385
|
+
]);
|
|
386
|
+
expect(annotated.startsWith('[ref: tool0turn0]\n')).toBe(true);
|
|
387
|
+
expect(annotated).toContain('[unresolved refs: tool9turn9]');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('accepts a deep-equal existing _unresolved_refs array', () => {
|
|
391
|
+
const content = '{"data":1,"_unresolved_refs":["tool9turn9"]}';
|
|
392
|
+
const annotated = annotateToolOutputWithReference(content, 'tool0turn0', [
|
|
393
|
+
'tool9turn9',
|
|
394
|
+
]);
|
|
395
|
+
const parsed = JSON.parse(annotated);
|
|
396
|
+
expect(parsed._unresolved_refs).toEqual(['tool9turn9']);
|
|
397
|
+
expect(parsed._ref).toBe('tool0turn0');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe('TOOL_OUTPUT_REF_PATTERN', () => {
|
|
402
|
+
it('matches braced tool<N>turn<M> tokens and captures the key', () => {
|
|
403
|
+
const match = '{{tool0turn0}}'.match(TOOL_OUTPUT_REF_PATTERN);
|
|
404
|
+
expect(match?.[1]).toBe('tool0turn0');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('rejects bare tool<N>turn<M> tokens without braces', () => {
|
|
408
|
+
expect(TOOL_OUTPUT_REF_PATTERN.test('tool0turn0')).toBe(false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('is non-global so callers cannot trip on stale lastIndex', () => {
|
|
412
|
+
expect(TOOL_OUTPUT_REF_PATTERN.flags).not.toContain('g');
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
});
|