@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.
Files changed (67) hide show
  1. package/dist/cjs/graphs/Graph.cjs +31 -7
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/main.cjs +7 -0
  4. package/dist/cjs/run.cjs +4 -0
  5. package/dist/cjs/run.cjs.map +1 -1
  6. package/dist/cjs/stream.cjs +2 -1
  7. package/dist/cjs/stream.cjs.map +1 -1
  8. package/dist/cjs/tools/BashExecutor.cjs +58 -9
  9. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  10. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +4 -2
  11. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  12. package/dist/cjs/tools/CodeExecutor.cjs +57 -7
  13. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  14. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +9 -3
  15. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  16. package/dist/cjs/tools/ToolNode.cjs +114 -11
  17. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  18. package/dist/cjs/tools/eagerEventExecution.cjs +18 -1
  19. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
  20. package/dist/esm/graphs/Graph.mjs +31 -7
  21. package/dist/esm/graphs/Graph.mjs.map +1 -1
  22. package/dist/esm/main.mjs +3 -3
  23. package/dist/esm/run.mjs +4 -0
  24. package/dist/esm/run.mjs.map +1 -1
  25. package/dist/esm/stream.mjs +2 -1
  26. package/dist/esm/stream.mjs.map +1 -1
  27. package/dist/esm/tools/BashExecutor.mjs +56 -10
  28. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  29. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +4 -2
  30. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  31. package/dist/esm/tools/CodeExecutor.mjs +54 -8
  32. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  33. package/dist/esm/tools/ProgrammaticToolCalling.mjs +9 -3
  34. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  35. package/dist/esm/tools/ToolNode.mjs +115 -12
  36. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  37. package/dist/esm/tools/eagerEventExecution.mjs +18 -2
  38. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -1
  39. package/dist/types/graphs/Graph.d.ts +21 -3
  40. package/dist/types/run.d.ts +1 -0
  41. package/dist/types/tools/BashExecutor.d.ts +13 -0
  42. package/dist/types/tools/CodeExecutor.d.ts +14 -0
  43. package/dist/types/tools/ToolNode.d.ts +60 -1
  44. package/dist/types/tools/eagerEventExecution.d.ts +8 -0
  45. package/dist/types/types/hitl.d.ts +49 -3
  46. package/dist/types/types/run.d.ts +21 -0
  47. package/dist/types/types/tools.d.ts +95 -1
  48. package/package.json +1 -1
  49. package/src/graphs/Graph.ts +74 -28
  50. package/src/run.ts +4 -0
  51. package/src/specs/ask-user-question-batch.test.ts +289 -0
  52. package/src/specs/tool-error-resume.test.ts +194 -0
  53. package/src/stream.ts +17 -1
  54. package/src/tools/BashExecutor.ts +107 -14
  55. package/src/tools/BashProgrammaticToolCalling.ts +20 -1
  56. package/src/tools/CodeExecutor.ts +113 -9
  57. package/src/tools/ProgrammaticToolCalling.ts +27 -1
  58. package/src/tools/ToolNode.ts +208 -31
  59. package/src/tools/__tests__/BashExecutor.test.ts +39 -0
  60. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +113 -0
  61. package/src/tools/__tests__/ToolNode.session.test.ts +86 -0
  62. package/src/tools/__tests__/eagerEventExecution.session.test.ts +92 -0
  63. package/src/tools/__tests__/hitl.test.ts +48 -0
  64. package/src/tools/eagerEventExecution.ts +32 -5
  65. package/src/types/hitl.ts +49 -3
  66. package/src/types/run.ts +21 -0
  67. package/src/types/tools.ts +102 -1
@@ -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. Without this check, errors would be double-dispatched.
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
- continue;
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 Promise.all(
3398
- directCalls.map((call, i) =>
3399
- this.runDirectToolWithLifecycleHooks(call, config, {
3400
- batchIndex: directIndices[i],
3401
- turn,
3402
- batchScopeId,
3403
- resolvedArgsByCallId,
3404
- preBatchSnapshot,
3405
- additionalContextsSink: directAdditionalContexts,
3406
- runInput: input as T,
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 Promise.all(
3463
- filteredCalls.map((call, i) =>
3464
- this.runDirectToolWithLifecycleHooks(call, config, {
3465
- batchIndex: i,
3466
- turn,
3467
- batchScopeId,
3468
- resolvedArgsByCallId,
3469
- preBatchSnapshot,
3470
- additionalContextsSink: directAdditionalContexts,
3471
- runInput: input as T,
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
  });