@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/tools/ToolNode.ts
CHANGED
|
@@ -37,6 +37,11 @@ import type {
|
|
|
37
37
|
PostToolBatchEntry,
|
|
38
38
|
} from '@/hooks';
|
|
39
39
|
import type * as t from '@/types';
|
|
40
|
+
import {
|
|
41
|
+
buildToolExecutionRequestPlan,
|
|
42
|
+
resolveRuntimeSessionHint,
|
|
43
|
+
recordArgsEqual,
|
|
44
|
+
} from '@/tools/eagerEventExecution';
|
|
40
45
|
import {
|
|
41
46
|
resolveLangfuseRuntimeScope,
|
|
42
47
|
withLangfuseRuntimeScope,
|
|
@@ -45,10 +50,6 @@ import {
|
|
|
45
50
|
buildReferenceKey,
|
|
46
51
|
ToolOutputReferenceRegistry,
|
|
47
52
|
} from '@/tools/toolOutputReferences';
|
|
48
|
-
import {
|
|
49
|
-
buildToolExecutionRequestPlan,
|
|
50
|
-
recordArgsEqual,
|
|
51
|
-
} from '@/tools/eagerEventExecution';
|
|
52
53
|
import {
|
|
53
54
|
calculateMaxToolResultChars,
|
|
54
55
|
truncateToolResultContent,
|
|
@@ -415,6 +416,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
415
416
|
private agentLangfuse?: t.LangfuseConfig;
|
|
416
417
|
toolCallStepIds?: Map<string, string>;
|
|
417
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();
|
|
418
426
|
private toolUsageCount: Map<string, number>;
|
|
419
427
|
/** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */
|
|
420
428
|
private toolCallTurns: Map<string, number> = new Map();
|
|
@@ -456,6 +464,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
456
464
|
private executingAgentId?: string;
|
|
457
465
|
/** Tool names that bypass event dispatch and execute directly (e.g., graph-managed handoff tools) */
|
|
458
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>;
|
|
459
482
|
/**
|
|
460
483
|
* File checkpointer extracted from the local coding tool bundle when
|
|
461
484
|
* `toolExecution.local.fileCheckpointing === true`. Exposed via
|
|
@@ -517,6 +540,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
517
540
|
agentId,
|
|
518
541
|
executingAgentId,
|
|
519
542
|
directToolNames,
|
|
543
|
+
interruptingToolNames,
|
|
520
544
|
codeSessionToolNames,
|
|
521
545
|
maxContextTokens,
|
|
522
546
|
maxToolResultChars,
|
|
@@ -555,6 +579,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
555
579
|
// existing agentId option) still get attribution without knowing the new option.
|
|
556
580
|
this.executingAgentId = executingAgentId ?? agentId;
|
|
557
581
|
this.directToolNames = directToolNames;
|
|
582
|
+
this.interruptingToolNames =
|
|
583
|
+
interruptingToolNames != null && interruptingToolNames.size > 0
|
|
584
|
+
? interruptingToolNames
|
|
585
|
+
: undefined;
|
|
558
586
|
this.codeSessionToolNames =
|
|
559
587
|
codeSessionToolNames != null && codeSessionToolNames.length > 0
|
|
560
588
|
? new Set(codeSessionToolNames)
|
|
@@ -997,6 +1025,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
997
1025
|
);
|
|
998
1026
|
}
|
|
999
1027
|
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Stateful runtime session hint — orthogonal to the transient
|
|
1031
|
+
* exec-session above, and injected independently (a first call has a
|
|
1032
|
+
* hint but no exec session yet). Explicit host hint wins; otherwise
|
|
1033
|
+
* fall back to the conversation's thread_id.
|
|
1034
|
+
*/
|
|
1035
|
+
const runtimeSessionHint = this.resolveRuntimeSessionHint(config);
|
|
1036
|
+
if (runtimeSessionHint != null) {
|
|
1037
|
+
invokeParams = {
|
|
1038
|
+
...invokeParams,
|
|
1039
|
+
_runtime_session_hint: runtimeSessionHint,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1000
1042
|
}
|
|
1001
1043
|
|
|
1002
1044
|
/**
|
|
@@ -1125,7 +1167,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1125
1167
|
}
|
|
1126
1168
|
if (this.errorHandler) {
|
|
1127
1169
|
try {
|
|
1128
|
-
await this.errorHandler(
|
|
1170
|
+
const dispatched = await this.errorHandler(
|
|
1129
1171
|
{
|
|
1130
1172
|
error: e,
|
|
1131
1173
|
id: call.id!,
|
|
@@ -1134,7 +1176,24 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1134
1176
|
},
|
|
1135
1177
|
config.metadata
|
|
1136
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
|
+
}
|
|
1137
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.
|
|
1138
1197
|
// eslint-disable-next-line no-console
|
|
1139
1198
|
console.error('Error in errorHandler:', {
|
|
1140
1199
|
toolName: call.name,
|
|
@@ -1783,6 +1842,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1783
1842
|
);
|
|
1784
1843
|
}
|
|
1785
1844
|
|
|
1845
|
+
/** Delegates to the shared resolver so the direct and event-driven planning
|
|
1846
|
+
* paths derive the runtime session hint identically. */
|
|
1847
|
+
private resolveRuntimeSessionHint(
|
|
1848
|
+
config: RunnableConfig
|
|
1849
|
+
): string | undefined {
|
|
1850
|
+
return resolveRuntimeSessionHint(
|
|
1851
|
+
this.toolExecution,
|
|
1852
|
+
config.configurable?.thread_id as string | undefined
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1786
1856
|
private storeCodeSessionFromResults(
|
|
1787
1857
|
results: t.ToolExecuteResult[],
|
|
1788
1858
|
requestMap: Map<string, t.ToolCallRequest>
|
|
@@ -1849,9 +1919,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1849
1919
|
const toolCallId = call.id ?? '';
|
|
1850
1920
|
|
|
1851
1921
|
// Skip error ToolMessages when errorHandler already dispatched ON_RUN_STEP_COMPLETED
|
|
1852
|
-
// 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.
|
|
1853
1927
|
if (toolMessage.status === 'error' && this.errorHandler != null) {
|
|
1854
|
-
|
|
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
|
+
}
|
|
1855
1938
|
}
|
|
1856
1939
|
|
|
1857
1940
|
if (this.sessions && this.participatesInCodeSession(call.name)) {
|
|
@@ -2492,12 +2575,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
2492
2575
|
entry.call.name === Constants.READ_FILE
|
|
2493
2576
|
? this.getCodeSessionContext()
|
|
2494
2577
|
: undefined;
|
|
2578
|
+
const runtimeSessionHint = this.participatesInCodeSession(
|
|
2579
|
+
entry.call.name
|
|
2580
|
+
)
|
|
2581
|
+
? this.resolveRuntimeSessionHint(config)
|
|
2582
|
+
: undefined;
|
|
2495
2583
|
return {
|
|
2496
2584
|
id: entry.call.id,
|
|
2497
2585
|
name: entry.call.name,
|
|
2498
2586
|
args: entry.args,
|
|
2499
2587
|
stepId: entry.stepId,
|
|
2500
2588
|
codeSessionContext,
|
|
2589
|
+
runtimeSessionHint,
|
|
2501
2590
|
};
|
|
2502
2591
|
}),
|
|
2503
2592
|
usageCount: this.toolUsageCount,
|
|
@@ -3148,6 +3237,94 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3148
3237
|
return converted;
|
|
3149
3238
|
}
|
|
3150
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
|
+
|
|
3151
3328
|
/**
|
|
3152
3329
|
* Execute all tool calls via ON_TOOL_EXECUTE event dispatch.
|
|
3153
3330
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
@@ -3394,18 +3571,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3394
3571
|
const directAdditionalContexts: string[] = [];
|
|
3395
3572
|
const directOutputs: (BaseMessage | Command)[] =
|
|
3396
3573
|
directCalls.length > 0
|
|
3397
|
-
? await
|
|
3398
|
-
directCalls
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
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
|
+
}
|
|
3409
3586
|
)
|
|
3410
3587
|
: [];
|
|
3411
3588
|
|
|
@@ -3459,18 +3636,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3459
3636
|
const preBatchSnapshot =
|
|
3460
3637
|
this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
3461
3638
|
const directAdditionalContexts: string[] = [];
|
|
3462
|
-
const toolOutputs = await
|
|
3463
|
-
filteredCalls
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
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
|
+
}
|
|
3474
3651
|
);
|
|
3475
3652
|
await this.handleRunToolCompletions(
|
|
3476
3653
|
filteredCalls,
|
|
@@ -2,8 +2,10 @@ import { describe, it, expect } from '@jest/globals';
|
|
|
2
2
|
import {
|
|
3
3
|
BashExecutionToolDescription,
|
|
4
4
|
BashToolOutputReferencesGuide,
|
|
5
|
+
StatefulBashExecutionToolDescription,
|
|
5
6
|
buildBashExecutionToolDescription,
|
|
6
7
|
} from '../BashExecutor';
|
|
8
|
+
import { CODE_ARTIFACT_PATH_GUIDANCE } from '../CodeExecutor';
|
|
7
9
|
|
|
8
10
|
describe('buildBashExecutionToolDescription', () => {
|
|
9
11
|
it('returns the base description by default', () => {
|
|
@@ -55,4 +57,41 @@ describe('buildBashExecutionToolDescription', () => {
|
|
|
55
57
|
});
|
|
56
58
|
expect(composed.includes(`${BashExecutionToolDescription}\n\n`)).toBe(true);
|
|
57
59
|
});
|
|
60
|
+
|
|
61
|
+
describe('stateful variant', () => {
|
|
62
|
+
it('selects the stateful description when statefulSessions is on', () => {
|
|
63
|
+
expect(
|
|
64
|
+
buildBashExecutionToolDescription({ statefulSessions: true })
|
|
65
|
+
).toBe(StatefulBashExecutionToolDescription);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('hedges: usually-persists but may-reset, and only /mnt/data is durable', () => {
|
|
69
|
+
const d = StatefulBashExecutionToolDescription;
|
|
70
|
+
expect(d).toContain('usually');
|
|
71
|
+
expect(d).toContain('may be reset');
|
|
72
|
+
expect(d).toContain('Only /mnt/data is durable');
|
|
73
|
+
/* filesystem-tier, not shell-variable-tier */
|
|
74
|
+
expect(d).toContain('do not rely on shell variables');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps the artifact-path guidance in both variants', () => {
|
|
78
|
+
expect(BashExecutionToolDescription).toContain(
|
|
79
|
+
CODE_ARTIFACT_PATH_GUIDANCE
|
|
80
|
+
);
|
|
81
|
+
expect(StatefulBashExecutionToolDescription).toContain(
|
|
82
|
+
CODE_ARTIFACT_PATH_GUIDANCE
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('still composes with the output-references guide', () => {
|
|
87
|
+
const composed = buildBashExecutionToolDescription({
|
|
88
|
+
statefulSessions: true,
|
|
89
|
+
enableToolOutputReferences: true,
|
|
90
|
+
});
|
|
91
|
+
expect(composed.startsWith(StatefulBashExecutionToolDescription)).toBe(
|
|
92
|
+
true
|
|
93
|
+
);
|
|
94
|
+
expect(composed).toContain(BashToolOutputReferencesGuide);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
58
97
|
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, jest, afterEach } from '@jest/globals';
|
|
2
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
3
|
+
|
|
4
|
+
/* CodeExecutor imports the default export of node-fetch; capture POST bodies. */
|
|
5
|
+
const fetchCalls: Array<Record<string, unknown>> = [];
|
|
6
|
+
jest.mock('node-fetch', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: async (_url: string, init?: { body?: string }) => {
|
|
9
|
+
fetchCalls.push(JSON.parse(init?.body ?? '{}'));
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
json: async () => ({
|
|
13
|
+
session_id: 's1',
|
|
14
|
+
stdout: 'ok',
|
|
15
|
+
stderr: '',
|
|
16
|
+
files: [],
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
CODE_ARTIFACT_PATH_GUIDANCE,
|
|
24
|
+
CodeExecutionToolDescription,
|
|
25
|
+
StatefulCodeExecutionToolDescription,
|
|
26
|
+
buildCodeExecutionToolDescription,
|
|
27
|
+
buildCodeExecutionToolSchema,
|
|
28
|
+
createCodeExecutionTool,
|
|
29
|
+
} from '../CodeExecutor';
|
|
30
|
+
|
|
31
|
+
describe('CodeExecutor stateful description', () => {
|
|
32
|
+
it('selects the stateful description only when statefulSessions is on', () => {
|
|
33
|
+
expect(buildCodeExecutionToolDescription()).toBe(
|
|
34
|
+
CodeExecutionToolDescription
|
|
35
|
+
);
|
|
36
|
+
expect(buildCodeExecutionToolDescription({ statefulSessions: false })).toBe(
|
|
37
|
+
CodeExecutionToolDescription
|
|
38
|
+
);
|
|
39
|
+
expect(buildCodeExecutionToolDescription({ statefulSessions: true })).toBe(
|
|
40
|
+
StatefulCodeExecutionToolDescription
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('hedges the stateful wording and keeps /mnt/data as the durable store', () => {
|
|
45
|
+
const d = StatefulCodeExecutionToolDescription;
|
|
46
|
+
expect(d).toContain('usually share one runtime');
|
|
47
|
+
expect(d).toContain('may be reset at any time');
|
|
48
|
+
expect(d).toContain('MUST be written to /mnt/data');
|
|
49
|
+
expect(d).toContain(CODE_ARTIFACT_PATH_GUIDANCE);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('adjusts the code-param note per mode', () => {
|
|
53
|
+
const stateless =
|
|
54
|
+
buildCodeExecutionToolSchema().properties.code.description;
|
|
55
|
+
const stateful = buildCodeExecutionToolSchema({ statefulSessions: true })
|
|
56
|
+
.properties.code.description;
|
|
57
|
+
expect(stateless).toContain('variables and imports don\'t persist');
|
|
58
|
+
expect(stateful).toContain('typically still defined');
|
|
59
|
+
expect(stateful).toContain('may reset between calls');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('CodeExecutor runtime_session_hint wire field', () => {
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
fetchCalls.length = 0;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function run(
|
|
69
|
+
toolCall: Record<string, unknown>,
|
|
70
|
+
params: Record<string, unknown> = {}
|
|
71
|
+
): Promise<Record<string, unknown>[]> {
|
|
72
|
+
const codeTool = createCodeExecutionTool(params);
|
|
73
|
+
await codeTool.invoke({ lang: 'py', code: 'print(1)' }, {
|
|
74
|
+
toolCall,
|
|
75
|
+
} as unknown as RunnableConfig);
|
|
76
|
+
return fetchCalls;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it('sends runtime_session_hint when ToolNode injected it', async () => {
|
|
80
|
+
const calls = await run({ _runtime_session_hint: 'conv-42' });
|
|
81
|
+
expect(calls[0].runtime_session_hint).toBe('conv-42');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('omits runtime_session_hint when not injected', async () => {
|
|
85
|
+
const calls = await run({});
|
|
86
|
+
expect('runtime_session_hint' in calls[0]).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not leak the statefulSessions factory flag onto the wire', async () => {
|
|
90
|
+
const calls = await run({}, { statefulSessions: true });
|
|
91
|
+
expect('statefulSessions' in calls[0]).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('strips a model-supplied runtime_session_hint from the raw args', async () => {
|
|
95
|
+
const codeTool = createCodeExecutionTool({});
|
|
96
|
+
await codeTool.invoke(
|
|
97
|
+
{ lang: 'py', code: 'print(1)', runtime_session_hint: 'model-picked' },
|
|
98
|
+
{ toolCall: {} } as unknown as RunnableConfig
|
|
99
|
+
);
|
|
100
|
+
expect('runtime_session_hint' in fetchCalls[0]).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('lets only the ToolNode-injected hint reach the wire, ignoring model args', async () => {
|
|
104
|
+
const codeTool = createCodeExecutionTool({});
|
|
105
|
+
await codeTool.invoke(
|
|
106
|
+
{ lang: 'py', code: 'print(1)', runtime_session_hint: 'model-picked' },
|
|
107
|
+
{
|
|
108
|
+
toolCall: { _runtime_session_hint: 'host-conv' },
|
|
109
|
+
} as unknown as RunnableConfig
|
|
110
|
+
);
|
|
111
|
+
expect(fetchCalls[0].runtime_session_hint).toBe('host-conv');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1263,4 +1263,90 @@ describe('ToolNode code execution session management', () => {
|
|
|
1263
1263
|
);
|
|
1264
1264
|
});
|
|
1265
1265
|
});
|
|
1266
|
+
|
|
1267
|
+
describe('stateful runtime session hint injection', () => {
|
|
1268
|
+
it('injects the explicit runtimeSessionHint when statefulSessions is on', async () => {
|
|
1269
|
+
const capturedConfigs: Record<string, unknown>[] = [];
|
|
1270
|
+
const mockTool = createMockCodeTool({ capturedConfigs });
|
|
1271
|
+
const toolNode = new ToolNode({
|
|
1272
|
+
tools: [mockTool],
|
|
1273
|
+
sessions: new Map(),
|
|
1274
|
+
toolExecution: {
|
|
1275
|
+
engine: 'sandbox',
|
|
1276
|
+
sandbox: {
|
|
1277
|
+
statefulSessions: true,
|
|
1278
|
+
runtimeSessionHint: 'conv-explicit',
|
|
1279
|
+
},
|
|
1280
|
+
},
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
await toolNode.invoke({ messages: [createAIMessageWithCodeCall('c1')] });
|
|
1284
|
+
|
|
1285
|
+
expect(capturedConfigs[0]._runtime_session_hint).toBe('conv-explicit');
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it('falls back to configurable.thread_id when no explicit hint is given', async () => {
|
|
1289
|
+
const capturedConfigs: Record<string, unknown>[] = [];
|
|
1290
|
+
const mockTool = createMockCodeTool({ capturedConfigs });
|
|
1291
|
+
const toolNode = new ToolNode({
|
|
1292
|
+
tools: [mockTool],
|
|
1293
|
+
sessions: new Map(),
|
|
1294
|
+
toolExecution: {
|
|
1295
|
+
engine: 'sandbox',
|
|
1296
|
+
sandbox: { statefulSessions: true },
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
await toolNode.invoke(
|
|
1301
|
+
{ messages: [createAIMessageWithCodeCall('c1')] },
|
|
1302
|
+
{ configurable: { thread_id: 'thread-abc' } }
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
expect(capturedConfigs[0]._runtime_session_hint).toBe('thread-abc');
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
it('does not inject a hint when statefulSessions is off', async () => {
|
|
1309
|
+
const capturedConfigs: Record<string, unknown>[] = [];
|
|
1310
|
+
const mockTool = createMockCodeTool({ capturedConfigs });
|
|
1311
|
+
const toolNode = new ToolNode({
|
|
1312
|
+
tools: [mockTool],
|
|
1313
|
+
sessions: new Map(),
|
|
1314
|
+
toolExecution: {
|
|
1315
|
+
engine: 'sandbox',
|
|
1316
|
+
sandbox: { statefulSessions: false },
|
|
1317
|
+
},
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
await toolNode.invoke(
|
|
1321
|
+
{ messages: [createAIMessageWithCodeCall('c1')] },
|
|
1322
|
+
{ configurable: { thread_id: 'thread-abc' } }
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
expect(capturedConfigs[0]._runtime_session_hint).toBeUndefined();
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it('injects the hint independently of an existing exec session', async () => {
|
|
1329
|
+
const capturedConfigs: Record<string, unknown>[] = [];
|
|
1330
|
+
const sessions: t.ToolSessionMap = new Map();
|
|
1331
|
+
sessions.set(Constants.EXECUTE_CODE, {
|
|
1332
|
+
session_id: 'exec-1',
|
|
1333
|
+
files: [],
|
|
1334
|
+
lastUpdated: 0,
|
|
1335
|
+
});
|
|
1336
|
+
const mockTool = createMockCodeTool({ capturedConfigs });
|
|
1337
|
+
const toolNode = new ToolNode({
|
|
1338
|
+
tools: [mockTool],
|
|
1339
|
+
sessions,
|
|
1340
|
+
toolExecution: {
|
|
1341
|
+
engine: 'sandbox',
|
|
1342
|
+
sandbox: { statefulSessions: true, runtimeSessionHint: 'conv-1' },
|
|
1343
|
+
},
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
await toolNode.invoke({ messages: [createAIMessageWithCodeCall('c1')] });
|
|
1347
|
+
|
|
1348
|
+
expect(capturedConfigs[0].session_id).toBe('exec-1');
|
|
1349
|
+
expect(capturedConfigs[0]._runtime_session_hint).toBe('conv-1');
|
|
1350
|
+
});
|
|
1351
|
+
});
|
|
1266
1352
|
});
|