@librechat/agents 3.3.13 → 3.4.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 +10 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/index.cjs +35 -206
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +121 -241
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/google/index.cjs +19 -12
- package/dist/cjs/llm/google/index.cjs.map +1 -1
- package/dist/cjs/llm/mistral/index.cjs +26 -0
- package/dist/cjs/llm/mistral/index.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +82 -80
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/providers.cjs +19 -19
- package/dist/cjs/llm/providers.cjs.map +1 -1
- package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
- package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
- package/dist/cjs/llm/stream/smoother.cjs +369 -0
- package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
- package/dist/cjs/llm/vertexai/index.cjs +13 -1
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +18 -10
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +34 -2
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +76 -29
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +10 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +34 -205
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +120 -240
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/google/index.mjs +19 -12
- package/dist/esm/llm/google/index.mjs.map +1 -1
- package/dist/esm/llm/mistral/index.mjs +26 -0
- package/dist/esm/llm/mistral/index.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +82 -80
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/providers.mjs +3 -3
- package/dist/esm/llm/providers.mjs.map +1 -1
- package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
- package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
- package/dist/esm/llm/stream/smoother.mjs +365 -0
- package/dist/esm/llm/stream/smoother.mjs.map +1 -0
- package/dist/esm/llm/vertexai/index.mjs +13 -1
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +4 -2
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +34 -2
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +76 -30
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +3 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/llm/google/index.d.ts +2 -0
- package/dist/types/llm/mistral/index.d.ts +11 -0
- package/dist/types/llm/openai/index.d.ts +20 -4
- package/dist/types/llm/openrouter/index.d.ts +4 -1
- package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
- package/dist/types/llm/stream/smoother.d.ts +95 -0
- package/dist/types/llm/vertexai/index.d.ts +2 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +3 -0
- package/dist/types/types/llm.d.ts +21 -14
- package/dist/types/utils/tokens.d.ts +10 -0
- package/package.json +2 -2
- package/src/graphs/Graph.ts +11 -0
- package/src/index.ts +8 -0
- package/src/llm/anthropic/index.ts +85 -354
- package/src/llm/bedrock/index.ts +174 -390
- package/src/llm/bedrock/llm.spec.ts +2 -0
- package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
- package/src/llm/custom-chat-models.smoke.test.ts +16 -1
- package/src/llm/google/index.ts +17 -18
- package/src/llm/google/streamSmoothing.test.ts +121 -0
- package/src/llm/mistral/index.ts +33 -0
- package/src/llm/mistral/streamSmoothing.test.ts +97 -0
- package/src/llm/openai/deepseek.test.ts +56 -0
- package/src/llm/openai/index.ts +119 -126
- package/src/llm/openrouter/index.ts +4 -1
- package/src/llm/providers.ts +3 -3
- package/src/llm/stream/chunkAdapters.test.ts +202 -0
- package/src/llm/stream/chunkAdapters.ts +317 -0
- package/src/llm/stream/reassembly.test.ts +241 -0
- package/src/llm/stream/smoother.bench.test.ts +155 -0
- package/src/llm/stream/smoother.test.ts +519 -0
- package/src/llm/stream/smoother.ts +574 -0
- package/src/llm/vertexai/index.ts +17 -1
- package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
- package/src/specs/subagent.test.ts +44 -0
- package/src/specs/tokens.test.ts +159 -14
- package/src/tools/__tests__/SubagentExecutor.test.ts +48 -1
- package/src/tools/subagent/SubagentExecutor.ts +69 -5
- package/src/types/llm.ts +53 -36
- package/src/utils/tokens.ts +115 -30
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { expect, test, describe, jest } from '@jest/globals';
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
4
|
+
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
5
|
+
import { ChatVertexAI } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Vertex stream smoothing', () => {
|
|
8
|
+
function textOutput(text: string): Record<string, unknown> {
|
|
9
|
+
return {
|
|
10
|
+
candidates: [
|
|
11
|
+
{
|
|
12
|
+
content: { role: 'model', parts: [{ text }] },
|
|
13
|
+
index: 0,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function runStream(
|
|
20
|
+
outputs: Record<string, unknown>[],
|
|
21
|
+
modelFields: Record<string, unknown> = {}
|
|
22
|
+
): Promise<{
|
|
23
|
+
yielded: ChatGenerationChunk[];
|
|
24
|
+
dispatchedTokens: string[];
|
|
25
|
+
}> {
|
|
26
|
+
const model = new ChatVertexAI({
|
|
27
|
+
model: 'gemini-2.5-flash',
|
|
28
|
+
authOptions: {
|
|
29
|
+
projectId: 'test-project',
|
|
30
|
+
credentials: { client_email: 'test@test', private_key: 'test' },
|
|
31
|
+
},
|
|
32
|
+
...modelFields,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
let index = 0;
|
|
36
|
+
const fakeStream = {
|
|
37
|
+
get streamDone(): boolean {
|
|
38
|
+
return index > outputs.length;
|
|
39
|
+
},
|
|
40
|
+
async nextChunk(): Promise<unknown> {
|
|
41
|
+
const output = index < outputs.length ? outputs[index] : null;
|
|
42
|
+
index += 1;
|
|
43
|
+
return output;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
(
|
|
47
|
+
model as unknown as {
|
|
48
|
+
streamedConnection: { request: unknown };
|
|
49
|
+
}
|
|
50
|
+
).streamedConnection.request = jest.fn(async () => ({ data: fakeStream }));
|
|
51
|
+
|
|
52
|
+
const dispatchedTokens: string[] = [];
|
|
53
|
+
const runManager = {
|
|
54
|
+
handleCustomEvent: jest.fn(async () => undefined),
|
|
55
|
+
handleLLMNewToken: jest.fn(async (token: string) => {
|
|
56
|
+
dispatchedTokens.push(token);
|
|
57
|
+
}),
|
|
58
|
+
} as unknown as CallbackManagerForLLMRun;
|
|
59
|
+
|
|
60
|
+
const yielded: ChatGenerationChunk[] = [];
|
|
61
|
+
for await (const chunk of model._streamResponseChunks(
|
|
62
|
+
[new HumanMessage('hi')],
|
|
63
|
+
{} as Parameters<ChatVertexAI['_streamResponseChunks']>[1],
|
|
64
|
+
runManager
|
|
65
|
+
)) {
|
|
66
|
+
yielded.push(chunk);
|
|
67
|
+
}
|
|
68
|
+
return { yielded, dispatchedTokens };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('splits large text outputs at stream boundaries with pacing', async () => {
|
|
72
|
+
const { yielded, dispatchedTokens } = await runStream(
|
|
73
|
+
[textOutput('alpha beta gamma')],
|
|
74
|
+
{ _lc_stream_delay: 1 }
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const texts = yielded.map((chunk) => chunk.text).filter(Boolean);
|
|
78
|
+
expect(texts).toEqual(['alpha ', 'beta ', 'gamma']);
|
|
79
|
+
expect(dispatchedTokens.filter(Boolean)).toEqual([
|
|
80
|
+
'alpha ',
|
|
81
|
+
'beta ',
|
|
82
|
+
'gamma',
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('passes chunks through unsplit when smoothing is disabled', async () => {
|
|
87
|
+
const { yielded } = await runStream([textOutput('alpha beta gamma')], {
|
|
88
|
+
_lc_stream_delay: 0,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(yielded.map((chunk) => chunk.text).filter(Boolean)).toEqual([
|
|
92
|
+
'alpha beta gamma',
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('defaults to 25ms adaptive smoothing with 0 disabling', () => {
|
|
97
|
+
const base = {
|
|
98
|
+
model: 'gemini-2.5-flash',
|
|
99
|
+
authOptions: {
|
|
100
|
+
projectId: 'test-project',
|
|
101
|
+
credentials: { client_email: 'test@test', private_key: 'test' },
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
expect(new ChatVertexAI(base)._lc_stream_delay).toBe(25);
|
|
105
|
+
expect(
|
|
106
|
+
new ChatVertexAI({ ...base, _lc_stream_delay: 0 })._lc_stream_delay
|
|
107
|
+
).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -21,6 +21,7 @@ import * as providers from '@/llm/providers';
|
|
|
21
21
|
import { Run } from '@/run';
|
|
22
22
|
|
|
23
23
|
const CHILD_RESPONSE = 'Research result: Paris is the capital of France.';
|
|
24
|
+
const OVERRIDDEN_CHILD_RESPONSE = 'Deterministic child override result.';
|
|
24
25
|
|
|
25
26
|
const callerConfig: Partial<RunnableConfig> & {
|
|
26
27
|
version: 'v1' | 'v2';
|
|
@@ -226,6 +227,49 @@ describe('Subagent Integration', () => {
|
|
|
226
227
|
expect(subagentTool).toBeDefined();
|
|
227
228
|
});
|
|
228
229
|
|
|
230
|
+
it('only applies an explicitly configured subagent model override', async () => {
|
|
231
|
+
const invokeSubagent = async (
|
|
232
|
+
overrideSubagents: boolean
|
|
233
|
+
): Promise<string> => {
|
|
234
|
+
const run = await Run.create<t.IState>({
|
|
235
|
+
runId: `subagent-model-override-${overrideSubagents}-${Date.now()}`,
|
|
236
|
+
graphConfig: {
|
|
237
|
+
type: 'standard',
|
|
238
|
+
agents: [createParentAgent()],
|
|
239
|
+
},
|
|
240
|
+
returnContent: true,
|
|
241
|
+
skipCleanup: true,
|
|
242
|
+
});
|
|
243
|
+
const graph = run.Graph as StandardGraph;
|
|
244
|
+
const model = new FakeListChatModel({
|
|
245
|
+
responses: [OVERRIDDEN_CHILD_RESPONSE],
|
|
246
|
+
});
|
|
247
|
+
graph.overrideModel = model;
|
|
248
|
+
if (overrideSubagents) {
|
|
249
|
+
graph.setSubagentModelOverride(model);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const context = graph.agentContexts.get('parent');
|
|
253
|
+
const subagentTool = (context?.graphTools as t.GenericTool[]).find(
|
|
254
|
+
(tool) => 'name' in tool && tool.name === Constants.SUBAGENT
|
|
255
|
+
);
|
|
256
|
+
expect(subagentTool).toBeDefined();
|
|
257
|
+
|
|
258
|
+
return String(
|
|
259
|
+
await subagentTool!.invoke(
|
|
260
|
+
{
|
|
261
|
+
description: 'What is the capital of France?',
|
|
262
|
+
subagent_type: 'researcher',
|
|
263
|
+
},
|
|
264
|
+
callerConfig
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
await expect(invokeSubagent(false)).resolves.toBe(CHILD_RESPONSE);
|
|
270
|
+
await expect(invokeSubagent(true)).resolves.toBe(OVERRIDDEN_CHILD_RESPONSE);
|
|
271
|
+
});
|
|
272
|
+
|
|
229
273
|
it('inherits eager event-tool settings into self-spawn child graphs', async () => {
|
|
230
274
|
const originalCreateWorkflow = StandardGraph.prototype.createWorkflow;
|
|
231
275
|
const observedChildGraphs: Array<{
|
package/src/specs/tokens.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
estimateImageBlockTokens,
|
|
14
14
|
estimateDocumentBlockTokens,
|
|
15
15
|
estimateTimedMediaBlockTokens,
|
|
16
|
+
UnsafeTokenMeasurementError,
|
|
16
17
|
} from '@/utils/tokens';
|
|
17
18
|
|
|
18
19
|
/** Builds a minimal PNG data URI whose IHDR encodes the given dimensions. */
|
|
@@ -71,6 +72,32 @@ describe('createTokenCounter with different encodings', () => {
|
|
|
71
72
|
expect(count).toBeGreaterThan(0);
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
test('claude correction keeps proxy-backed tool history safely measurable', async () => {
|
|
76
|
+
let getCalls = 0;
|
|
77
|
+
const args = new Proxy(
|
|
78
|
+
{ query: 'select * from reports' },
|
|
79
|
+
{
|
|
80
|
+
get(target, property, receiver) {
|
|
81
|
+
getCalls++;
|
|
82
|
+
return Reflect.get(target, property, receiver);
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
const message = {
|
|
87
|
+
content: '',
|
|
88
|
+
tool_calls: [{ id: 'proxy-call', name: 'query', args }],
|
|
89
|
+
getType: () => 'ai',
|
|
90
|
+
} as unknown as AIMessage;
|
|
91
|
+
const counter = await createTokenCounter('claude');
|
|
92
|
+
|
|
93
|
+
const count = counter(message);
|
|
94
|
+
|
|
95
|
+
expect(Number.isSafeInteger(count)).toBe(true);
|
|
96
|
+
expect(count).toBeGreaterThan(0);
|
|
97
|
+
expect(count).toBeLessThan(1_000);
|
|
98
|
+
expect(getCalls).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
74
101
|
test('o200k_base encoding produces valid token counts', async () => {
|
|
75
102
|
const counter = await createTokenCounter('o200k_base');
|
|
76
103
|
const msg = new HumanMessage('Hello, world!');
|
|
@@ -110,6 +137,22 @@ describe('getTokenCountForMessage', () => {
|
|
|
110
137
|
expect(count).toBe(600_003);
|
|
111
138
|
});
|
|
112
139
|
|
|
140
|
+
test.each([
|
|
141
|
+
-1,
|
|
142
|
+
1.5,
|
|
143
|
+
Number.MAX_SAFE_INTEGER,
|
|
144
|
+
Number.MAX_SAFE_INTEGER + 1,
|
|
145
|
+
Number.POSITIVE_INFINITY,
|
|
146
|
+
Number.NaN,
|
|
147
|
+
])('rejects an unsafe tokenizer result of %s', (unsafeCount) => {
|
|
148
|
+
expect(() =>
|
|
149
|
+
getTokenCountForMessage(
|
|
150
|
+
new HumanMessage('unsafe count'),
|
|
151
|
+
() => unsafeCount
|
|
152
|
+
)
|
|
153
|
+
).toThrow(UnsafeTokenMeasurementError);
|
|
154
|
+
});
|
|
155
|
+
|
|
113
156
|
test('bounds direct string tool args before tokenization and charges omitted characters', () => {
|
|
114
157
|
const callbackLengths: number[] = [];
|
|
115
158
|
const args = 'x'.repeat(300_000);
|
|
@@ -153,10 +196,13 @@ describe('getTokenCountForMessage', () => {
|
|
|
153
196
|
);
|
|
154
197
|
|
|
155
198
|
expect(Math.max(...callbackLengths)).toBeLessThanOrEqual(200_000);
|
|
156
|
-
|
|
199
|
+
// A traversal-work sentinel is unknown size, not real context usage.
|
|
200
|
+
// The provider-input projection will compact the value before invoke.
|
|
201
|
+
expect(count).toBeGreaterThan(0);
|
|
202
|
+
expect(count).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
157
203
|
});
|
|
158
204
|
|
|
159
|
-
test('
|
|
205
|
+
test('counts an opaque proxy argument as its bounded provider-safe placeholder', () => {
|
|
160
206
|
let prototypeCalls = 0;
|
|
161
207
|
const args = new Proxy(
|
|
162
208
|
{ safe: true },
|
|
@@ -171,11 +217,11 @@ describe('getTokenCountForMessage', () => {
|
|
|
171
217
|
expect(hasUnsafeStructuredSerialization(args)).toBe(true);
|
|
172
218
|
expect(
|
|
173
219
|
getTokenCountForMessage(messageWithToolArgs(args), (text) => text.length)
|
|
174
|
-
).
|
|
220
|
+
).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
175
221
|
expect(prototypeCalls).toBeLessThanOrEqual(2);
|
|
176
222
|
});
|
|
177
223
|
|
|
178
|
-
test('
|
|
224
|
+
test('does not recurse through self-referential prototype proxies', () => {
|
|
179
225
|
let prototypeCalls = 0;
|
|
180
226
|
const args: Record<string, unknown> = new Proxy<Record<string, unknown>>(
|
|
181
227
|
{},
|
|
@@ -190,11 +236,11 @@ describe('getTokenCountForMessage', () => {
|
|
|
190
236
|
expect(hasUnsafeStructuredSerialization(args)).toBe(true);
|
|
191
237
|
expect(
|
|
192
238
|
getTokenCountForMessage(messageWithToolArgs(args), (text) => text.length)
|
|
193
|
-
).
|
|
239
|
+
).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
194
240
|
expect(prototypeCalls).toBeLessThanOrEqual(2);
|
|
195
241
|
});
|
|
196
242
|
|
|
197
|
-
test('
|
|
243
|
+
test('uses bounded placeholders when descriptor and own-key proxy traps throw', () => {
|
|
198
244
|
const descriptorProxy = new Proxy(
|
|
199
245
|
{},
|
|
200
246
|
{
|
|
@@ -219,11 +265,11 @@ describe('getTokenCountForMessage', () => {
|
|
|
219
265
|
messageWithToolArgs(args),
|
|
220
266
|
(text) => text.length
|
|
221
267
|
)
|
|
222
|
-
).
|
|
268
|
+
).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
223
269
|
}
|
|
224
270
|
});
|
|
225
271
|
|
|
226
|
-
test('
|
|
272
|
+
test('does not invoke get traps or revoked proxies while measuring arguments', () => {
|
|
227
273
|
let getCalls = 0;
|
|
228
274
|
const getProxy = new Proxy(
|
|
229
275
|
{},
|
|
@@ -250,12 +296,12 @@ describe('getTokenCountForMessage', () => {
|
|
|
250
296
|
messageWithToolArgs(args),
|
|
251
297
|
(text) => text.length
|
|
252
298
|
)
|
|
253
|
-
).
|
|
299
|
+
).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
254
300
|
}
|
|
255
301
|
expect(getCalls).toBe(0);
|
|
256
302
|
});
|
|
257
303
|
|
|
258
|
-
test('
|
|
304
|
+
test('reports a typed terminal error for a proxied content block', () => {
|
|
259
305
|
let getCalls = 0;
|
|
260
306
|
const contentBlock = new Proxy(
|
|
261
307
|
{ type: 'text', text: 'safe' },
|
|
@@ -271,12 +317,88 @@ describe('getTokenCountForMessage', () => {
|
|
|
271
317
|
getType: () => 'tool',
|
|
272
318
|
} as unknown as ToolMessage;
|
|
273
319
|
|
|
274
|
-
expect(getTokenCountForMessage(message, (text) => text.length)).
|
|
275
|
-
|
|
320
|
+
expect(() => getTokenCountForMessage(message, (text) => text.length)).toThrow(
|
|
321
|
+
UnsafeTokenMeasurementError
|
|
276
322
|
);
|
|
323
|
+
try {
|
|
324
|
+
getTokenCountForMessage(message, (text) => text.length);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
expect(JSON.parse((error as Error).message)).toEqual({
|
|
327
|
+
type: 'unsafe_token_measurement',
|
|
328
|
+
reason: 'content_proxy',
|
|
329
|
+
path: 'content[0]',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
277
332
|
expect(getCalls).toBe(0);
|
|
278
333
|
});
|
|
279
334
|
|
|
335
|
+
test('reports typed errors for unsafe message and metadata branches', () => {
|
|
336
|
+
let getterCalls = 0;
|
|
337
|
+
const messageProxy = new Proxy(new HumanMessage('secret payload'), {
|
|
338
|
+
get() {
|
|
339
|
+
getterCalls++;
|
|
340
|
+
throw new Error('message read denied');
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
const metadataProxy = new Proxy(
|
|
344
|
+
{},
|
|
345
|
+
{
|
|
346
|
+
get() {
|
|
347
|
+
getterCalls++;
|
|
348
|
+
throw new Error('metadata read denied');
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
const accessorMetadata = {};
|
|
353
|
+
Object.defineProperty(accessorMetadata, 'type', {
|
|
354
|
+
get() {
|
|
355
|
+
getterCalls++;
|
|
356
|
+
return 'computer_call_output';
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
const cases = [
|
|
360
|
+
{
|
|
361
|
+
message: messageProxy,
|
|
362
|
+
reason: 'message_proxy',
|
|
363
|
+
path: 'message',
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
message: {
|
|
367
|
+
content: '',
|
|
368
|
+
additional_kwargs: metadataProxy,
|
|
369
|
+
getType: () => 'tool',
|
|
370
|
+
} as unknown as ToolMessage,
|
|
371
|
+
reason: 'metadata_proxy',
|
|
372
|
+
path: 'additional_kwargs',
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
message: {
|
|
376
|
+
content: '',
|
|
377
|
+
additional_kwargs: accessorMetadata,
|
|
378
|
+
getType: () => 'tool',
|
|
379
|
+
} as unknown as ToolMessage,
|
|
380
|
+
reason: 'metadata_accessor',
|
|
381
|
+
path: 'additional_kwargs.type',
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
for (const testCase of cases) {
|
|
386
|
+
try {
|
|
387
|
+
getTokenCountForMessage(testCase.message, (text) => text.length);
|
|
388
|
+
throw new Error('Expected token measurement to fail');
|
|
389
|
+
} catch (error) {
|
|
390
|
+
expect(error).toBeInstanceOf(UnsafeTokenMeasurementError);
|
|
391
|
+
expect(JSON.parse((error as Error).message)).toEqual({
|
|
392
|
+
type: 'unsafe_token_measurement',
|
|
393
|
+
reason: testCase.reason,
|
|
394
|
+
path: testCase.path,
|
|
395
|
+
});
|
|
396
|
+
expect((error as Error).message).not.toContain('secret payload');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
expect(getterCalls).toBe(0);
|
|
400
|
+
});
|
|
401
|
+
|
|
280
402
|
test('detects inherited accessors without invoking them', () => {
|
|
281
403
|
let accessorCalls = 0;
|
|
282
404
|
const prototype = {};
|
|
@@ -292,10 +414,32 @@ describe('getTokenCountForMessage', () => {
|
|
|
292
414
|
expect(hasUnsafeStructuredSerialization(args)).toBe(true);
|
|
293
415
|
expect(
|
|
294
416
|
getTokenCountForMessage(messageWithToolArgs(args), (text) => text.length)
|
|
295
|
-
).
|
|
417
|
+
).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
296
418
|
expect(accessorCalls).toBe(0);
|
|
297
419
|
});
|
|
298
420
|
|
|
421
|
+
test('counts safely serializable proxy tool-call history without invoking getters', () => {
|
|
422
|
+
let getCalls = 0;
|
|
423
|
+
const args = new Proxy(
|
|
424
|
+
{ query: 'select * from reports' },
|
|
425
|
+
{
|
|
426
|
+
get(target, property, receiver) {
|
|
427
|
+
getCalls++;
|
|
428
|
+
return Reflect.get(target, property, receiver);
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const count = getTokenCountForMessage(
|
|
434
|
+
messageWithToolArgs(args),
|
|
435
|
+
(text) => text.length
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
expect(count).toBeGreaterThan(0);
|
|
439
|
+
expect(count).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
440
|
+
expect(getCalls).toBe(0);
|
|
441
|
+
});
|
|
442
|
+
|
|
299
443
|
test('counts tool_calls-only names and arguments', () => {
|
|
300
444
|
const message = new AIMessage({
|
|
301
445
|
content: '',
|
|
@@ -334,7 +478,8 @@ describe('getTokenCountForMessage', () => {
|
|
|
334
478
|
|
|
335
479
|
const count = getTokenCountForMessage(message, (text) => text.length);
|
|
336
480
|
|
|
337
|
-
expect(count).
|
|
481
|
+
expect(count).toBeLessThan(Number.MAX_SAFE_INTEGER);
|
|
482
|
+
expect(count).toBeGreaterThan(0);
|
|
338
483
|
expect(toJSONCalls).toBe(0);
|
|
339
484
|
});
|
|
340
485
|
|
|
@@ -67,6 +67,50 @@ describe('filterSubagentResult', () => {
|
|
|
67
67
|
expect(filterSubagentResult(messages)).toBe('First part.\nSecond part.');
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
it('prefers final text_delta blocks over earlier AI text', () => {
|
|
71
|
+
const messages: BaseMessage[] = [
|
|
72
|
+
new AIMessage({
|
|
73
|
+
content: [
|
|
74
|
+
{ type: 'text', text: 'Let me search.' },
|
|
75
|
+
{ type: 'tool_use', id: 'call_1', name: 'search', input: {} },
|
|
76
|
+
],
|
|
77
|
+
}),
|
|
78
|
+
new ToolMessage({ content: 'result', tool_call_id: 'call_1' }),
|
|
79
|
+
new AIMessage({
|
|
80
|
+
content: [
|
|
81
|
+
{ type: 'text_delta', index: 0, text: 'Streamed ' },
|
|
82
|
+
{ type: 'text_delta', index: 0, text: 'result.' },
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
expect(filterSubagentResult(messages)).toBe('Streamed result.');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps annotation-only text blocks within a text_delta sequence', () => {
|
|
90
|
+
const messages: BaseMessage[] = [
|
|
91
|
+
new AIMessage({
|
|
92
|
+
content: [
|
|
93
|
+
{ type: 'text_delta', index: 0, text: 'Cited ' },
|
|
94
|
+
{ type: 'text', index: 0, citations: [{ url: 'source' }] },
|
|
95
|
+
{ type: 'text_delta', index: 0, text: 'answer.' },
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
];
|
|
99
|
+
expect(filterSubagentResult(messages)).toBe('Cited answer.');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('separates text_delta blocks with different indexes', () => {
|
|
103
|
+
const messages: BaseMessage[] = [
|
|
104
|
+
new AIMessage({
|
|
105
|
+
content: [
|
|
106
|
+
{ type: 'text_delta', index: 0, text: 'First.' },
|
|
107
|
+
{ type: 'text_delta', index: 1, text: 'Second.' },
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
];
|
|
111
|
+
expect(filterSubagentResult(messages)).toBe('First.\nSecond.');
|
|
112
|
+
});
|
|
113
|
+
|
|
70
114
|
it('strips tool_use blocks from array content', () => {
|
|
71
115
|
const messages: BaseMessage[] = [
|
|
72
116
|
new AIMessage({
|
|
@@ -470,7 +514,10 @@ describe('SubagentExecutor', () => {
|
|
|
470
514
|
}) as unknown as StandardGraph,
|
|
471
515
|
});
|
|
472
516
|
await expect(
|
|
473
|
-
executor.execute({
|
|
517
|
+
executor.execute({
|
|
518
|
+
description: 'Do something',
|
|
519
|
+
subagentType: 'researcher',
|
|
520
|
+
})
|
|
474
521
|
).rejects.toBeInstanceOf(StreamLimitExceededError);
|
|
475
522
|
});
|
|
476
523
|
|
|
@@ -27,17 +27,24 @@ import type { AggregatedHookResult, HookRegistry } from '@/hooks';
|
|
|
27
27
|
import type { AgentContext } from '@/agents/AgentContext';
|
|
28
28
|
import type { StandardGraph } from '@/graphs/Graph';
|
|
29
29
|
import type { HandlerRegistry } from '@/events';
|
|
30
|
-
import { Constants, GraphEvents, Callback, StepTypes } from '@/common';
|
|
31
30
|
import {
|
|
32
31
|
StreamLimitExceededError,
|
|
33
32
|
RUN_BREAKER_SCOPE_CONFIG_KEY,
|
|
34
33
|
} from '@/llm/streamLimits';
|
|
34
|
+
import {
|
|
35
|
+
ContentTypes,
|
|
36
|
+
Constants,
|
|
37
|
+
GraphEvents,
|
|
38
|
+
Callback,
|
|
39
|
+
StepTypes,
|
|
40
|
+
} from '@/common';
|
|
35
41
|
import { executeHooks } from '@/hooks';
|
|
36
42
|
|
|
37
43
|
const DEFAULT_MAX_TURNS = 25;
|
|
38
44
|
const RECURSION_MULTIPLIER = 3;
|
|
39
45
|
const ERROR_MESSAGE_MAX_CHARS = 200;
|
|
40
46
|
const MAX_PENDING_SUBAGENT_UPDATES = 64;
|
|
47
|
+
const TEXT_DELTA_CONTENT_TYPE = `${ContentTypes.TEXT}_delta`;
|
|
41
48
|
|
|
42
49
|
const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
|
|
43
50
|
additionalContexts: [] as string[],
|
|
@@ -119,7 +126,10 @@ type SanitizedStepCompleted =
|
|
|
119
126
|
};
|
|
120
127
|
|
|
121
128
|
type SanitizedProcessedToolCall = Partial<
|
|
122
|
-
Pick<
|
|
129
|
+
Pick<
|
|
130
|
+
ProcessedToolCall,
|
|
131
|
+
'args' | 'id' | 'name' | 'output' | 'progress' | 'outcome'
|
|
132
|
+
>
|
|
123
133
|
>;
|
|
124
134
|
|
|
125
135
|
type SanitizedRunStepCompleted = {
|
|
@@ -1401,6 +1411,9 @@ export function summarizeEvent(eventName: string, data: unknown): string {
|
|
|
1401
1411
|
* pure tool_use (e.g. the subagent hit `maxTurns` mid-tool-call), the walk
|
|
1402
1412
|
* continues to earlier AIMessages so partial progress is salvaged — this
|
|
1403
1413
|
* matches Claude Code's behavior in `agentToolUtils.finalizeAgentTool`.
|
|
1414
|
+
* Consecutive streamed text-delta blocks with the same provider index are
|
|
1415
|
+
* coalesced without adding whitespace. Annotation-only text blocks are
|
|
1416
|
+
* ignored; complete text blocks and distinct delta indexes remain separated.
|
|
1404
1417
|
* Returns "Task completed" only when no AIMessage in the history contains
|
|
1405
1418
|
* any text.
|
|
1406
1419
|
*/
|
|
@@ -1422,13 +1435,64 @@ export function filterSubagentResult(messages: BaseMessage[]): string {
|
|
|
1422
1435
|
}
|
|
1423
1436
|
|
|
1424
1437
|
const textParts: string[] = [];
|
|
1438
|
+
let textDeltaParts: string[] = [];
|
|
1439
|
+
let textDeltaIndex: string | number | undefined;
|
|
1440
|
+
const flushTextDeltaParts = (): void => {
|
|
1441
|
+
if (textDeltaParts.length === 0) {
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
textParts.push(textDeltaParts.join(''));
|
|
1445
|
+
textDeltaParts = [];
|
|
1446
|
+
textDeltaIndex = undefined;
|
|
1447
|
+
};
|
|
1425
1448
|
for (const block of content) {
|
|
1426
1449
|
if (typeof block === 'string') {
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1450
|
+
flushTextDeltaParts();
|
|
1451
|
+
if (block !== '') {
|
|
1452
|
+
textParts.push(block);
|
|
1453
|
+
}
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const type =
|
|
1458
|
+
'type' in block && typeof block.type === 'string' ? block.type : '';
|
|
1459
|
+
const isTextDelta = type === TEXT_DELTA_CONTENT_TYPE;
|
|
1460
|
+
const isText = type === ContentTypes.TEXT || isTextDelta;
|
|
1461
|
+
const text =
|
|
1462
|
+
isText && 'text' in block && typeof block.text === 'string'
|
|
1463
|
+
? block.text
|
|
1464
|
+
: '';
|
|
1465
|
+
if (isTextDelta) {
|
|
1466
|
+
if (text === '') {
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
const index =
|
|
1470
|
+
'index' in block &&
|
|
1471
|
+
(typeof block.index === 'string' || typeof block.index === 'number')
|
|
1472
|
+
? block.index
|
|
1473
|
+
: undefined;
|
|
1474
|
+
if (
|
|
1475
|
+
textDeltaIndex != null &&
|
|
1476
|
+
index != null &&
|
|
1477
|
+
index !== textDeltaIndex
|
|
1478
|
+
) {
|
|
1479
|
+
flushTextDeltaParts();
|
|
1480
|
+
}
|
|
1481
|
+
textDeltaIndex ??= index;
|
|
1482
|
+
textDeltaParts.push(text);
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (type === ContentTypes.TEXT && text === '') {
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
flushTextDeltaParts();
|
|
1491
|
+
if (text !== '') {
|
|
1492
|
+
textParts.push(text);
|
|
1430
1493
|
}
|
|
1431
1494
|
}
|
|
1495
|
+
flushTextDeltaParts();
|
|
1432
1496
|
|
|
1433
1497
|
if (textParts.length > 0) {
|
|
1434
1498
|
return textParts.join('\n');
|