@librechat/agents 3.3.8 → 3.3.9

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.
Files changed (148) hide show
  1. package/dist/cjs/graphs/Graph.cjs +47 -13
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +35 -2
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  6. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  7. package/dist/cjs/instrumentation.cjs +18 -48
  8. package/dist/cjs/instrumentation.cjs.map +1 -1
  9. package/dist/cjs/langfuse.cjs +174 -29
  10. package/dist/cjs/langfuse.cjs.map +1 -1
  11. package/dist/cjs/langfuseConfig.cjs +12 -0
  12. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  13. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  14. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  16. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  17. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  18. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  19. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  20. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  21. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  22. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  24. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/init.cjs +3 -3
  26. package/dist/cjs/llm/invoke.cjs +5 -5
  27. package/dist/cjs/llm/openai/index.cjs +1 -1
  28. package/dist/cjs/main.cjs +10 -10
  29. package/dist/cjs/messages/prune.cjs +13 -1
  30. package/dist/cjs/messages/prune.cjs.map +1 -1
  31. package/dist/cjs/prompts/activityLabel.cjs +24 -12
  32. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  33. package/dist/cjs/run.cjs +47 -22
  34. package/dist/cjs/run.cjs.map +1 -1
  35. package/dist/cjs/session/messageSerialization.cjs +6 -0
  36. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  37. package/dist/cjs/stream.cjs +21 -10
  38. package/dist/cjs/stream.cjs.map +1 -1
  39. package/dist/cjs/summarization/node.cjs +5 -0
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/ToolNode.cjs +253 -24
  42. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  43. package/dist/cjs/tools/handlers.cjs +1 -1
  44. package/dist/cjs/tools/search/tool.cjs +1 -1
  45. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  46. package/dist/cjs/utils/index.cjs +2 -2
  47. package/dist/esm/graphs/Graph.mjs +48 -14
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/graphs/MultiAgentGraph.mjs +35 -2
  50. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  51. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  52. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  53. package/dist/esm/instrumentation.mjs +18 -48
  54. package/dist/esm/instrumentation.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +176 -28
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/langfuseConfig.mjs +10 -1
  58. package/dist/esm/langfuseConfig.mjs.map +1 -1
  59. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  60. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  61. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  62. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  63. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  64. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  65. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  66. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  67. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  68. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  69. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  70. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +2 -2
  72. package/dist/esm/llm/invoke.mjs +5 -5
  73. package/dist/esm/llm/openai/index.mjs +1 -1
  74. package/dist/esm/main.mjs +8 -8
  75. package/dist/esm/messages/prune.mjs +13 -1
  76. package/dist/esm/messages/prune.mjs.map +1 -1
  77. package/dist/esm/prompts/activityLabel.mjs +24 -12
  78. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  79. package/dist/esm/run.mjs +47 -22
  80. package/dist/esm/run.mjs.map +1 -1
  81. package/dist/esm/session/messageSerialization.mjs +6 -0
  82. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  83. package/dist/esm/stream.mjs +21 -10
  84. package/dist/esm/stream.mjs.map +1 -1
  85. package/dist/esm/summarization/node.mjs +5 -0
  86. package/dist/esm/summarization/node.mjs.map +1 -1
  87. package/dist/esm/tools/ToolNode.mjs +254 -25
  88. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  89. package/dist/esm/tools/handlers.mjs +1 -1
  90. package/dist/esm/tools/search/tool.mjs +1 -1
  91. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  92. package/dist/esm/utils/index.mjs +2 -2
  93. package/dist/types/graphs/Graph.d.ts +19 -0
  94. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  95. package/dist/types/langfuse.d.ts +16 -8
  96. package/dist/types/langfuseConfig.d.ts +6 -0
  97. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  98. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  99. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  100. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  101. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  102. package/dist/types/session/types.d.ts +1 -0
  103. package/dist/types/tools/ToolNode.d.ts +7 -1
  104. package/dist/types/types/hitl.d.ts +8 -0
  105. package/dist/types/types/tools.d.ts +30 -0
  106. package/package.json +7 -4
  107. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  108. package/src/graphs/Graph.ts +69 -20
  109. package/src/graphs/MultiAgentGraph.ts +56 -2
  110. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  111. package/src/hitl/askUserQuestion.ts +14 -1
  112. package/src/instrumentation.ts +35 -77
  113. package/src/langfuse.ts +320 -43
  114. package/src/langfuseConfig.ts +24 -0
  115. package/src/langfuseRuntimeContext.ts +43 -1
  116. package/src/langfuseRuntimeScope.ts +94 -21
  117. package/src/langfuseSpanRegistry.ts +131 -0
  118. package/src/langfuseTraceShaping.ts +194 -7
  119. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  120. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  121. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  122. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  123. package/src/messages/prune.ts +12 -1
  124. package/src/prompts/activityLabel.ts +23 -6
  125. package/src/run.ts +76 -45
  126. package/src/scripts/activity-labels/captured.json +56 -0
  127. package/src/scripts/activity-labels/checks.cjs +205 -0
  128. package/src/scripts/activity-labels/corpus.cjs +473 -0
  129. package/src/scripts/activity-labels/report.cjs +203 -0
  130. package/src/scripts/activity-labels/rescore.cjs +102 -0
  131. package/src/scripts/activity-labels/run.ts +705 -0
  132. package/src/scripts/activity-labels/variants.ts +71 -0
  133. package/src/session/messageSerialization.ts +12 -1
  134. package/src/session/types.ts +1 -0
  135. package/src/specs/activity-label-prompt.test.ts +26 -10
  136. package/src/specs/agent-handoffs.test.ts +306 -0
  137. package/src/specs/langfuse-callbacks.test.ts +456 -0
  138. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  139. package/src/specs/langfuse-span-registry.test.ts +70 -0
  140. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  141. package/src/specs/prune.test.ts +38 -1
  142. package/src/stream.ts +70 -6
  143. package/src/summarization/node.ts +5 -0
  144. package/src/tools/ToolNode.ts +400 -9
  145. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  146. package/src/tools/__tests__/hitl.test.ts +58 -0
  147. package/src/types/hitl.ts +8 -0
  148. package/src/types/tools.ts +35 -1
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Regression tests for danny-avila/LibreChat#14371: eager tool execution
3
+ * looped to the recursion limit for tools with large repetitive arguments.
4
+ *
5
+ * The eager prestart accumulator reconciles provider quirks with lossy
6
+ * heuristics (repeat-fragment dedupe, overlap merge) that can swallow
7
+ * legitimately repetitive payload fragments (SQL/code). The prestarted args
8
+ * then diverged from the canonical LangChain `tool_call_chunks` accumulation
9
+ * that materializes the final request, ToolNode's guard errored with "Tool
10
+ * call changed after eager execution started", and the model's retry
11
+ * re-prestarted and re-diverged — burning the whole recursion limit.
12
+ *
13
+ * Fixed two ways:
14
+ * 1. Seal-time verification: prestart only when the heuristic accumulation is
15
+ * confirmed to match the canonical verbatim concatenation
16
+ * (`getStreamedReadyToolCalls` in src/stream.ts).
17
+ * 2. Circuit breaker: if the "changed after eager execution" guard still
18
+ * fires, the tool name is suppressed from eager prestart for the rest of
19
+ * the run, so a retry executes normally and the loop is structurally
20
+ * impossible (ToolNode.takeMatchingEagerEventExecution +
21
+ * isEagerExecutionExcludedTool).
22
+ */
23
+ import { z } from 'zod';
24
+ import { tool } from '@langchain/core/tools';
25
+ import { describe, it, expect, jest, afterEach } from '@jest/globals';
26
+ import {
27
+ AIMessage,
28
+ AIMessageChunk,
29
+ ToolMessage,
30
+ } from '@langchain/core/messages';
31
+ import type { StructuredToolInterface } from '@langchain/core/tools';
32
+ import type { AgentContext } from '@/agents/AgentContext';
33
+ import type { StandardGraph } from '@/graphs';
34
+ import type * as t from '@/types';
35
+ import {
36
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
37
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
38
+ BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
39
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
40
+ } from '@/tools/streamedToolCallSeals';
41
+ import { GraphEvents, Providers, StepTypes } from '@/common';
42
+ import { ChatModelStreamHandler } from '@/stream';
43
+ import { ToolNode } from '@/tools/ToolNode';
44
+ import { HandlerRegistry } from '@/events';
45
+ import * as events from '@/utils/events';
46
+
47
+ function createGraph(overrides: Partial<StandardGraph> = {}): StandardGraph {
48
+ const runSteps = new Map<string, t.RunStep>();
49
+ const stepIdsByKey = new Map<string, string>();
50
+ let stepCounter = 0;
51
+ const handlerRegistry = new HandlerRegistry();
52
+ handlerRegistry.register(GraphEvents.ON_TOOL_EXECUTE, {
53
+ handle: async () => undefined,
54
+ });
55
+ const eagerUsageCount = new Map<string, number>();
56
+
57
+ const graph = {
58
+ config: {
59
+ configurable: { user_id: 'user_1' },
60
+ metadata: { run_id: 'run_1' },
61
+ },
62
+ eagerEventToolExecution: { enabled: true },
63
+ eagerEventToolExecutions: new Map(),
64
+ eagerEventToolUsageCount: eagerUsageCount,
65
+ getEagerEventToolUsageCount: jest.fn(() => eagerUsageCount),
66
+ eagerEventToolCallChunks: new Map(),
67
+ eagerEventToolSuppressions: new Set<string>(),
68
+ handlerRegistry,
69
+ hookRegistry: undefined,
70
+ humanInTheLoop: undefined,
71
+ toolOutputReferences: undefined,
72
+ sessions: new Map(),
73
+ toolCallStepIds: new Map(),
74
+ messageIdsByStepKey: new Map(),
75
+ messageStepHasToolCalls: new Map(),
76
+ prelimMessageIdsByStepKey: new Map(),
77
+ getAgentContext: jest.fn(
78
+ (): Partial<AgentContext> => ({
79
+ provider: Providers.ANTHROPIC,
80
+ reasoningKey: 'reasoning',
81
+ toolDefinitions: [{ name: 'db_query' }, { name: 'stock' }],
82
+ graphTools: [],
83
+ agentId: 'agent_1',
84
+ })
85
+ ),
86
+ getStepKey: jest.fn(() => 'step-key'),
87
+ getStepIdByKey: jest.fn((stepKey: string) => {
88
+ const stepId = stepIdsByKey.get(stepKey);
89
+ if (stepId == null) {
90
+ throw new Error('no current step');
91
+ }
92
+ return stepId;
93
+ }),
94
+ getRunStep: jest.fn((stepId: string) => runSteps.get(stepId)),
95
+ dispatchRunStep: jest.fn(async (stepKey: string, details: unknown) => {
96
+ const id = `step_${++stepCounter}`;
97
+ if (
98
+ (details as t.StepDetails).type === StepTypes.TOOL_CALLS &&
99
+ Array.isArray((details as t.ToolCallsDetails).tool_calls)
100
+ ) {
101
+ for (const toolCall of (details as t.ToolCallsDetails).tool_calls ??
102
+ []) {
103
+ if (toolCall.id != null && toolCall.id !== '') {
104
+ graph.toolCallStepIds.set(toolCall.id, id);
105
+ }
106
+ }
107
+ }
108
+ stepIdsByKey.set(stepKey, id);
109
+ runSteps.set(id, {
110
+ id,
111
+ type: (details as { type: t.RunStep['type'] }).type,
112
+ stepDetails: details as t.RunStep['stepDetails'],
113
+ } as t.RunStep);
114
+ return id;
115
+ }),
116
+ dispatchRunStepDelta: jest.fn(async () => undefined),
117
+ ...overrides,
118
+ };
119
+
120
+ return graph as unknown as StandardGraph;
121
+ }
122
+
123
+ function createDummyTool(name: string): StructuredToolInterface {
124
+ return tool(async () => 'direct should not run', {
125
+ name,
126
+ description: 'dummy',
127
+ schema: z.object({ sql: z.string() }),
128
+ }) as unknown as StructuredToolInterface;
129
+ }
130
+
131
+ function installToolExecuteResponder(): {
132
+ toolExecuteCalls: t.ToolExecuteBatchRequest[];
133
+ } {
134
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
135
+ jest
136
+ .spyOn(events, 'safeDispatchCustomEvent')
137
+ .mockImplementation(async (event, data): Promise<void> => {
138
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
139
+ return;
140
+ }
141
+ const batch = data as t.ToolExecuteBatchRequest;
142
+ toolExecuteCalls.push(batch);
143
+ batch.resolve(
144
+ batch.toolCalls.map((call) => ({
145
+ toolCallId: call.id,
146
+ status: 'success',
147
+ content: `ok ${call.name}`,
148
+ }))
149
+ );
150
+ });
151
+ return { toolExecuteCalls };
152
+ }
153
+
154
+ /**
155
+ * A realistic Anthropic input_json_delta fragment sequence for a repetitive
156
+ * payload: the model legitimately writes the same SQL statement three times
157
+ * and the provider splits deltas on statement boundaries. The 2nd occurrence
158
+ * collides with the overlap-merge heuristic (the accumulator ends with an
159
+ * >=8-char prefix of it) and the 3rd with the repeat-fragment dedupe.
160
+ */
161
+ const STATEMENT = 'INSERT INTO t VALUES (1);';
162
+ const REPETITIVE_FRAGMENTS = [
163
+ `{"sql":"${STATEMENT}`,
164
+ STATEMENT,
165
+ STATEMENT,
166
+ '"}',
167
+ ];
168
+ const CANONICAL_SQL = `${STATEMENT}${STATEMENT}${STATEMENT}`;
169
+
170
+ function toToolCallChunks(
171
+ callId: string,
172
+ name: string,
173
+ fragments: string[]
174
+ ): Array<Record<string, unknown>> {
175
+ return fragments.map((args, i) =>
176
+ i === 0 ? { id: callId, name, args, index: 0 } : { args, index: 0 }
177
+ );
178
+ }
179
+
180
+ async function streamChunks(args: {
181
+ handler: ChatModelStreamHandler;
182
+ graph: StandardGraph;
183
+ metadata: Record<string, unknown>;
184
+ toolCallChunks: Array<Record<string, unknown>>;
185
+ }): Promise<void> {
186
+ const { handler, graph, metadata, toolCallChunks } = args;
187
+ for (const toolCallChunk of toolCallChunks) {
188
+ await handler.handle(
189
+ GraphEvents.CHAT_MODEL_STREAM,
190
+ {
191
+ chunk: {
192
+ content: '',
193
+ tool_call_chunks: [toolCallChunk],
194
+ } as unknown as t.StreamChunk,
195
+ },
196
+ metadata,
197
+ graph
198
+ );
199
+ }
200
+ }
201
+
202
+ /** Seal index 0 the way Anthropic does: the next tool-use block begins. */
203
+ async function streamNextToolIndex(args: {
204
+ handler: ChatModelStreamHandler;
205
+ graph: StandardGraph;
206
+ metadata: Record<string, unknown>;
207
+ callId: string;
208
+ }): Promise<void> {
209
+ const { handler, graph, metadata, callId } = args;
210
+ await handler.handle(
211
+ GraphEvents.CHAT_MODEL_STREAM,
212
+ {
213
+ chunk: {
214
+ content: '',
215
+ tool_call_chunks: [
216
+ { id: callId, name: 'stock', args: '{"ticker":"C', index: 1 },
217
+ ],
218
+ } as unknown as t.StreamChunk,
219
+ },
220
+ metadata,
221
+ graph
222
+ );
223
+ }
224
+
225
+ /**
226
+ * The canonical accumulation LangChain performs in the model node: concat all
227
+ * AIMessageChunks; the final message's tool_calls carry the args ToolNode
228
+ * receives as the request.
229
+ */
230
+ function canonicalToolCall(
231
+ callId: string,
232
+ name: string,
233
+ fragments: string[]
234
+ ): { id: string; name: string; args: Record<string, unknown> } {
235
+ let accumulated: AIMessageChunk | undefined;
236
+ for (const toolCallChunk of toToolCallChunks(callId, name, fragments)) {
237
+ const chunk = new AIMessageChunk({
238
+ content: '',
239
+ tool_call_chunks: [
240
+ { ...toolCallChunk, type: 'tool_call_chunk' },
241
+ ] as AIMessageChunk['tool_call_chunks'],
242
+ });
243
+ accumulated = accumulated == null ? chunk : accumulated.concat(chunk);
244
+ }
245
+ const toolCall = accumulated?.tool_calls?.[0];
246
+ if (toolCall?.id == null) {
247
+ throw new Error('canonical accumulation produced no tool call');
248
+ }
249
+ return {
250
+ id: toolCall.id,
251
+ name: toolCall.name,
252
+ args: toolCall.args as Record<string, unknown>,
253
+ };
254
+ }
255
+
256
+ describe('eager args divergence (LibreChat#14371)', () => {
257
+ afterEach(() => {
258
+ jest.restoreAllMocks();
259
+ });
260
+
261
+ it('sanity: the canonical LangChain concat preserves every repeated fragment', () => {
262
+ const canonical = canonicalToolCall(
263
+ 'call_1',
264
+ 'db_query',
265
+ REPETITIVE_FRAGMENTS
266
+ );
267
+ expect(canonical.args).toEqual({ sql: CANONICAL_SQL });
268
+ });
269
+
270
+ it('does not prestart when overlap merge + repeat dedupe diverge from the canonical accumulation', async () => {
271
+ const graph = createGraph();
272
+ const { toolExecuteCalls } = installToolExecuteResponder();
273
+ const handler = new ChatModelStreamHandler();
274
+ const metadata = { langgraph_node: 'agent' };
275
+
276
+ await streamChunks({
277
+ handler,
278
+ graph,
279
+ metadata,
280
+ toolCallChunks: toToolCallChunks(
281
+ 'call_1',
282
+ 'db_query',
283
+ REPETITIVE_FRAGMENTS
284
+ ),
285
+ });
286
+ await streamNextToolIndex({ handler, graph, metadata, callId: 'call_2' });
287
+
288
+ // Pre-fix: the seal prestarted `{"sql":"<one statement>"}` here — args
289
+ // the model never asked for — and the run then looped on the "changed
290
+ // after eager execution started" guard. Now the unconfirmed snapshot is
291
+ // skipped and the call falls through to normal execution.
292
+ expect(toolExecuteCalls).toHaveLength(0);
293
+ expect(graph.eagerEventToolExecutions.has('call_1')).toBe(false);
294
+ });
295
+
296
+ it('does not prestart when the overlap heuristic alone swallows legitimate payload', async () => {
297
+ // existing='{"code":"aaaaaaaaaa' ends with incoming.slice(0, 10), so the
298
+ // merge drops 10 legitimate chars; canonical has 20 a's.
299
+ const fragments = ['{"code":"aaaaaaaaaa', 'aaaaaaaaaab"}'];
300
+ const graph = createGraph();
301
+ const { toolExecuteCalls } = installToolExecuteResponder();
302
+ const handler = new ChatModelStreamHandler();
303
+ const metadata = { langgraph_node: 'agent' };
304
+
305
+ await streamChunks({
306
+ handler,
307
+ graph,
308
+ metadata,
309
+ toolCallChunks: toToolCallChunks('call_1', 'db_query', fragments),
310
+ });
311
+ await streamNextToolIndex({ handler, graph, metadata, callId: 'call_2' });
312
+
313
+ expect(toolExecuteCalls).toHaveLength(0);
314
+ expect(graph.eagerEventToolExecutions.has('call_1')).toBe(false);
315
+ });
316
+
317
+ it('does not prestart when the repeat-fragment dedupe alone drops legitimate payload', async () => {
318
+ // 'AB' fragments are too short for the overlap merge (< 8 chars), so only
319
+ // isRepeatedObservedFragment fires — the 3rd fragment is dropped.
320
+ const fragments = ['{"sql":"AB', 'AB', 'AB', '"}'];
321
+ const graph = createGraph();
322
+ const { toolExecuteCalls } = installToolExecuteResponder();
323
+ const handler = new ChatModelStreamHandler();
324
+ const metadata = { langgraph_node: 'agent' };
325
+
326
+ await streamChunks({
327
+ handler,
328
+ graph,
329
+ metadata,
330
+ toolCallChunks: toToolCallChunks('call_1', 'db_query', fragments),
331
+ });
332
+ await streamNextToolIndex({ handler, graph, metadata, callId: 'call_2' });
333
+
334
+ expect(toolExecuteCalls).toHaveLength(0);
335
+ expect(graph.eagerEventToolExecutions.has('call_1')).toBe(false);
336
+ });
337
+
338
+ it('does not treat a pure-signal adapter seal as an args restatement (Bedrock)', async () => {
339
+ // Bedrock's contentBlockStop seal chunk carries `args: ''` — a pure
340
+ // signal, not a restatement. A repeated complete-JSON fragment leaves
341
+ // the heuristic accumulator with lastArgsFragment === argsText while the
342
+ // canonical concatenation differs; the seal must NOT bless that state as
343
+ // authoritative (Codex P1 on #368).
344
+ const bedrockMetadata = {
345
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
346
+ BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
347
+ };
348
+ const graph = createGraph({
349
+ getAgentContext: jest.fn(
350
+ (): Partial<AgentContext> => ({
351
+ provider: Providers.BEDROCK,
352
+ reasoningKey: 'reasoning_content',
353
+ toolDefinitions: [{ name: 'db_query' }],
354
+ graphTools: [],
355
+ agentId: 'agent_1',
356
+ })
357
+ ) as unknown as StandardGraph['getAgentContext'],
358
+ });
359
+ const { toolExecuteCalls } = installToolExecuteResponder();
360
+ const handler = new ChatModelStreamHandler();
361
+ const metadata = { langgraph_node: 'agent' };
362
+
363
+ const fragment = '{"sql":"SELECT 1;"}';
364
+ for (const toolCallChunk of [
365
+ { id: 'call_1', name: 'db_query', args: fragment, index: 0 },
366
+ { args: fragment, index: 0 },
367
+ ]) {
368
+ await handler.handle(
369
+ GraphEvents.CHAT_MODEL_STREAM,
370
+ {
371
+ chunk: {
372
+ content: '',
373
+ tool_call_chunks: [toolCallChunk],
374
+ response_metadata: bedrockMetadata,
375
+ } as unknown as t.StreamChunk,
376
+ },
377
+ metadata,
378
+ graph
379
+ );
380
+ }
381
+ await handler.handle(
382
+ GraphEvents.CHAT_MODEL_STREAM,
383
+ {
384
+ chunk: {
385
+ content: '',
386
+ tool_call_chunks: [{ args: '', index: 0 }],
387
+ response_metadata: {
388
+ ...bedrockMetadata,
389
+ [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: {
390
+ kind: 'single',
391
+ index: 0,
392
+ },
393
+ },
394
+ } as unknown as t.StreamChunk,
395
+ },
396
+ metadata,
397
+ graph
398
+ );
399
+
400
+ expect(toolExecuteCalls).toHaveLength(0);
401
+ expect(graph.eagerEventToolExecutions.has('call_1')).toBe(false);
402
+ });
403
+
404
+ it('still prestarts adapter-sealed calls whose seal chunk restates the args (OpenAI Responses contract)', async () => {
405
+ const graph = createGraph({
406
+ getAgentContext: jest.fn(
407
+ (): Partial<AgentContext> => ({
408
+ provider: Providers.OPENAI,
409
+ reasoningKey: 'reasoning_content',
410
+ toolDefinitions: [{ name: 'db_query' }],
411
+ graphTools: [],
412
+ agentId: 'agent_1',
413
+ })
414
+ ) as unknown as StandardGraph['getAgentContext'],
415
+ });
416
+ const { toolExecuteCalls } = installToolExecuteResponder();
417
+ const handler = new ChatModelStreamHandler();
418
+ const metadata = { langgraph_node: 'agent' };
419
+ const adapterMetadata = {
420
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
421
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
422
+ };
423
+
424
+ await handler.handle(
425
+ GraphEvents.CHAT_MODEL_STREAM,
426
+ {
427
+ chunk: {
428
+ content: '',
429
+ tool_call_chunks: [
430
+ { id: 'call_1', name: 'db_query', args: '{"sql":"SELE', index: 0 },
431
+ ],
432
+ response_metadata: adapterMetadata,
433
+ } as unknown as t.StreamChunk,
434
+ },
435
+ metadata,
436
+ graph
437
+ );
438
+ // The `arguments.done` seal chunk restates the complete args.
439
+ await handler.handle(
440
+ GraphEvents.CHAT_MODEL_STREAM,
441
+ {
442
+ chunk: {
443
+ content: '',
444
+ tool_call_chunks: [
445
+ { id: 'call_1', args: '{"sql":"SELECT 1;"}', index: 0 },
446
+ ],
447
+ response_metadata: {
448
+ ...adapterMetadata,
449
+ [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: {
450
+ kind: 'single',
451
+ id: 'call_1',
452
+ index: 0,
453
+ },
454
+ },
455
+ } as unknown as t.StreamChunk,
456
+ },
457
+ metadata,
458
+ graph
459
+ );
460
+
461
+ expect(toolExecuteCalls).toHaveLength(1);
462
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
463
+ id: 'call_1',
464
+ name: 'db_query',
465
+ args: { sql: 'SELECT 1;' },
466
+ });
467
+ });
468
+
469
+ it('still prestarts sealed calls whose fragments accumulate cleanly', async () => {
470
+ const fragments = ['{"sql":"SELECT ', '1;', '"}'];
471
+ const graph = createGraph();
472
+ const { toolExecuteCalls } = installToolExecuteResponder();
473
+ const handler = new ChatModelStreamHandler();
474
+ const metadata = { langgraph_node: 'agent' };
475
+
476
+ await streamChunks({
477
+ handler,
478
+ graph,
479
+ metadata,
480
+ toolCallChunks: toToolCallChunks('call_1', 'db_query', fragments),
481
+ });
482
+ await streamNextToolIndex({ handler, graph, metadata, callId: 'call_2' });
483
+
484
+ expect(toolExecuteCalls).toHaveLength(1);
485
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
486
+ id: 'call_1',
487
+ name: 'db_query',
488
+ args: { sql: 'SELECT 1;' },
489
+ });
490
+ });
491
+
492
+ it('the retry path no longer loops: every round executes normally with canonical args', async () => {
493
+ const metadata = { langgraph_node: 'agent' };
494
+ const guardErrors: string[] = [];
495
+ const normalExecutions: Array<Record<string, unknown>> = [];
496
+
497
+ // Simulate the agent loop that previously burned the recursion limit:
498
+ // each round the model streams the identical repetitive tool call and
499
+ // ToolNode materializes the canonical request.
500
+ for (let round = 0; round < 3; round += 1) {
501
+ const graph = createGraph();
502
+ const { toolExecuteCalls } = installToolExecuteResponder();
503
+ const handler = new ChatModelStreamHandler();
504
+ const callId = `call_round_${round}`;
505
+ await streamChunks({
506
+ handler,
507
+ graph,
508
+ metadata,
509
+ toolCallChunks: toToolCallChunks(
510
+ callId,
511
+ 'db_query',
512
+ REPETITIVE_FRAGMENTS
513
+ ),
514
+ });
515
+ await streamNextToolIndex({
516
+ handler,
517
+ graph,
518
+ metadata,
519
+ callId: `${callId}_next`,
520
+ });
521
+
522
+ const canonical = canonicalToolCall(
523
+ callId,
524
+ 'db_query',
525
+ REPETITIVE_FRAGMENTS
526
+ );
527
+ const toolNode = new ToolNode({
528
+ tools: [createDummyTool('db_query')],
529
+ eventDrivenMode: true,
530
+ eagerEventToolExecution: { enabled: true },
531
+ eagerEventToolExecutions: graph.eagerEventToolExecutions,
532
+ eagerEventToolUsageCount: graph.getEagerEventToolUsageCount(),
533
+ eagerEventToolSuppressions: graph.eagerEventToolSuppressions,
534
+ toolCallStepIds: graph.toolCallStepIds,
535
+ });
536
+ const result = (await toolNode.invoke({
537
+ messages: [new AIMessage({ content: '', tool_calls: [canonical] })],
538
+ })) as { messages: ToolMessage[] };
539
+
540
+ const toolMessage = result.messages.find(
541
+ (message) => message.tool_call_id === callId
542
+ );
543
+ if (
544
+ typeof toolMessage?.content === 'string' &&
545
+ toolMessage.content.includes('changed after eager execution')
546
+ ) {
547
+ guardErrors.push(toolMessage.content);
548
+ }
549
+ const dispatched = toolExecuteCalls
550
+ .flatMap((batch) => batch.toolCalls)
551
+ .find((call) => call.id === callId);
552
+ if (dispatched != null) {
553
+ normalExecutions.push(dispatched.args);
554
+ }
555
+ jest.restoreAllMocks();
556
+ }
557
+
558
+ // Pre-fix: 3/3 rounds errored with the guard and nothing ever executed
559
+ // with the args the model requested.
560
+ expect(guardErrors).toHaveLength(0);
561
+ expect(normalExecutions).toHaveLength(3);
562
+ for (const args of normalExecutions) {
563
+ expect(args).toEqual({ sql: CANONICAL_SQL });
564
+ }
565
+ });
566
+
567
+ describe('circuit breaker', () => {
568
+ it('suppresses eager prestart for a tool after the changed-args guard fires', async () => {
569
+ jest.spyOn(console, 'warn').mockImplementation(() => undefined);
570
+ const { toolExecuteCalls } = installToolExecuteResponder();
571
+ const suppressions = new Set<string>();
572
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
573
+ const request: t.ToolCallRequest = {
574
+ id: 'call_1',
575
+ name: 'db_query',
576
+ args: { sql: 'diverged' },
577
+ stepId: 'step_1',
578
+ turn: 0,
579
+ };
580
+ eagerExecutions.set('call_1', {
581
+ toolCallId: 'call_1',
582
+ toolName: 'db_query',
583
+ args: { sql: 'diverged' },
584
+ request,
585
+ promise: Promise.resolve({
586
+ results: [
587
+ { toolCallId: 'call_1', status: 'success', content: 'eager' },
588
+ ],
589
+ }),
590
+ });
591
+
592
+ const toolNode = new ToolNode({
593
+ tools: [createDummyTool('db_query')],
594
+ eventDrivenMode: true,
595
+ eagerEventToolExecution: { enabled: true },
596
+ eagerEventToolExecutions: eagerExecutions,
597
+ eagerEventToolSuppressions: suppressions,
598
+ toolCallStepIds: new Map([['call_1', 'step_1']]),
599
+ });
600
+ const result = (await toolNode.invoke({
601
+ messages: [
602
+ new AIMessage({
603
+ content: '',
604
+ tool_calls: [
605
+ { id: 'call_1', name: 'db_query', args: { sql: CANONICAL_SQL } },
606
+ ],
607
+ }),
608
+ ],
609
+ })) as { messages: ToolMessage[] };
610
+
611
+ expect(result.messages[0].content).toContain(
612
+ 'changed after eager execution'
613
+ );
614
+ expect(suppressions.has('db_query')).toBe(true);
615
+ expect(toolExecuteCalls).toHaveLength(0);
616
+ });
617
+
618
+ it('suppresses the eagerly executed name too when the identity mismatches', async () => {
619
+ // If the stream prestarts name A but the final request materializes as
620
+ // name B for the same call id, suppressing only B would let every
621
+ // retry prestart A again — repeating A's side effects while the run
622
+ // loops (Codex P2 on #368).
623
+ jest.spyOn(console, 'warn').mockImplementation(() => undefined);
624
+ installToolExecuteResponder();
625
+ const suppressions = new Set<string>();
626
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
627
+ const request: t.ToolCallRequest = {
628
+ id: 'call_1',
629
+ name: 'tool_a',
630
+ args: { sql: 'SELECT 1;' },
631
+ stepId: 'step_1',
632
+ turn: 0,
633
+ };
634
+ eagerExecutions.set('call_1', {
635
+ toolCallId: 'call_1',
636
+ toolName: 'tool_a',
637
+ args: { sql: 'SELECT 1;' },
638
+ request,
639
+ promise: Promise.resolve({
640
+ results: [
641
+ { toolCallId: 'call_1', status: 'success', content: 'eager' },
642
+ ],
643
+ }),
644
+ });
645
+
646
+ const toolNode = new ToolNode({
647
+ tools: [createDummyTool('tool_a'), createDummyTool('tool_b')],
648
+ eventDrivenMode: true,
649
+ eagerEventToolExecution: { enabled: true },
650
+ eagerEventToolExecutions: eagerExecutions,
651
+ eagerEventToolSuppressions: suppressions,
652
+ toolCallStepIds: new Map([['call_1', 'step_1']]),
653
+ });
654
+ const result = (await toolNode.invoke({
655
+ messages: [
656
+ new AIMessage({
657
+ content: '',
658
+ tool_calls: [
659
+ { id: 'call_1', name: 'tool_b', args: { sql: 'SELECT 1;' } },
660
+ ],
661
+ }),
662
+ ],
663
+ })) as { messages: ToolMessage[] };
664
+
665
+ expect(result.messages[0].content).toContain(
666
+ 'changed after eager execution'
667
+ );
668
+ expect(suppressions.has('tool_b')).toBe(true);
669
+ expect(suppressions.has('tool_a')).toBe(true);
670
+ });
671
+
672
+ it('stops prestarting a suppressed tool while siblings still prestart', async () => {
673
+ const graph = createGraph();
674
+ (graph.eagerEventToolSuppressions as Set<string>).add('db_query');
675
+ const { toolExecuteCalls } = installToolExecuteResponder();
676
+ const handler = new ChatModelStreamHandler();
677
+ const metadata = { langgraph_node: 'agent' };
678
+
679
+ // A clean, confirmable db_query stream: without the suppression this
680
+ // would prestart (see "still prestarts sealed calls" above).
681
+ await streamChunks({
682
+ handler,
683
+ graph,
684
+ metadata,
685
+ toolCallChunks: toToolCallChunks('call_1', 'db_query', [
686
+ '{"sql":"SELECT 1;"}',
687
+ ]),
688
+ });
689
+ await streamNextToolIndex({ handler, graph, metadata, callId: 'call_2' });
690
+
691
+ expect(toolExecuteCalls).toHaveLength(0);
692
+ expect(graph.eagerEventToolExecutions.has('call_1')).toBe(false);
693
+
694
+ // The sibling tool (index 1) is not suppressed: sealing it via the
695
+ // final tool-call signal still prestarts it.
696
+ await handler.handle(
697
+ GraphEvents.CHAT_MODEL_STREAM,
698
+ {
699
+ chunk: {
700
+ content: '',
701
+ tool_call_chunks: [{ args: 'H"}', index: 1 }],
702
+ response_metadata: { finish_reason: 'tool_calls' },
703
+ } as unknown as t.StreamChunk,
704
+ },
705
+ metadata,
706
+ graph
707
+ );
708
+
709
+ expect(toolExecuteCalls).toHaveLength(1);
710
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
711
+ id: 'call_2',
712
+ name: 'stock',
713
+ args: { ticker: 'CH' },
714
+ });
715
+ });
716
+
717
+ it('lets the retry execute normally after suppression', async () => {
718
+ const { toolExecuteCalls } = installToolExecuteResponder();
719
+ const suppressions = new Set<string>(['db_query']);
720
+ const toolNode = new ToolNode({
721
+ tools: [createDummyTool('db_query')],
722
+ eventDrivenMode: true,
723
+ eagerEventToolExecution: { enabled: true },
724
+ eagerEventToolExecutions: new Map(),
725
+ eagerEventToolSuppressions: suppressions,
726
+ toolCallStepIds: new Map([['call_retry', 'step_1']]),
727
+ });
728
+
729
+ const result = (await toolNode.invoke({
730
+ messages: [
731
+ new AIMessage({
732
+ content: '',
733
+ tool_calls: [
734
+ {
735
+ id: 'call_retry',
736
+ name: 'db_query',
737
+ args: { sql: CANONICAL_SQL },
738
+ },
739
+ ],
740
+ }),
741
+ ],
742
+ })) as { messages: ToolMessage[] };
743
+
744
+ expect(result.messages[0].content).toBe('ok db_query');
745
+ expect(toolExecuteCalls).toHaveLength(1);
746
+ expect(toolExecuteCalls[0].toolCalls[0]).toMatchObject({
747
+ id: 'call_retry',
748
+ name: 'db_query',
749
+ args: { sql: CANONICAL_SQL },
750
+ });
751
+ });
752
+ });
753
+ });