@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
package/src/tools/ToolNode.ts
CHANGED
|
@@ -416,6 +416,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
416
416
|
private agentLangfuse?: t.LangfuseConfig;
|
|
417
417
|
toolCallStepIds?: Map<string, string>;
|
|
418
418
|
errorHandler?: t.ToolNodeConstructorParams['errorHandler'];
|
|
419
|
+
/**
|
|
420
|
+
* Tool call ids whose `errorHandler` did NOT dispatch the error completion
|
|
421
|
+
* event (it returned `false` or threw). The output loop must dispatch the
|
|
422
|
+
* completion for these itself — skipping them there would strand the
|
|
423
|
+
* client's tool-call part without a terminal event.
|
|
424
|
+
*/
|
|
425
|
+
private undispatchedToolErrors: Set<string> = new Set();
|
|
419
426
|
private toolUsageCount: Map<string, number>;
|
|
420
427
|
/** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */
|
|
421
428
|
private toolCallTurns: Map<string, number> = new Map();
|
|
@@ -457,6 +464,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
457
464
|
private executingAgentId?: string;
|
|
458
465
|
/** Tool names that bypass event dispatch and execute directly (e.g., graph-managed handoff tools) */
|
|
459
466
|
private directToolNames?: Set<string>;
|
|
467
|
+
/**
|
|
468
|
+
* Tool names whose in-process body may raise a LangGraph `interrupt()`
|
|
469
|
+
* mid-execution (e.g. `ask_user_question`). Used only to REORDER within
|
|
470
|
+
* the direct group: a tool named here that is *already* direct (a real
|
|
471
|
+
* in-process graphTool — the only kind whose body can reach
|
|
472
|
+
* `interrupt()`) is scheduled ahead of its non-interrupting direct
|
|
473
|
+
* siblings, so a mid-body interrupt unwinds the ToolNode before a
|
|
474
|
+
* non-idempotent sibling executes and LangGraph's resume-time batch
|
|
475
|
+
* re-execution can't double it. This set is deliberately NOT folded
|
|
476
|
+
* into direct classification: a name that resolves to a schema-only
|
|
477
|
+
* event stub (an inherited `toolDefinition` with no executable
|
|
478
|
+
* instance) stays event-dispatched — forcing it direct would invoke
|
|
479
|
+
* the stub, which throws. See {@link t.ToolNodeOptions.interruptingToolNames}.
|
|
480
|
+
*/
|
|
481
|
+
private interruptingToolNames?: Set<string>;
|
|
460
482
|
/**
|
|
461
483
|
* File checkpointer extracted from the local coding tool bundle when
|
|
462
484
|
* `toolExecution.local.fileCheckpointing === true`. Exposed via
|
|
@@ -518,6 +540,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
518
540
|
agentId,
|
|
519
541
|
executingAgentId,
|
|
520
542
|
directToolNames,
|
|
543
|
+
interruptingToolNames,
|
|
521
544
|
codeSessionToolNames,
|
|
522
545
|
maxContextTokens,
|
|
523
546
|
maxToolResultChars,
|
|
@@ -556,6 +579,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
556
579
|
// existing agentId option) still get attribution without knowing the new option.
|
|
557
580
|
this.executingAgentId = executingAgentId ?? agentId;
|
|
558
581
|
this.directToolNames = directToolNames;
|
|
582
|
+
this.interruptingToolNames =
|
|
583
|
+
interruptingToolNames != null && interruptingToolNames.size > 0
|
|
584
|
+
? interruptingToolNames
|
|
585
|
+
: undefined;
|
|
559
586
|
this.codeSessionToolNames =
|
|
560
587
|
codeSessionToolNames != null && codeSessionToolNames.length > 0
|
|
561
588
|
? new Set(codeSessionToolNames)
|
|
@@ -1140,7 +1167,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1140
1167
|
}
|
|
1141
1168
|
if (this.errorHandler) {
|
|
1142
1169
|
try {
|
|
1143
|
-
await this.errorHandler(
|
|
1170
|
+
const dispatched = await this.errorHandler(
|
|
1144
1171
|
{
|
|
1145
1172
|
error: e,
|
|
1146
1173
|
id: call.id!,
|
|
@@ -1149,7 +1176,24 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1149
1176
|
},
|
|
1150
1177
|
config.metadata
|
|
1151
1178
|
);
|
|
1179
|
+
if (dispatched === false && call.id != null && call.id !== '') {
|
|
1180
|
+
/**
|
|
1181
|
+
* The handler could not dispatch the error completion (typically
|
|
1182
|
+
* a resume pass, where a fast-failing tool errors before the
|
|
1183
|
+
* step replay registers its run step). Remember the call so the
|
|
1184
|
+
* output loop dispatches the completion itself instead of
|
|
1185
|
+
* assuming the handler covered it.
|
|
1186
|
+
*/
|
|
1187
|
+
this.undispatchedToolErrors.add(call.id);
|
|
1188
|
+
}
|
|
1152
1189
|
} catch (handlerError) {
|
|
1190
|
+
// A THROWN handler is not proof the completion wasn't dispatched: the
|
|
1191
|
+
// built-in session handler emits `tool.completed` BEFORE invoking a
|
|
1192
|
+
// user ON_RUN_STEP_COMPLETED callback, so a throw from that callback
|
|
1193
|
+
// has already dispatched. Marking it undispatched would make the
|
|
1194
|
+
// fallback loop re-emit a duplicate completion — only an explicit
|
|
1195
|
+
// `false` return (handled above) means "nothing dispatched"; a throw
|
|
1196
|
+
// is just logged.
|
|
1153
1197
|
// eslint-disable-next-line no-console
|
|
1154
1198
|
console.error('Error in errorHandler:', {
|
|
1155
1199
|
toolName: call.name,
|
|
@@ -1875,9 +1919,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1875
1919
|
const toolCallId = call.id ?? '';
|
|
1876
1920
|
|
|
1877
1921
|
// Skip error ToolMessages when errorHandler already dispatched ON_RUN_STEP_COMPLETED
|
|
1878
|
-
// via handleToolCallErrorStatic
|
|
1922
|
+
// via handleToolCallErrorStatic — dispatching again here would double-dispatch.
|
|
1923
|
+
// When the handler reported it could NOT dispatch (no run step registered yet at
|
|
1924
|
+
// error time, e.g. a fast-failing tool on a resume pass), fall through: by now the
|
|
1925
|
+
// step replay has usually registered the id, so this loop's dispatch is the only
|
|
1926
|
+
// terminal event the client's tool-call part will ever get.
|
|
1879
1927
|
if (toolMessage.status === 'error' && this.errorHandler != null) {
|
|
1880
|
-
|
|
1928
|
+
if (this.undispatchedToolErrors.has(toolCallId)) {
|
|
1929
|
+
// CONSUME the marker: this loop now owns the dispatch for this id.
|
|
1930
|
+
// Leaving it set would let a later re-entry (same ToolNode instance
|
|
1931
|
+
// re-executing the batch, where the handler CAN dispatch) fall
|
|
1932
|
+
// through here too and double-dispatch — and the set would grow
|
|
1933
|
+
// unbounded across a long-lived graph's fast-failing calls.
|
|
1934
|
+
this.undispatchedToolErrors.delete(toolCallId);
|
|
1935
|
+
} else {
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1881
1938
|
}
|
|
1882
1939
|
|
|
1883
1940
|
if (this.sessions && this.participatesInCodeSession(call.name)) {
|
|
@@ -3180,6 +3237,94 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3180
3237
|
return converted;
|
|
3181
3238
|
}
|
|
3182
3239
|
|
|
3240
|
+
/**
|
|
3241
|
+
* Execute a group of direct (in-process) tool calls with interrupt-safe
|
|
3242
|
+
* ordering, returning outputs aligned 1:1 with `directCalls`.
|
|
3243
|
+
*
|
|
3244
|
+
* Fast path (the common case): when no call in the group is in
|
|
3245
|
+
* `interruptingToolNames`, this is a single `Promise.all` — byte-for-byte
|
|
3246
|
+
* the prior behavior, so ordinary batches are unaffected.
|
|
3247
|
+
*
|
|
3248
|
+
* Interrupt-safe path: when the group contains an interrupting tool (e.g.
|
|
3249
|
+
* `ask_user_question`, whose body raises a LangGraph `interrupt()` to
|
|
3250
|
+
* collect a human answer), the interrupting calls run as their own awaited
|
|
3251
|
+
* group **first**; only after they all settle without interrupting do the
|
|
3252
|
+
* remaining (potentially non-idempotent) siblings run. If an interrupting
|
|
3253
|
+
* call throws a `GraphInterrupt`, the `await` below rejects and unwinds the
|
|
3254
|
+
* whole ToolNode *before* any non-interrupting sibling has started — so a
|
|
3255
|
+
* sibling with real side effects (send_email, billing) never executes on
|
|
3256
|
+
* the first pass. On the resume pass LangGraph re-runs the batch from the
|
|
3257
|
+
* top; the interrupting tool resolves with the host's answer instead of
|
|
3258
|
+
* throwing, and the siblings execute for the FIRST time, exactly once.
|
|
3259
|
+
*
|
|
3260
|
+
* Without this ordering, a flat `Promise.all` starts every sibling
|
|
3261
|
+
* concurrently, so a non-idempotent sibling can complete its side effect
|
|
3262
|
+
* before the interrupt unwinds and then run a SECOND time on resume — the
|
|
3263
|
+
* duplicate side effect this method exists to prevent. Interrupting tools
|
|
3264
|
+
* are expected to be side-effect-free (they only suspend), so running them
|
|
3265
|
+
* as a group and re-running them on resume is harmless.
|
|
3266
|
+
*
|
|
3267
|
+
* `batchIndices[i]` is `directCalls[i]`'s position within the parent
|
|
3268
|
+
* ToolNode batch (used for `{{tool<i>turn<n>}}` registration); it is
|
|
3269
|
+
* preserved regardless of execution order. `baseContext` carries the
|
|
3270
|
+
* batch-scoped fields every call shares; `batchIndex` is filled in
|
|
3271
|
+
* per-call here.
|
|
3272
|
+
*/
|
|
3273
|
+
private async runDirectBatchInterruptSafe(
|
|
3274
|
+
directCalls: ToolCall[],
|
|
3275
|
+
batchIndices: number[],
|
|
3276
|
+
config: RunnableConfig,
|
|
3277
|
+
baseContext: Omit<RunToolBatchContext<T>, 'batchIndex'>
|
|
3278
|
+
): Promise<(BaseMessage | Command)[]> {
|
|
3279
|
+
const runOne = (
|
|
3280
|
+
call: ToolCall,
|
|
3281
|
+
position: number
|
|
3282
|
+
): Promise<BaseMessage | Command> =>
|
|
3283
|
+
this.runDirectToolWithLifecycleHooks(call, config, {
|
|
3284
|
+
...baseContext,
|
|
3285
|
+
batchIndex: batchIndices[position],
|
|
3286
|
+
});
|
|
3287
|
+
|
|
3288
|
+
const interrupting = this.interruptingToolNames;
|
|
3289
|
+
const hasInterrupting =
|
|
3290
|
+
interrupting != null &&
|
|
3291
|
+
directCalls.some((call) => interrupting.has(call.name));
|
|
3292
|
+
|
|
3293
|
+
if (!hasInterrupting) {
|
|
3294
|
+
return Promise.all(directCalls.map((call, i) => runOne(call, i)));
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
const outputs: (BaseMessage | Command)[] = new Array(directCalls.length);
|
|
3298
|
+
const interruptingPositions: number[] = [];
|
|
3299
|
+
const regularPositions: number[] = [];
|
|
3300
|
+
for (let i = 0; i < directCalls.length; i++) {
|
|
3301
|
+
if (interrupting.has(directCalls[i].name)) {
|
|
3302
|
+
interruptingPositions.push(i);
|
|
3303
|
+
} else {
|
|
3304
|
+
regularPositions.push(i);
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
// Interrupting group first. A GraphInterrupt here propagates out of the
|
|
3309
|
+
// `await` before any regular sibling is dispatched below.
|
|
3310
|
+
const interruptingOutputs = await Promise.all(
|
|
3311
|
+
interruptingPositions.map((i) => runOne(directCalls[i], i))
|
|
3312
|
+
);
|
|
3313
|
+
interruptingPositions.forEach((i, k) => {
|
|
3314
|
+
outputs[i] = interruptingOutputs[k];
|
|
3315
|
+
});
|
|
3316
|
+
|
|
3317
|
+
// No interrupting call suspended — safe to run the remaining siblings.
|
|
3318
|
+
const regularOutputs = await Promise.all(
|
|
3319
|
+
regularPositions.map((i) => runOne(directCalls[i], i))
|
|
3320
|
+
);
|
|
3321
|
+
regularPositions.forEach((i, k) => {
|
|
3322
|
+
outputs[i] = regularOutputs[k];
|
|
3323
|
+
});
|
|
3324
|
+
|
|
3325
|
+
return outputs;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3183
3328
|
/**
|
|
3184
3329
|
* Execute all tool calls via ON_TOOL_EXECUTE event dispatch.
|
|
3185
3330
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
@@ -3426,18 +3571,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3426
3571
|
const directAdditionalContexts: string[] = [];
|
|
3427
3572
|
const directOutputs: (BaseMessage | Command)[] =
|
|
3428
3573
|
directCalls.length > 0
|
|
3429
|
-
? await
|
|
3430
|
-
directCalls
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3574
|
+
? await this.runDirectBatchInterruptSafe(
|
|
3575
|
+
directCalls,
|
|
3576
|
+
directIndices,
|
|
3577
|
+
config,
|
|
3578
|
+
{
|
|
3579
|
+
turn,
|
|
3580
|
+
batchScopeId,
|
|
3581
|
+
resolvedArgsByCallId,
|
|
3582
|
+
preBatchSnapshot,
|
|
3583
|
+
additionalContextsSink: directAdditionalContexts,
|
|
3584
|
+
runInput: input as T,
|
|
3585
|
+
}
|
|
3441
3586
|
)
|
|
3442
3587
|
: [];
|
|
3443
3588
|
|
|
@@ -3491,18 +3636,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3491
3636
|
const preBatchSnapshot =
|
|
3492
3637
|
this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
3493
3638
|
const directAdditionalContexts: string[] = [];
|
|
3494
|
-
const toolOutputs = await
|
|
3495
|
-
filteredCalls
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3639
|
+
const toolOutputs = await this.runDirectBatchInterruptSafe(
|
|
3640
|
+
filteredCalls,
|
|
3641
|
+
filteredCalls.map((_call, i) => i),
|
|
3642
|
+
config,
|
|
3643
|
+
{
|
|
3644
|
+
turn,
|
|
3645
|
+
batchScopeId,
|
|
3646
|
+
resolvedArgsByCallId,
|
|
3647
|
+
preBatchSnapshot,
|
|
3648
|
+
additionalContextsSink: directAdditionalContexts,
|
|
3649
|
+
runInput: input as T,
|
|
3650
|
+
}
|
|
3506
3651
|
);
|
|
3507
3652
|
await this.handleRunToolCompletions(
|
|
3508
3653
|
filteredCalls,
|
|
@@ -3996,6 +3996,54 @@ describe('AskUserQuestion — interrupt + resume', () => {
|
|
|
3996
3996
|
expect(resumedAnswer).toBe('production');
|
|
3997
3997
|
});
|
|
3998
3998
|
|
|
3999
|
+
it('carries multiSelect through the interrupt payload and resumes with the joined option values', async () => {
|
|
4000
|
+
const { askUserQuestion } = await import('@/hitl');
|
|
4001
|
+
|
|
4002
|
+
let resumedAnswer: string | undefined;
|
|
4003
|
+
|
|
4004
|
+
const builder = new StateGraph(MessagesAnnotation)
|
|
4005
|
+
.addNode('clarifier', () => {
|
|
4006
|
+
const resolution = askUserQuestion({
|
|
4007
|
+
question: 'Which environments?',
|
|
4008
|
+
options: [
|
|
4009
|
+
{ label: 'Staging', value: 'staging' },
|
|
4010
|
+
{ label: 'Production', value: 'production' },
|
|
4011
|
+
],
|
|
4012
|
+
multiSelect: true,
|
|
4013
|
+
});
|
|
4014
|
+
resumedAnswer = resolution.answer;
|
|
4015
|
+
return { messages: [] };
|
|
4016
|
+
})
|
|
4017
|
+
.addEdge(START, 'clarifier')
|
|
4018
|
+
.addEdge('clarifier', END);
|
|
4019
|
+
const graph = builder.compile({ checkpointer: new MemorySaver() });
|
|
4020
|
+
|
|
4021
|
+
const config = { configurable: { thread_id: 'ask-q-multi-thread' } };
|
|
4022
|
+
|
|
4023
|
+
const interrupted = (await graph.invoke({ messages: [] }, config)) as {
|
|
4024
|
+
__interrupt__?: Array<{ id?: string; value?: t.HumanInterruptPayload }>;
|
|
4025
|
+
};
|
|
4026
|
+
const payload = interrupted.__interrupt__![0].value!;
|
|
4027
|
+
if (payload.type !== 'ask_user_question') {
|
|
4028
|
+
throw new Error('expected ask_user_question');
|
|
4029
|
+
}
|
|
4030
|
+
expect(payload.question.multiSelect).toBe(true);
|
|
4031
|
+
expect(payload.question.options).toHaveLength(2);
|
|
4032
|
+
|
|
4033
|
+
// Host joins the selected option values with ", ".
|
|
4034
|
+
const resolution: t.AskUserQuestionResolution = {
|
|
4035
|
+
answer: 'staging, production',
|
|
4036
|
+
};
|
|
4037
|
+
await resumeGraph(
|
|
4038
|
+
graph as unknown as CompiledMessagesGraph,
|
|
4039
|
+
interrupted,
|
|
4040
|
+
resolution,
|
|
4041
|
+
config
|
|
4042
|
+
);
|
|
4043
|
+
|
|
4044
|
+
expect(resumedAnswer).toBe('staging, production');
|
|
4045
|
+
});
|
|
4046
|
+
|
|
3999
4047
|
it('a DIRECT tool in event-driven mode can raise ask_user_question from its body and resume with the answer as its ToolMessage', async () => {
|
|
4000
4048
|
/**
|
|
4001
4049
|
* The production host shape (e.g. LibreChat's `AgentInputs.graphTools`
|
package/src/types/hitl.ts
CHANGED
|
@@ -140,6 +140,13 @@ export interface AskUserQuestionRequest {
|
|
|
140
140
|
* UI allows it. Omit to require a free-form answer.
|
|
141
141
|
*/
|
|
142
142
|
options?: AskUserQuestionOption[];
|
|
143
|
+
/**
|
|
144
|
+
* When `true`, the host UI may let the user pick several options; the
|
|
145
|
+
* resulting `AskUserQuestionResolution.answer` is the selected option
|
|
146
|
+
* values joined by `", "`. When omitted or `false`, hosts render a
|
|
147
|
+
* single-select picker. Only meaningful alongside `options`.
|
|
148
|
+
*/
|
|
149
|
+
multiSelect?: boolean;
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
/**
|
|
@@ -168,9 +175,10 @@ export type HumanInterruptPayload =
|
|
|
168
175
|
export interface AskUserQuestionResolution {
|
|
169
176
|
/**
|
|
170
177
|
* The human's answer. Free-form text, or — when `options` were
|
|
171
|
-
* provided — one of the option `value`s
|
|
172
|
-
*
|
|
173
|
-
*
|
|
178
|
+
* provided — one of the option `value`s (or, when the request set
|
|
179
|
+
* `multiSelect`, several option `value`s joined by `", "`). Hosts may
|
|
180
|
+
* also send any structured object their custom UI defines; see the
|
|
181
|
+
* host docs for what your downstream consumer expects.
|
|
174
182
|
*/
|
|
175
183
|
answer: string;
|
|
176
184
|
}
|
|
@@ -287,6 +295,44 @@ export function isAskUserQuestionInterrupt(
|
|
|
287
295
|
* an interrupt. This applies equally to direct tools (handoffs,
|
|
288
296
|
* subagents) and to event tools.
|
|
289
297
|
*
|
|
298
|
+
* ### Guarding non-idempotent siblings via `interruptingToolNames`
|
|
299
|
+
*
|
|
300
|
+
* The "must be idempotent" rule above is unavoidable in the general
|
|
301
|
+
* case, but the SDK can protect siblings against the one interrupt
|
|
302
|
+
* shape it can predict: a tool whose *body* raises `interrupt()`
|
|
303
|
+
* mid-execution — the `ask_user_question` shape, where the tool
|
|
304
|
+
* suspends the run to collect a human answer. Declare such tools in
|
|
305
|
+
* `RunConfig.interruptingToolNames`
|
|
306
|
+
* ({@link ToolNodeOptions.interruptingToolNames}) and the ToolNode
|
|
307
|
+
* schedules them, within each batch, **ahead of** their
|
|
308
|
+
* non-interrupting direct siblings. When one interrupts, the batch
|
|
309
|
+
* unwinds before any declared-safe sibling has run, so the sibling
|
|
310
|
+
* executes exactly once (on resume) instead of twice. Empirically:
|
|
311
|
+
*
|
|
312
|
+
* - A **direct** sibling sharing the interrupter's in-process
|
|
313
|
+
* `Promise.all` is the only shape that double-executes; declaring
|
|
314
|
+
* the interrupter closes it.
|
|
315
|
+
* - An **event-dispatched** sibling is already safe without any
|
|
316
|
+
* config: the ToolNode awaits the whole direct group (where the
|
|
317
|
+
* body interrupt unwinds) before it dispatches event tools, so a
|
|
318
|
+
* dispatched sibling never runs on the first pass.
|
|
319
|
+
*
|
|
320
|
+
* This is a *scheduling* guard, not full resume idempotency: it only
|
|
321
|
+
* covers tools that interrupt from their own body and only protects
|
|
322
|
+
* siblings scheduled after them. It does not retroactively make a
|
|
323
|
+
* `PreToolUse` `'ask'` gate on tool B stop tool A (already executed)
|
|
324
|
+
* from re-running — unless B is itself declared interrupting, so it
|
|
325
|
+
* runs first. Tools with side effects should still be written
|
|
326
|
+
* idempotent as defense in depth.
|
|
327
|
+
*
|
|
328
|
+
* The guard only REORDERS the direct group — declaring a name does not
|
|
329
|
+
* force it onto the direct path. The interrupting tool must already be a
|
|
330
|
+
* real in-process graphTool (the only kind whose body can reach
|
|
331
|
+
* `interrupt()`). A name that resolves to a schema-only event stub (an
|
|
332
|
+
* inherited `toolDefinition` with no executable instance, e.g. in a
|
|
333
|
+
* self-spawned child that scrubs `graphTools`) stays event-dispatched
|
|
334
|
+
* and the ordering is a no-op for it.
|
|
335
|
+
*
|
|
290
336
|
* ## Note on idempotency
|
|
291
337
|
*
|
|
292
338
|
* Same root cause as the resume re-execution above: LangGraph
|
package/src/types/run.ts
CHANGED
|
@@ -193,6 +193,27 @@ export type RunConfig = {
|
|
|
193
193
|
* the SDK stays name-agnostic.
|
|
194
194
|
*/
|
|
195
195
|
codeSessionToolNames?: string[];
|
|
196
|
+
/**
|
|
197
|
+
* Names of host tools whose in-process body may raise a LangGraph
|
|
198
|
+
* `interrupt()` mid-execution — the canonical example is an
|
|
199
|
+
* `ask_user_question` tool that suspends the run to collect a human
|
|
200
|
+
* answer. Within a single tool-call batch, a named tool that is a real
|
|
201
|
+
* in-process graphTool (the only kind whose body can reach `interrupt()`;
|
|
202
|
+
* graphTools are auto-marked direct) is scheduled **ahead of** its
|
|
203
|
+
* non-interrupting direct siblings. That ordering guarantees a mid-body
|
|
204
|
+
* interrupt unwinds the tool batch before a non-idempotent sibling
|
|
205
|
+
* (send_email, billing) executes, so the sibling cannot run once on the
|
|
206
|
+
* first pass and AGAIN when LangGraph re-runs the interrupted batch on
|
|
207
|
+
* resume.
|
|
208
|
+
*
|
|
209
|
+
* This only reorders the direct group — it does NOT force a name onto
|
|
210
|
+
* the direct path. A name that is only an inherited event `toolDefinition`
|
|
211
|
+
* (schema-only stub, e.g. in a self-spawned child) stays event-dispatched;
|
|
212
|
+
* the guard applies only to tools that are independently direct.
|
|
213
|
+
* Host-declared so the SDK stays name-agnostic; omit to keep the prior
|
|
214
|
+
* (unguarded) behavior.
|
|
215
|
+
*/
|
|
216
|
+
interruptingToolNames?: string[];
|
|
196
217
|
/**
|
|
197
218
|
* Selects the execution backend for built-in code tools. Omit this to keep
|
|
198
219
|
* the remote LibreChat Code API sandbox. Set `{ engine: 'local' }` to run
|
package/src/types/tools.ts
CHANGED
|
@@ -89,10 +89,18 @@ export type ToolNodeOptions = {
|
|
|
89
89
|
handleToolErrors?: boolean;
|
|
90
90
|
loadRuntimeTools?: ToolRefGenerator;
|
|
91
91
|
toolCallStepIds?: Map<string, string>;
|
|
92
|
+
/**
|
|
93
|
+
* Dispatches the error completion event for a failed tool call. Returns
|
|
94
|
+
* whether the event was actually dispatched — `false` (e.g. no run step is
|
|
95
|
+
* registered for the call yet, which happens when a tool fails fast on a
|
|
96
|
+
* resume pass) tells the ToolNode to fall back to its own completion
|
|
97
|
+
* dispatch for the error ToolMessage. A `void` resolution is treated as
|
|
98
|
+
* dispatched for backward compatibility.
|
|
99
|
+
*/
|
|
92
100
|
errorHandler?: (
|
|
93
101
|
data: ToolErrorData,
|
|
94
102
|
metadata?: Record<string, unknown>
|
|
95
|
-
) => Promise<void>;
|
|
103
|
+
) => Promise<boolean | void>;
|
|
96
104
|
/** Tool registry for lazy computation of programmatic tools and tool search */
|
|
97
105
|
toolRegistry?: LCToolRegistry;
|
|
98
106
|
/** Reference to Graph's sessions map for automatic session injection */
|
|
@@ -108,6 +116,34 @@ export type ToolNodeOptions = {
|
|
|
108
116
|
executingAgentId?: string;
|
|
109
117
|
/** Tool names that must be executed directly (via runTool) even in event-driven mode (e.g., graph-managed handoff tools) */
|
|
110
118
|
directToolNames?: Set<string>;
|
|
119
|
+
/**
|
|
120
|
+
* Tool names whose in-process body may raise a LangGraph `interrupt()`
|
|
121
|
+
* mid-execution — e.g. an `ask_user_question` tool that suspends the
|
|
122
|
+
* run to collect a human answer.
|
|
123
|
+
*
|
|
124
|
+
* Within a single tool-call batch, a named tool that is *already*
|
|
125
|
+
* direct (a real in-process graphTool — the only kind whose body can
|
|
126
|
+
* reach `interrupt()`; graphTools are auto-marked direct by the graph)
|
|
127
|
+
* is executed as its own awaited group **before** its non-interrupting
|
|
128
|
+
* direct siblings. If it interrupts, the ToolNode unwinds before any
|
|
129
|
+
* sibling runs, so a non-idempotent sibling (send_email, billing)
|
|
130
|
+
* cannot execute once on the first pass and AGAIN when LangGraph re-runs
|
|
131
|
+
* the interrupted batch on resume.
|
|
132
|
+
*
|
|
133
|
+
* This set only REORDERS the direct group; it does NOT promote a name
|
|
134
|
+
* into it. A name that resolves to a schema-only event stub (an
|
|
135
|
+
* inherited `toolDefinition` with no executable instance — e.g. in a
|
|
136
|
+
* self-spawned child that scrubs `graphTools`) stays event-dispatched.
|
|
137
|
+
* Forcing such a name direct would invoke the stub, which throws
|
|
138
|
+
* "should not be invoked directly in event-driven mode". For the guard
|
|
139
|
+
* to apply, the interrupting tool must independently be direct.
|
|
140
|
+
*
|
|
141
|
+
* Opt-in and empty by default: when unset (or when no direct batch call
|
|
142
|
+
* matches), direct-batch execution is byte-for-byte unchanged. See the
|
|
143
|
+
* "Resume re-execution" section of {@link HumanInTheLoopConfig} for the
|
|
144
|
+
* batch re-execution contract this guards against.
|
|
145
|
+
*/
|
|
146
|
+
interruptingToolNames?: Set<string>;
|
|
111
147
|
/** Opt-in eager execution for event-driven tool calls. */
|
|
112
148
|
eagerEventToolExecution?: EagerEventToolExecutionConfig;
|
|
113
149
|
/**
|