@librechat/agents 3.3.8 → 3.3.10
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/agents/AgentContext.cjs +4 -0
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +66 -13
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +35 -2
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
- package/dist/cjs/instrumentation.cjs +18 -48
- package/dist/cjs/instrumentation.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +174 -29
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/langfuseConfig.cjs +12 -0
- package/dist/cjs/langfuseConfig.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
- package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
- package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
- package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
- package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
- package/dist/cjs/langfuseTraceShaping.cjs +121 -4
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +5 -5
- package/dist/cjs/llm/openai/index.cjs +1 -1
- package/dist/cjs/main.cjs +10 -10
- package/dist/cjs/messages/prune.cjs +13 -1
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/prompts/activityLabel.cjs +24 -12
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
- package/dist/cjs/run.cjs +57 -22
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/messageSerialization.cjs +6 -0
- package/dist/cjs/session/messageSerialization.cjs.map +1 -1
- package/dist/cjs/stream.cjs +21 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +5 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +253 -24
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +1 -1
- package/dist/cjs/tools/search/tool.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/esm/agents/AgentContext.mjs +4 -0
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +67 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +35 -2
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +3 -2
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
- package/dist/esm/instrumentation.mjs +18 -48
- package/dist/esm/instrumentation.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +176 -28
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/langfuseConfig.mjs +10 -1
- package/dist/esm/langfuseConfig.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeContext.mjs +21 -3
- package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeScope.mjs +39 -10
- package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
- package/dist/esm/langfuseSpanRegistry.mjs +91 -0
- package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
- package/dist/esm/langfuseTraceShaping.mjs +121 -4
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +2 -2
- package/dist/esm/llm/invoke.mjs +5 -5
- package/dist/esm/llm/openai/index.mjs +1 -1
- package/dist/esm/main.mjs +8 -8
- package/dist/esm/messages/prune.mjs +13 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/prompts/activityLabel.mjs +24 -12
- package/dist/esm/prompts/activityLabel.mjs.map +1 -1
- package/dist/esm/run.mjs +57 -22
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/messageSerialization.mjs +6 -0
- package/dist/esm/session/messageSerialization.mjs.map +1 -1
- package/dist/esm/stream.mjs +21 -10
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +5 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +254 -25
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +1 -1
- package/dist/esm/tools/search/tool.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/types/agents/AgentContext.d.ts +2 -0
- package/dist/types/graphs/Graph.d.ts +24 -0
- package/dist/types/hitl/askUserQuestion.d.ts +11 -1
- package/dist/types/langfuse.d.ts +16 -8
- package/dist/types/langfuseConfig.d.ts +6 -0
- package/dist/types/langfuseRuntimeContext.d.ts +27 -1
- package/dist/types/langfuseRuntimeScope.d.ts +17 -2
- package/dist/types/langfuseSpanRegistry.d.ts +17 -0
- package/dist/types/langfuseTraceShaping.d.ts +2 -1
- package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
- package/dist/types/run.d.ts +7 -0
- package/dist/types/session/types.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +7 -1
- package/dist/types/types/hitl.d.ts +8 -0
- package/dist/types/types/tools.d.ts +30 -0
- package/package.json +7 -4
- package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
- package/src/agents/AgentContext.ts +5 -0
- package/src/graphs/Graph.ts +108 -20
- package/src/graphs/MultiAgentGraph.ts +56 -2
- package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
- package/src/hitl/askUserQuestion.ts +14 -1
- package/src/instrumentation.ts +35 -77
- package/src/langfuse.ts +320 -43
- package/src/langfuseConfig.ts +24 -0
- package/src/langfuseRuntimeContext.ts +43 -1
- package/src/langfuseRuntimeScope.ts +94 -21
- package/src/langfuseSpanRegistry.ts +131 -0
- package/src/langfuseTraceShaping.ts +194 -7
- package/src/llm/anthropic/utils/message_inputs.ts +70 -19
- package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
- package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
- package/src/llm/bedrock/utils/message_inputs.ts +32 -7
- package/src/messages/prune.ts +12 -1
- package/src/prompts/activityLabel.ts +23 -6
- package/src/run.ts +91 -45
- package/src/scripts/activity-labels/captured.json +56 -0
- package/src/scripts/activity-labels/checks.cjs +205 -0
- package/src/scripts/activity-labels/corpus.cjs +473 -0
- package/src/scripts/activity-labels/report.cjs +203 -0
- package/src/scripts/activity-labels/rescore.cjs +102 -0
- package/src/scripts/activity-labels/run.ts +705 -0
- package/src/scripts/activity-labels/variants.ts +71 -0
- package/src/session/messageSerialization.ts +12 -1
- package/src/session/types.ts +1 -0
- package/src/specs/activity-label-prompt.test.ts +26 -10
- package/src/specs/agent-handoffs.test.ts +306 -0
- package/src/specs/discovered-tools.test.ts +217 -0
- package/src/specs/langfuse-callbacks.test.ts +456 -0
- package/src/specs/langfuse-routing.integration.test.ts +138 -1
- package/src/specs/langfuse-span-registry.test.ts +70 -0
- package/src/specs/langfuse-trace-shaping.test.ts +294 -0
- package/src/specs/prune.test.ts +38 -1
- package/src/stream.ts +70 -6
- package/src/summarization/node.ts +5 -0
- package/src/tools/ToolNode.ts +400 -9
- package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
- package/src/tools/__tests__/hitl.test.ts +58 -0
- package/src/types/hitl.ts +8 -0
- 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
|
+
});
|