@librechat/agents 3.2.58 → 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/main.cjs +7 -0
- package/dist/cjs/run.cjs +4 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +2 -1
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +58 -9
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +4 -2
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +57 -7
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +9 -3
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +114 -11
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/eagerEventExecution.cjs +18 -1
- package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +31 -7
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/run.mjs +4 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +2 -1
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +56 -10
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs +4 -2
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +54 -8
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +9 -3
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +115 -12
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/eagerEventExecution.mjs +18 -2
- package/dist/esm/tools/eagerEventExecution.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/BashExecutor.d.ts +13 -0
- package/dist/types/tools/CodeExecutor.d.ts +14 -0
- package/dist/types/tools/ToolNode.d.ts +60 -1
- package/dist/types/tools/eagerEventExecution.d.ts +8 -0
- 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 +95 -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/stream.ts +17 -1
- package/src/tools/BashExecutor.ts +107 -14
- package/src/tools/BashProgrammaticToolCalling.ts +20 -1
- package/src/tools/CodeExecutor.ts +113 -9
- package/src/tools/ProgrammaticToolCalling.ts +27 -1
- package/src/tools/ToolNode.ts +208 -31
- package/src/tools/__tests__/BashExecutor.test.ts +39 -0
- package/src/tools/__tests__/CodeExecutor.stateful.test.ts +113 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +86 -0
- package/src/tools/__tests__/eagerEventExecution.session.test.ts +92 -0
- package/src/tools/__tests__/hitl.test.ts +48 -0
- package/src/tools/eagerEventExecution.ts +32 -5
- package/src/types/hitl.ts +49 -3
- package/src/types/run.ts +21 -0
- package/src/types/tools.ts +102 -1
package/src/graphs/Graph.ts
CHANGED
|
@@ -609,6 +609,14 @@ export abstract class Graph<
|
|
|
609
609
|
*/
|
|
610
610
|
eagerEventToolExecution: t.EagerEventToolExecutionConfig | undefined;
|
|
611
611
|
codeSessionToolNames: string[] | undefined;
|
|
612
|
+
/**
|
|
613
|
+
* Run-scoped names of tools whose in-process body may raise a LangGraph
|
|
614
|
+
* `interrupt()` (e.g. `ask_user_question`). Threaded from
|
|
615
|
+
* `RunConfig.interruptingToolNames` into every ToolNode this graph
|
|
616
|
+
* compiles so a mid-batch interrupt cannot double-execute non-idempotent
|
|
617
|
+
* siblings on resume. See {@link t.ToolNodeOptions.interruptingToolNames}.
|
|
618
|
+
*/
|
|
619
|
+
interruptingToolNames: string[] | undefined;
|
|
612
620
|
eagerEventToolExecutions: Map<string, t.EagerEventToolExecution> = new Map();
|
|
613
621
|
eagerEventToolUsageCount: Map<string, number> = new Map();
|
|
614
622
|
private eagerEventToolUsageCountsByAgentId: Map<string, Map<string, number>> =
|
|
@@ -658,6 +666,7 @@ export abstract class Graph<
|
|
|
658
666
|
this.toolOutputReferences = undefined;
|
|
659
667
|
this.eagerEventToolExecution = undefined;
|
|
660
668
|
this.codeSessionToolNames = undefined;
|
|
669
|
+
this.interruptingToolNames = undefined;
|
|
661
670
|
this.eagerEventToolExecutions.clear();
|
|
662
671
|
this.clearEagerEventToolUsageCounts();
|
|
663
672
|
this.eagerEventToolCallChunks.clear();
|
|
@@ -1277,11 +1286,16 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
1277
1286
|
),
|
|
1278
1287
|
toolExecution: this.toolExecution,
|
|
1279
1288
|
directToolNames: directToolNames.size > 0 ? directToolNames : undefined,
|
|
1289
|
+
interruptingToolNames:
|
|
1290
|
+
this.interruptingToolNames != null &&
|
|
1291
|
+
this.interruptingToolNames.length > 0
|
|
1292
|
+
? new Set(this.interruptingToolNames)
|
|
1293
|
+
: undefined,
|
|
1280
1294
|
maxContextTokens: agentContext?.maxContextTokens,
|
|
1281
1295
|
maxToolResultChars: agentContext?.maxToolResultChars,
|
|
1282
1296
|
toolOutputRegistry: this.getOrCreateToolOutputRegistry(),
|
|
1283
1297
|
fileCheckpointer: this.getOrCreateFileCheckpointer(),
|
|
1284
|
-
errorHandler: (data, metadata): Promise<
|
|
1298
|
+
errorHandler: (data, metadata): Promise<boolean> =>
|
|
1285
1299
|
StandardGraph.handleToolCallErrorStatic(this, data, metadata),
|
|
1286
1300
|
});
|
|
1287
1301
|
this.registerCompiledToolNode(node);
|
|
@@ -1330,12 +1344,17 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
1330
1344
|
// agent so hooks can attribute the batch even at the top level.
|
|
1331
1345
|
executingAgentId: agentContext?.agentId,
|
|
1332
1346
|
toolCallStepIds: this.toolCallStepIds,
|
|
1333
|
-
errorHandler: (data, metadata): Promise<
|
|
1347
|
+
errorHandler: (data, metadata): Promise<boolean> =>
|
|
1334
1348
|
StandardGraph.handleToolCallErrorStatic(this, data, metadata),
|
|
1335
1349
|
toolRegistry: agentContext?.toolRegistry,
|
|
1336
1350
|
sessions: this.sessions,
|
|
1337
1351
|
toolExecution: this.toolExecution,
|
|
1338
1352
|
codeSessionToolNames: this.codeSessionToolNames,
|
|
1353
|
+
interruptingToolNames:
|
|
1354
|
+
this.interruptingToolNames != null &&
|
|
1355
|
+
this.interruptingToolNames.length > 0
|
|
1356
|
+
? new Set(this.interruptingToolNames)
|
|
1357
|
+
: undefined,
|
|
1339
1358
|
hookRegistry: this.hookRegistry,
|
|
1340
1359
|
humanInTheLoop: this.humanInTheLoop,
|
|
1341
1360
|
maxContextTokens: agentContext?.maxContextTokens,
|
|
@@ -2361,6 +2380,17 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
2361
2380
|
childGraph.toolOutputReferences = this.toolOutputReferences;
|
|
2362
2381
|
childGraph.eagerEventToolExecution = this.eagerEventToolExecution;
|
|
2363
2382
|
childGraph.codeSessionToolNames = this.codeSessionToolNames;
|
|
2383
|
+
// Pure execution-ordering hint (unlike `humanInTheLoop` above).
|
|
2384
|
+
// It ONLY reorders tools already in the child's direct group;
|
|
2385
|
+
// it does not force a name onto the direct path (that fold-in
|
|
2386
|
+
// was removed — Codex review of #294). So for a self-spawned
|
|
2387
|
+
// child that scrubs inherited `graphTools` (keeping only the
|
|
2388
|
+
// event `toolDefinition` / schema-only stub for a name like
|
|
2389
|
+
// `ask_user_question`), the name isn't in the child's direct
|
|
2390
|
+
// group and this is a no-op — the stub is still dispatched via
|
|
2391
|
+
// ON_TOOL_EXECUTE, never invoked directly. Where the child DOES
|
|
2392
|
+
// have the executable graphTool, the guard correctly applies.
|
|
2393
|
+
childGraph.interruptingToolNames = this.interruptingToolNames;
|
|
2364
2394
|
childGraph.toolExecution = this.toolExecution;
|
|
2365
2395
|
childGraph.eventToolExecutionAvailable =
|
|
2366
2396
|
this.handlerRegistry?.getHandler(GraphEvents.ON_TOOL_EXECUTE) !=
|
|
@@ -2715,32 +2745,38 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
2715
2745
|
|
|
2716
2746
|
/**
|
|
2717
2747
|
* Static version of handleToolCallError to avoid creating strong references
|
|
2718
|
-
* that prevent garbage collection
|
|
2748
|
+
* that prevent garbage collection.
|
|
2749
|
+
*
|
|
2750
|
+
* Returns whether the error completion event was actually dispatched. A
|
|
2751
|
+
* tool can error before this graph instance has a run step for the call —
|
|
2752
|
+
* on a resume pass the interrupted batch re-executes IMMEDIATELY on graph
|
|
2753
|
+
* re-entry, before any step replay has registered `toolCallStepIds` (a
|
|
2754
|
+
* fast-failing tool, e.g. a schema-validation reject, loses that race).
|
|
2755
|
+
* That is a caller-recoverable condition, not an invariant violation: the
|
|
2756
|
+
* ToolNode falls back to its normal completion dispatch for the error
|
|
2757
|
+
* ToolMessage when this returns `false`, so throwing here would only
|
|
2758
|
+
* replace a recoverable miss with a lost completion event and a scary log.
|
|
2719
2759
|
*/
|
|
2720
2760
|
static async handleToolCallErrorStatic(
|
|
2721
2761
|
graph: StandardGraph,
|
|
2722
2762
|
data: t.ToolErrorData,
|
|
2723
2763
|
metadata?: Record<string, unknown>
|
|
2724
|
-
): Promise<
|
|
2725
|
-
if (!graph.config) {
|
|
2726
|
-
throw new Error('No config provided');
|
|
2727
|
-
}
|
|
2728
|
-
|
|
2764
|
+
): Promise<boolean> {
|
|
2729
2765
|
if (!data.id) {
|
|
2730
2766
|
console.warn('No Tool ID provided for Tool Error');
|
|
2731
|
-
return;
|
|
2767
|
+
return false;
|
|
2732
2768
|
}
|
|
2733
2769
|
|
|
2734
2770
|
const stepId = graph.toolCallStepIds.get(data.id) ?? '';
|
|
2735
2771
|
if (!stepId) {
|
|
2736
|
-
|
|
2772
|
+
return false;
|
|
2737
2773
|
}
|
|
2738
2774
|
|
|
2739
2775
|
const { name, input: args, error } = data;
|
|
2740
2776
|
|
|
2741
2777
|
const runStep = graph.getRunStep(stepId);
|
|
2742
2778
|
if (!runStep) {
|
|
2743
|
-
|
|
2779
|
+
return false;
|
|
2744
2780
|
}
|
|
2745
2781
|
|
|
2746
2782
|
const tool_call: t.ProcessedToolCall = {
|
|
@@ -2751,21 +2787,31 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
2751
2787
|
progress: 1,
|
|
2752
2788
|
};
|
|
2753
2789
|
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2790
|
+
// No registered ON_RUN_STEP_COMPLETED handler ⇒ nothing was dispatched.
|
|
2791
|
+
// Report `false` so the ToolNode runs its own fallback dispatch; returning
|
|
2792
|
+
// `true` here would silently drop the error completion for hosts that wire
|
|
2793
|
+
// completions through callback-based custom events instead of a handler.
|
|
2794
|
+
const handler = graph.handlerRegistry?.getHandler(
|
|
2795
|
+
GraphEvents.ON_RUN_STEP_COMPLETED
|
|
2796
|
+
);
|
|
2797
|
+
if (!handler) {
|
|
2798
|
+
return false;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
await handler.handle(
|
|
2802
|
+
GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
2803
|
+
{
|
|
2804
|
+
result: {
|
|
2805
|
+
id: stepId,
|
|
2806
|
+
index: runStep.index,
|
|
2807
|
+
type: 'tool_call',
|
|
2808
|
+
tool_call,
|
|
2809
|
+
} as t.ToolCompleteEvent,
|
|
2810
|
+
},
|
|
2811
|
+
metadata,
|
|
2812
|
+
graph
|
|
2813
|
+
);
|
|
2814
|
+
return true;
|
|
2769
2815
|
}
|
|
2770
2816
|
|
|
2771
2817
|
/**
|
|
@@ -2775,8 +2821,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
2775
2821
|
async handleToolCallError(
|
|
2776
2822
|
data: t.ToolErrorData,
|
|
2777
2823
|
metadata?: Record<string, unknown>
|
|
2778
|
-
): Promise<
|
|
2779
|
-
|
|
2824
|
+
): Promise<boolean> {
|
|
2825
|
+
return StandardGraph.handleToolCallErrorStatic(this, data, metadata);
|
|
2780
2826
|
}
|
|
2781
2827
|
|
|
2782
2828
|
async dispatchRunStepDelta(
|
package/src/run.ts
CHANGED
|
@@ -128,6 +128,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
128
128
|
private toolOutputReferences?: t.ToolOutputReferencesConfig;
|
|
129
129
|
private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
|
|
130
130
|
private codeSessionToolNames?: string[];
|
|
131
|
+
private interruptingToolNames?: string[];
|
|
131
132
|
private toolExecution?: t.ToolExecutionConfig;
|
|
132
133
|
private subagentUsageSink?: t.SubagentUsageSink;
|
|
133
134
|
private indexTokenCountMap?: Record<string, number>;
|
|
@@ -184,6 +185,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
184
185
|
this.toolOutputReferences = config.toolOutputReferences;
|
|
185
186
|
this.eagerEventToolExecution = config.eagerEventToolExecution;
|
|
186
187
|
this.codeSessionToolNames = config.codeSessionToolNames;
|
|
188
|
+
this.interruptingToolNames = config.interruptingToolNames;
|
|
187
189
|
this.toolExecution = config.toolExecution;
|
|
188
190
|
this.subagentUsageSink = config.subagentUsageSink;
|
|
189
191
|
|
|
@@ -271,6 +273,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
271
273
|
standardGraph.toolOutputReferences = this.toolOutputReferences;
|
|
272
274
|
standardGraph.eagerEventToolExecution = this.eagerEventToolExecution;
|
|
273
275
|
standardGraph.codeSessionToolNames = this.codeSessionToolNames;
|
|
276
|
+
standardGraph.interruptingToolNames = this.interruptingToolNames;
|
|
274
277
|
standardGraph.toolExecution = this.toolExecution;
|
|
275
278
|
this.Graph = standardGraph;
|
|
276
279
|
return standardGraph.createWorkflow();
|
|
@@ -301,6 +304,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
301
304
|
multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
|
|
302
305
|
multiAgentGraph.eagerEventToolExecution = this.eagerEventToolExecution;
|
|
303
306
|
multiAgentGraph.codeSessionToolNames = this.codeSessionToolNames;
|
|
307
|
+
multiAgentGraph.interruptingToolNames = this.interruptingToolNames;
|
|
304
308
|
multiAgentGraph.toolExecution = this.toolExecution;
|
|
305
309
|
this.Graph = multiAgentGraph;
|
|
306
310
|
return multiAgentGraph.createWorkflow();
|
|
@@ -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
|
+
});
|