@librechat/agents 3.2.59 → 3.2.61

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.
@@ -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
+ });