@librechat/agents 3.2.59 → 3.2.60
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 +31 -7
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/run.cjs +4 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +95 -10
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +31 -7
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/run.mjs +4 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +95 -10
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +21 -3
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +57 -1
- package/dist/types/types/hitl.d.ts +49 -3
- package/dist/types/types/run.d.ts +21 -0
- package/dist/types/types/tools.d.ts +37 -1
- package/package.json +1 -1
- package/src/graphs/Graph.ts +74 -28
- package/src/run.ts +4 -0
- package/src/specs/ask-user-question-batch.test.ts +289 -0
- package/src/specs/tool-error-resume.test.ts +194 -0
- package/src/tools/ToolNode.ts +172 -27
- package/src/tools/__tests__/hitl.test.ts +48 -0
- package/src/types/hitl.ts +49 -3
- package/src/types/run.ts +21 -0
- package/src/types/tools.ts +37 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
|
4
|
+
import { describe, it, expect, jest, afterEach } from '@jest/globals';
|
|
5
|
+
import {
|
|
6
|
+
END,
|
|
7
|
+
START,
|
|
8
|
+
Command,
|
|
9
|
+
StateGraph,
|
|
10
|
+
MemorySaver,
|
|
11
|
+
isInterrupted,
|
|
12
|
+
MessagesAnnotation,
|
|
13
|
+
} from '@langchain/langgraph';
|
|
14
|
+
import type { Runnable, RunnableConfig } from '@langchain/core/runnables';
|
|
15
|
+
import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
16
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
17
|
+
import type * as t from '@/types';
|
|
18
|
+
import * as events from '@/utils/events';
|
|
19
|
+
import { askUserQuestion } from '@/hitl';
|
|
20
|
+
import { ToolNode } from '@/tools/ToolNode';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pins the `interruptingToolNames` guard for the `ask_user_question`
|
|
24
|
+
* shape: a normal tool whose BODY raises a LangGraph `interrupt()`
|
|
25
|
+
* mid-execution (via {@link askUserQuestion}) to collect a human answer.
|
|
26
|
+
*
|
|
27
|
+
* When such a tool shares a tool-call batch with a NON-idempotent
|
|
28
|
+
* sibling (send_email, billing), LangGraph's resume contract — re-run
|
|
29
|
+
* the whole interrupted node from the top — would otherwise execute the
|
|
30
|
+
* sibling once on the first pass and AGAIN on resume, duplicating the
|
|
31
|
+
* side effect. Declaring the interrupter in `interruptingToolNames`
|
|
32
|
+
* schedules it ahead of its siblings, so the batch unwinds before any
|
|
33
|
+
* sibling runs and the sibling executes exactly once (on resume).
|
|
34
|
+
*
|
|
35
|
+
* Runs entirely in-memory (StateGraph + MemorySaver) — no LLM, no Run
|
|
36
|
+
* machinery — so it exercises the ToolNode batch-execution change
|
|
37
|
+
* directly. Companion dist probe: `node -e` against dist/cjs/main.cjs.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
type MessagesUpdate = { messages: BaseMessage[] };
|
|
41
|
+
type CompiledMessagesGraph = Runnable<unknown, { messages: BaseMessage[] }> & {
|
|
42
|
+
invoke(input: unknown, config?: RunnableConfig): Promise<unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Tool whose body suspends the run to ask the user (no side effect). */
|
|
46
|
+
function makeAskTool(): StructuredToolInterface {
|
|
47
|
+
return tool(
|
|
48
|
+
async () => {
|
|
49
|
+
const { answer } = askUserQuestion({ question: 'Proceed?' });
|
|
50
|
+
return `answered: ${answer}`;
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'ask_user_question',
|
|
54
|
+
description: 'suspends to collect a human answer',
|
|
55
|
+
schema: z.object({}).passthrough(),
|
|
56
|
+
}
|
|
57
|
+
) as unknown as StructuredToolInterface;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Non-idempotent sibling — records every body invocation. */
|
|
61
|
+
function makeSideEffectTool(sideEffect: () => string): StructuredToolInterface {
|
|
62
|
+
return tool(async () => sideEffect(), {
|
|
63
|
+
name: 'side_effect',
|
|
64
|
+
description: 'non-idempotent side effect (e.g. send_email)',
|
|
65
|
+
schema: z.object({}).passthrough(),
|
|
66
|
+
}) as unknown as StructuredToolInterface;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildGraph(
|
|
70
|
+
node: ToolNode,
|
|
71
|
+
toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }>
|
|
72
|
+
): CompiledMessagesGraph {
|
|
73
|
+
const builder = new StateGraph(MessagesAnnotation)
|
|
74
|
+
.addNode(
|
|
75
|
+
'agent',
|
|
76
|
+
(): MessagesUpdate => ({
|
|
77
|
+
messages: [new AIMessage({ content: '', tool_calls: toolCalls })],
|
|
78
|
+
})
|
|
79
|
+
)
|
|
80
|
+
.addNode('tools', node)
|
|
81
|
+
.addEdge(START, 'agent')
|
|
82
|
+
.addEdge('agent', 'tools')
|
|
83
|
+
.addEdge('tools', END);
|
|
84
|
+
return builder.compile({
|
|
85
|
+
checkpointer: new MemorySaver(),
|
|
86
|
+
}) as unknown as CompiledMessagesGraph;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ASK = { id: 'ask1', name: 'ask_user_question', args: {} };
|
|
90
|
+
const SIDE_EFFECT = { id: 'se1', name: 'side_effect', args: {} };
|
|
91
|
+
|
|
92
|
+
describe('ask_user_question batched with a sibling tool', () => {
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
jest.restoreAllMocks();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('direct sibling — guarded by interruptingToolNames', () => {
|
|
98
|
+
// The exposed shape: both tools run in-process, so absent the guard
|
|
99
|
+
// they share one Promise.all and the sibling double-executes.
|
|
100
|
+
for (const order of [
|
|
101
|
+
{ label: '[ask, side_effect]', calls: [ASK, SIDE_EFFECT] },
|
|
102
|
+
{ label: '[side_effect, ask]', calls: [SIDE_EFFECT, ASK] },
|
|
103
|
+
]) {
|
|
104
|
+
it(`runs the sibling exactly once across pause→resume — order ${order.label}`, async () => {
|
|
105
|
+
const sideEffect = jest.fn(() => 'EXECUTED');
|
|
106
|
+
const node = new ToolNode({
|
|
107
|
+
tools: [makeAskTool(), makeSideEffectTool(sideEffect)],
|
|
108
|
+
eventDrivenMode: true,
|
|
109
|
+
directToolNames: new Set(['ask_user_question', 'side_effect']),
|
|
110
|
+
interruptingToolNames: new Set(['ask_user_question']),
|
|
111
|
+
});
|
|
112
|
+
const graph = buildGraph(node, order.calls);
|
|
113
|
+
const config = {
|
|
114
|
+
configurable: { thread_id: `guarded-${order.label}` },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const first = await graph.invoke({ messages: [] }, config);
|
|
118
|
+
expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
|
|
119
|
+
// Guard: the interrupter is scheduled first, so the sibling has
|
|
120
|
+
// NOT run when the batch unwinds on the first pass.
|
|
121
|
+
expect(sideEffect).not.toHaveBeenCalled();
|
|
122
|
+
|
|
123
|
+
const second = await graph.invoke(
|
|
124
|
+
new Command({ resume: { answer: 'yes' } }),
|
|
125
|
+
config
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Sibling ran exactly once, on the resume pass.
|
|
129
|
+
expect(sideEffect).toHaveBeenCalledTimes(1);
|
|
130
|
+
|
|
131
|
+
const messages = (second as { messages: ToolMessage[] }).messages;
|
|
132
|
+
const seMsg = messages.find(
|
|
133
|
+
(m) => m instanceof ToolMessage && m.name === 'side_effect'
|
|
134
|
+
) as ToolMessage;
|
|
135
|
+
expect(String(seMsg.content)).toBe('EXECUTED');
|
|
136
|
+
const askMsg = messages.find(
|
|
137
|
+
(m) => m instanceof ToolMessage && m.name === 'ask_user_question'
|
|
138
|
+
) as ToolMessage;
|
|
139
|
+
expect(String(askMsg.content)).toBe('answered: yes');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('baseline (no guard) — documents the defect the guard closes', () => {
|
|
145
|
+
it('double-executes an unguarded direct sibling on resume', async () => {
|
|
146
|
+
const sideEffect = jest.fn(() => 'EXECUTED');
|
|
147
|
+
const node = new ToolNode({
|
|
148
|
+
tools: [makeAskTool(), makeSideEffectTool(sideEffect)],
|
|
149
|
+
eventDrivenMode: true,
|
|
150
|
+
directToolNames: new Set(['ask_user_question', 'side_effect']),
|
|
151
|
+
// interruptingToolNames intentionally omitted.
|
|
152
|
+
});
|
|
153
|
+
const graph = buildGraph(node, [ASK, SIDE_EFFECT]);
|
|
154
|
+
const config = { configurable: { thread_id: 'baseline-unguarded' } };
|
|
155
|
+
|
|
156
|
+
const first = await graph.invoke({ messages: [] }, config);
|
|
157
|
+
expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
|
|
158
|
+
// Unguarded: the sibling shared the interrupter's Promise.all and
|
|
159
|
+
// already ran its side effect before the pause.
|
|
160
|
+
expect(sideEffect).toHaveBeenCalledTimes(1);
|
|
161
|
+
|
|
162
|
+
await graph.invoke(new Command({ resume: { answer: 'yes' } }), config);
|
|
163
|
+
// LangGraph re-runs the batch → the side effect fires a SECOND time.
|
|
164
|
+
expect(sideEffect).toHaveBeenCalledTimes(2);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('event-dispatched sibling — already safe without the guard', () => {
|
|
169
|
+
it('never runs the event sibling on the first pass', async () => {
|
|
170
|
+
const sideEffect = jest.fn(() => 'EXECUTED');
|
|
171
|
+
// Host executes event-dispatched tools; record side_effect there.
|
|
172
|
+
jest
|
|
173
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
174
|
+
.mockImplementation(async (event, data) => {
|
|
175
|
+
if (event !== 'on_tool_execute') {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const req = data as {
|
|
179
|
+
toolCalls: Array<{ id: string; name: string }>;
|
|
180
|
+
resolve: (results: t.ToolExecuteResult[]) => void;
|
|
181
|
+
};
|
|
182
|
+
const results = req.toolCalls.map((tc) => {
|
|
183
|
+
if (tc.name === 'side_effect') {
|
|
184
|
+
sideEffect();
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
toolCallId: tc.id,
|
|
188
|
+
content: 'EXECUTED',
|
|
189
|
+
status: 'success' as const,
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
req.resolve(results);
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const node = new ToolNode({
|
|
197
|
+
tools: [
|
|
198
|
+
makeAskTool(),
|
|
199
|
+
// schema-only stub; the host "executes" side_effect via event.
|
|
200
|
+
tool(async () => 'unused', {
|
|
201
|
+
name: 'side_effect',
|
|
202
|
+
description: 'event tool',
|
|
203
|
+
schema: z.object({}).passthrough(),
|
|
204
|
+
}) as unknown as StructuredToolInterface,
|
|
205
|
+
],
|
|
206
|
+
eventDrivenMode: true,
|
|
207
|
+
// side_effect is NOT direct → dispatched as an event.
|
|
208
|
+
directToolNames: new Set(['ask_user_question']),
|
|
209
|
+
interruptingToolNames: new Set(['ask_user_question']),
|
|
210
|
+
});
|
|
211
|
+
const graph = buildGraph(node, [SIDE_EFFECT, ASK]);
|
|
212
|
+
const config = { configurable: { thread_id: 'event-sibling' } };
|
|
213
|
+
|
|
214
|
+
const first = await graph.invoke({ messages: [] }, config);
|
|
215
|
+
expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
|
|
216
|
+
// The direct interrupter unwinds the batch before event tools are
|
|
217
|
+
// dispatched, so the host never executed side_effect.
|
|
218
|
+
expect(sideEffect).not.toHaveBeenCalled();
|
|
219
|
+
|
|
220
|
+
await graph.invoke(new Command({ resume: { answer: 'yes' } }), config);
|
|
221
|
+
expect(sideEffect).toHaveBeenCalledTimes(1);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('interruptingToolNames only reorders; never forces direct (Codex #294)', () => {
|
|
226
|
+
// A self-spawned child scrubs inherited `graphTools` but keeps the
|
|
227
|
+
// event `toolDefinition`, so a name like `ask_user_question` exists
|
|
228
|
+
// only as a schema-only stub. Propagating `interruptingToolNames`
|
|
229
|
+
// must NOT force that name onto the direct path — invoking the stub
|
|
230
|
+
// throws "should not be invoked directly". It must stay dispatched.
|
|
231
|
+
it('dispatches an interrupting name that has no in-process implementation', async () => {
|
|
232
|
+
const directlyInvoked = jest.fn();
|
|
233
|
+
// Mirrors createSchemaOnlyTool: throws if invoked in-process.
|
|
234
|
+
const stub = tool(
|
|
235
|
+
async () => {
|
|
236
|
+
directlyInvoked();
|
|
237
|
+
throw new Error(
|
|
238
|
+
'Tool "ask_user_question" should not be invoked directly in event-driven mode.'
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'ask_user_question',
|
|
243
|
+
description: 'schema-only event stub',
|
|
244
|
+
schema: z.object({}).passthrough(),
|
|
245
|
+
}
|
|
246
|
+
) as unknown as StructuredToolInterface;
|
|
247
|
+
|
|
248
|
+
jest
|
|
249
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
250
|
+
.mockImplementation(async (event, data) => {
|
|
251
|
+
if (event !== 'on_tool_execute') {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const req = data as {
|
|
255
|
+
toolCalls: Array<{ id: string; name: string }>;
|
|
256
|
+
resolve: (results: t.ToolExecuteResult[]) => void;
|
|
257
|
+
};
|
|
258
|
+
req.resolve(
|
|
259
|
+
req.toolCalls.map((tc) => ({
|
|
260
|
+
toolCallId: tc.id,
|
|
261
|
+
content: 'HOST-HANDLED',
|
|
262
|
+
status: 'success' as const,
|
|
263
|
+
}))
|
|
264
|
+
);
|
|
265
|
+
return true;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const node = new ToolNode({
|
|
269
|
+
tools: [stub],
|
|
270
|
+
eventDrivenMode: true,
|
|
271
|
+
// NOT in directToolNames → it is an event tool, even though it is
|
|
272
|
+
// named in interruptingToolNames.
|
|
273
|
+
interruptingToolNames: new Set(['ask_user_question']),
|
|
274
|
+
});
|
|
275
|
+
const graph = buildGraph(node, [ASK]);
|
|
276
|
+
const config = { configurable: { thread_id: 'stub-stays-event' } };
|
|
277
|
+
|
|
278
|
+
const result = await graph.invoke({ messages: [] }, config);
|
|
279
|
+
|
|
280
|
+
// Dispatched to the host, NOT invoked in-process.
|
|
281
|
+
expect(directlyInvoked).not.toHaveBeenCalled();
|
|
282
|
+
const msg = (result as { messages: ToolMessage[] }).messages.find(
|
|
283
|
+
(m) => m instanceof ToolMessage
|
|
284
|
+
) as ToolMessage;
|
|
285
|
+
expect(msg.status).toBe('success');
|
|
286
|
+
expect(String(msg.content)).toBe('HOST-HANDLED');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
4
|
+
import { MemorySaver, Command } from '@langchain/langgraph';
|
|
5
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
6
|
+
import type * as t from '@/types';
|
|
7
|
+
import { GraphEvents, Providers } from '@/common';
|
|
8
|
+
import { FakeChatModel } from '@/llm/fake';
|
|
9
|
+
import { askUserQuestion } from '@/hitl';
|
|
10
|
+
import { StandardGraph } from '@/graphs';
|
|
11
|
+
import { Run } from '@/run';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Regression: a DIRECT (graphTools) tool that fails schema validation during
|
|
15
|
+
* the RESUME pass of an interrupted batch.
|
|
16
|
+
*
|
|
17
|
+
* LangGraph re-executes the whole interrupted batch on resume, and a
|
|
18
|
+
* fast-failing sibling (e.g. a zod reject) errors BEFORE the rebuilt graph
|
|
19
|
+
* has registered run steps for the replayed calls. `handleToolCallErrorStatic`
|
|
20
|
+
* used to throw (`No config provided`) in that state, which surfaced as a
|
|
21
|
+
* scary `Error in errorHandler` log on every such resume — the error itself
|
|
22
|
+
* was already handled (the model receives the error ToolMessage either way).
|
|
23
|
+
* It now reports `false` (nothing dispatched) so the ToolNode can decide, and
|
|
24
|
+
* the client-facing contract stays: the error completion event dispatches
|
|
25
|
+
* exactly ONCE, on the pass where the call's run step is live (the first
|
|
26
|
+
* pass), never zero times and never twice.
|
|
27
|
+
*/
|
|
28
|
+
const askTool = tool(
|
|
29
|
+
async (input) => {
|
|
30
|
+
const { answer } = askUserQuestion(input as { question: string });
|
|
31
|
+
return answer;
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'ask_user_question',
|
|
35
|
+
description:
|
|
36
|
+
'Ask the user a clarifying question and wait for their answer.',
|
|
37
|
+
schema: z.object({ question: z.string() }),
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const strictTool = tool(
|
|
42
|
+
async ({ items }: { items: string[] }) => `got ${items.length}`,
|
|
43
|
+
{
|
|
44
|
+
name: 'strict_tool',
|
|
45
|
+
// Array cap mirrors the field repro: an ask_user_question sibling whose
|
|
46
|
+
// `options` array exceeded the 12-option schema cap.
|
|
47
|
+
description: 'A tool with a strict array-cap schema.',
|
|
48
|
+
schema: z.object({ items: z.array(z.string()).max(1) }),
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
describe('Direct tool schema error across interrupt/resume', () => {
|
|
53
|
+
jest.setTimeout(30000);
|
|
54
|
+
|
|
55
|
+
const threadConfig: Partial<RunnableConfig> & {
|
|
56
|
+
version: 'v1' | 'v2';
|
|
57
|
+
streamMode: string;
|
|
58
|
+
} = {
|
|
59
|
+
configurable: { thread_id: 'tool-error-resume-thread' },
|
|
60
|
+
streamMode: 'values',
|
|
61
|
+
version: 'v2' as const,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const completedToolEvents: Array<{ name?: string; output?: string }> = [];
|
|
65
|
+
const customHandlers = {
|
|
66
|
+
[GraphEvents.ON_RUN_STEP_COMPLETED]: {
|
|
67
|
+
handle: (_event: GraphEvents, data: t.StreamEventData): void => {
|
|
68
|
+
const result = (data as { result?: t.ToolCompleteEvent }).result;
|
|
69
|
+
if (result?.type === 'tool_call') {
|
|
70
|
+
completedToolEvents.push({
|
|
71
|
+
name: result.tool_call.name,
|
|
72
|
+
output: result.tool_call.output,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
async function buildRun(
|
|
80
|
+
saver: MemorySaver,
|
|
81
|
+
runId: string
|
|
82
|
+
): Promise<Run<t.IState>> {
|
|
83
|
+
const run = await Run.create<t.IState>({
|
|
84
|
+
runId,
|
|
85
|
+
graphConfig: {
|
|
86
|
+
type: 'standard',
|
|
87
|
+
agents: [
|
|
88
|
+
{
|
|
89
|
+
agentId: 'resume-error-agent',
|
|
90
|
+
provider: Providers.OPENAI,
|
|
91
|
+
clientOptions: {
|
|
92
|
+
model: 'gpt-4o-mini',
|
|
93
|
+
streaming: true,
|
|
94
|
+
streamUsage: false,
|
|
95
|
+
},
|
|
96
|
+
instructions: 'You are a helpful assistant.',
|
|
97
|
+
maxContextTokens: 8000,
|
|
98
|
+
/** Non-empty toolDefinitions flips the ToolNode to event dispatch —
|
|
99
|
+
* the production shape — while graphTools ride the direct path. */
|
|
100
|
+
toolDefinitions: [
|
|
101
|
+
{ name: 'dummy_event_tool', description: 'host tool' },
|
|
102
|
+
],
|
|
103
|
+
graphTools: [askTool, strictTool],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
compileOptions: { checkpointer: saver },
|
|
107
|
+
},
|
|
108
|
+
returnContent: true,
|
|
109
|
+
skipCleanup: true,
|
|
110
|
+
customHandlers,
|
|
111
|
+
eagerEventToolExecution: {
|
|
112
|
+
enabled: true,
|
|
113
|
+
excludeToolNames: ['ask_user_question', 'strict_tool'],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
run.Graph!.overrideModel = new FakeChatModel({
|
|
117
|
+
responses: ['calling tools', 'done after resume'],
|
|
118
|
+
toolCalls: [
|
|
119
|
+
{
|
|
120
|
+
name: 'ask_user_question',
|
|
121
|
+
args: { question: 'pick one' },
|
|
122
|
+
id: 'call_ask_1',
|
|
123
|
+
type: 'tool_call',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'strict_tool',
|
|
127
|
+
args: { items: ['a', 'b', 'c'] },
|
|
128
|
+
id: 'call_strict_1',
|
|
129
|
+
type: 'tool_call',
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
return run;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
test('error completion dispatches exactly once; resume-pass handler reports false instead of throwing', async () => {
|
|
137
|
+
const saver = new MemorySaver();
|
|
138
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
139
|
+
const staticSpy = jest.spyOn(StandardGraph, 'handleToolCallErrorStatic');
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
/** Pass 1: the strict tool rejects its args while the ask tool
|
|
143
|
+
* interrupts — the run pauses AND the schema error completes. */
|
|
144
|
+
const run1 = await buildRun(saver, 'run-pass-1');
|
|
145
|
+
await run1.processStream(
|
|
146
|
+
{ messages: [new HumanMessage('go')] },
|
|
147
|
+
threadConfig
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const strictCompletions = completedToolEvents.filter(
|
|
151
|
+
(e) =>
|
|
152
|
+
e.name === 'strict_tool' &&
|
|
153
|
+
e.output?.includes('Error processing tool') === true
|
|
154
|
+
);
|
|
155
|
+
expect(strictCompletions).toHaveLength(1);
|
|
156
|
+
// Stable LangChain wrapper text (version-independent), proving the
|
|
157
|
+
// completion carried the schema-validation failure.
|
|
158
|
+
expect(strictCompletions[0].output).toContain(
|
|
159
|
+
'did not match expected schema'
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/** Pass 2: fresh instance (host restart shape), resume with the
|
|
163
|
+
* answer. The batch re-executes; the strict tool fails again before
|
|
164
|
+
* any run step is registered on the rebuilt graph. */
|
|
165
|
+
const run2 = await buildRun(saver, 'run-pass-2');
|
|
166
|
+
await run2.processStream(
|
|
167
|
+
new Command({ resume: { answer: 'blue' } }) as unknown as t.IState,
|
|
168
|
+
threadConfig
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
/** The handler reported "not dispatched" (no run step yet) rather than
|
|
172
|
+
* throwing — so nothing was logged and no duplicate completion event
|
|
173
|
+
* reached the client. */
|
|
174
|
+
const resumeResults = await Promise.all(
|
|
175
|
+
staticSpy.mock.results.map((r) => r.value as Promise<boolean>)
|
|
176
|
+
);
|
|
177
|
+
expect(resumeResults).toContain(false);
|
|
178
|
+
const handlerFailures = errorSpy.mock.calls.filter((args) =>
|
|
179
|
+
String(args[0]).includes('Error in errorHandler')
|
|
180
|
+
);
|
|
181
|
+
expect(handlerFailures).toHaveLength(0);
|
|
182
|
+
expect(
|
|
183
|
+
completedToolEvents.filter(
|
|
184
|
+
(e) =>
|
|
185
|
+
e.name === 'strict_tool' &&
|
|
186
|
+
e.output?.includes('Error processing tool') === true
|
|
187
|
+
)
|
|
188
|
+
).toHaveLength(1);
|
|
189
|
+
} finally {
|
|
190
|
+
errorSpy.mockRestore();
|
|
191
|
+
staticSpy.mockRestore();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|