@librechat/agents 3.2.58 → 3.2.59

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 (48) hide show
  1. package/dist/cjs/main.cjs +7 -0
  2. package/dist/cjs/stream.cjs +2 -1
  3. package/dist/cjs/stream.cjs.map +1 -1
  4. package/dist/cjs/tools/BashExecutor.cjs +58 -9
  5. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  6. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +4 -2
  7. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  8. package/dist/cjs/tools/CodeExecutor.cjs +57 -7
  9. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  10. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +9 -3
  11. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  12. package/dist/cjs/tools/ToolNode.cjs +19 -1
  13. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  14. package/dist/cjs/tools/eagerEventExecution.cjs +18 -1
  15. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
  16. package/dist/esm/main.mjs +3 -3
  17. package/dist/esm/stream.mjs +2 -1
  18. package/dist/esm/stream.mjs.map +1 -1
  19. package/dist/esm/tools/BashExecutor.mjs +56 -10
  20. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  21. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +4 -2
  22. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  23. package/dist/esm/tools/CodeExecutor.mjs +54 -8
  24. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  25. package/dist/esm/tools/ProgrammaticToolCalling.mjs +9 -3
  26. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  27. package/dist/esm/tools/ToolNode.mjs +20 -2
  28. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  29. package/dist/esm/tools/eagerEventExecution.mjs +18 -2
  30. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -1
  31. package/dist/types/tools/BashExecutor.d.ts +13 -0
  32. package/dist/types/tools/CodeExecutor.d.ts +14 -0
  33. package/dist/types/tools/ToolNode.d.ts +3 -0
  34. package/dist/types/tools/eagerEventExecution.d.ts +8 -0
  35. package/dist/types/types/tools.d.ts +58 -0
  36. package/package.json +1 -1
  37. package/src/stream.ts +17 -1
  38. package/src/tools/BashExecutor.ts +107 -14
  39. package/src/tools/BashProgrammaticToolCalling.ts +20 -1
  40. package/src/tools/CodeExecutor.ts +113 -9
  41. package/src/tools/ProgrammaticToolCalling.ts +27 -1
  42. package/src/tools/ToolNode.ts +36 -4
  43. package/src/tools/__tests__/BashExecutor.test.ts +39 -0
  44. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +113 -0
  45. package/src/tools/__tests__/ToolNode.session.test.ts +86 -0
  46. package/src/tools/__tests__/eagerEventExecution.session.test.ts +92 -0
  47. package/src/tools/eagerEventExecution.ts +32 -5
  48. package/src/types/tools.ts +65 -0
@@ -149,6 +149,64 @@ Usage:
149
149
  - NEVER use this tool to execute malicious code.
150
150
  `.trim();
151
151
 
152
+ /**
153
+ * Best-effort statefulness note. Deliberately hedged: warm reuse is an
154
+ * optimization, not a guarantee (the runtime may be reset on idle timeout,
155
+ * eviction, or the 8h VM lifetime), so the model must never depend on carried
156
+ * state for correctness and must persist anything durable to /mnt/data.
157
+ */
158
+ export const STATEFUL_ENV_NOTE =
159
+ 'Session state (best-effort): consecutive executions in this conversation usually share one runtime, so variables, imports, and in-memory data from earlier successful calls are typically still available. The runtime may be reset at any time, so treat carried-over state as an optimization, never a guarantee. Anything that must survive MUST be written to /mnt/data. If a NameError/ImportError signals lost state, re-run the needed setup and continue.';
160
+
161
+ export const StatefulCodeExecutionToolDescription = `
162
+ Runs code and returns stdout/stderr output from a session-based execution environment, similar to a long-running command-line session.
163
+
164
+ ${STATEFUL_ENV_NOTE}
165
+
166
+ Usage:
167
+ - No network access available.
168
+ - Generated files are automatically delivered; **DO NOT** provide download links.
169
+ - ${CODE_ARTIFACT_PATH_GUIDANCE}
170
+ - NEVER use this tool to execute malicious code.
171
+ `.trim();
172
+
173
+ export function buildCodeExecutionToolDescription(opts?: {
174
+ statefulSessions?: boolean;
175
+ }): string {
176
+ return opts?.statefulSessions === true
177
+ ? StatefulCodeExecutionToolDescription
178
+ : CodeExecutionToolDescription;
179
+ }
180
+
181
+ const STATELESS_CODE_PARAM_NOTE =
182
+ 'The environment is stateless; variables and imports don\'t persist between executions.';
183
+ const STATEFUL_CODE_PARAM_NOTE =
184
+ 'Executions in this conversation usually share one runtime: variables and imports from prior successful calls are typically still defined, but the runtime may reset between calls. Rebuild state on NameError/ImportError; persist anything important to /mnt/data.';
185
+
186
+ export function buildCodeExecutionToolSchema(opts?: {
187
+ statefulSessions?: boolean;
188
+ }): typeof CodeExecutionToolSchema {
189
+ const note =
190
+ opts?.statefulSessions === true
191
+ ? STATEFUL_CODE_PARAM_NOTE
192
+ : STATELESS_CODE_PARAM_NOTE;
193
+ const codeDescription =
194
+ CodeExecutionToolSchema.properties.code.description.replace(
195
+ STATELESS_CODE_PARAM_NOTE,
196
+ note
197
+ );
198
+ return {
199
+ ...CodeExecutionToolSchema,
200
+ properties: {
201
+ ...CodeExecutionToolSchema.properties,
202
+ code: {
203
+ ...CodeExecutionToolSchema.properties.code,
204
+ description: codeDescription,
205
+ },
206
+ },
207
+ } as typeof CodeExecutionToolSchema;
208
+ }
209
+
152
210
  export const CodeExecutionToolName = Constants.EXECUTE_CODE;
153
211
 
154
212
  export const CodeExecutionToolDefinition = {
@@ -162,21 +220,42 @@ function createCodeExecutionTool(
162
220
  ): DynamicStructuredTool {
163
221
  return tool(
164
222
  async (rawInput, config) => {
165
- const { authHeaders, ...executionParams } = params ?? {};
166
- const { lang, code, ...rest } = rawInput as {
223
+ /* `statefulSessions` is a prompt-only flag (drives the description);
224
+ * keep it out of the wire body. */
225
+ const {
226
+ authHeaders,
227
+ statefulSessions: _statefulSessions,
228
+ ...executionParams
229
+ } = params ?? {};
230
+ void _statefulSessions;
231
+ /* Drop any model-supplied `runtime_session_hint` from the raw args: the
232
+ * hint is host-controlled and must only ever come from ToolNode's
233
+ * injected `_runtime_session_hint` (below). Spreading `...rest` into
234
+ * postData would otherwise let a tool call opt itself into / pick a
235
+ * stateful runtime even when statefulSessions is off. */
236
+ const {
237
+ lang,
238
+ code,
239
+ runtime_session_hint: _ignoredModelHint,
240
+ ...rest
241
+ } = rawInput as {
167
242
  lang: SupportedLanguage;
168
243
  code: string;
244
+ runtime_session_hint?: unknown;
169
245
  args?: string[];
170
246
  };
247
+ void _ignoredModelHint;
171
248
  /**
172
249
  * Extract session context from config.toolCall (injected by ToolNode).
173
250
  * - session_id: associates with the previous run.
174
251
  * - _injected_files: File refs to pass directly (avoids /files endpoint race condition).
175
252
  */
176
- const { session_id, _injected_files } = (config.toolCall ?? {}) as {
177
- session_id?: string;
178
- _injected_files?: t.CodeEnvFile[];
179
- };
253
+ const { session_id, _injected_files, _runtime_session_hint } =
254
+ (config.toolCall ?? {}) as {
255
+ session_id?: string;
256
+ _injected_files?: t.CodeEnvFile[];
257
+ _runtime_session_hint?: string;
258
+ };
180
259
 
181
260
  const postData: Record<string, unknown> = {
182
261
  lang,
@@ -185,6 +264,16 @@ function createCodeExecutionTool(
185
264
  ...executionParams,
186
265
  };
187
266
 
267
+ /* Stateful sessions: forward the hint so the Code API can route this
268
+ * execution to a warm per-session runtime. Additive — stateless
269
+ * servers ignore the unknown field. */
270
+ if (
271
+ typeof _runtime_session_hint === 'string' &&
272
+ _runtime_session_hint !== ''
273
+ ) {
274
+ postData.runtime_session_hint = _runtime_session_hint;
275
+ }
276
+
188
277
  /* File injection: `_injected_files` from ToolNode (set when host
189
278
  * primes a CodeSessionContext) or `params.files` from tool
190
279
  * factory (set by hosts that pre-resolve at construction time).
@@ -242,12 +331,27 @@ function createCodeExecutionTool(
242
331
  code
243
332
  );
244
333
  const hasFiles = result.files != null && result.files.length > 0;
334
+ /* Echo the durable runtime session (stateful backends only) so hosts
335
+ * can surface a "session active / was reset" signal later. Additive:
336
+ * absent on stateless servers. */
337
+ const runtimeEcho =
338
+ result.runtime_session_id != null
339
+ ? {
340
+ runtime_session_id: result.runtime_session_id,
341
+ runtime_status: result.runtime_status,
342
+ }
343
+ : {};
245
344
  return [
246
345
  appendCodeSessionFileSummary(outputWithReminder, result.files),
247
346
  (hasFiles
248
- ? { session_id: result.session_id, files: result.files }
347
+ ? {
348
+ session_id: result.session_id,
349
+ files: result.files,
350
+ ...runtimeEcho,
351
+ }
249
352
  : {
250
353
  session_id: result.session_id,
354
+ ...runtimeEcho,
251
355
  }) satisfies t.CodeExecutionArtifact,
252
356
  ];
253
357
  } catch (error) {
@@ -260,8 +364,8 @@ function createCodeExecutionTool(
260
364
  },
261
365
  {
262
366
  name: CodeExecutionToolName,
263
- description: CodeExecutionToolDescription,
264
- schema: CodeExecutionToolSchema,
367
+ description: buildCodeExecutionToolDescription(params ?? undefined),
368
+ schema: buildCodeExecutionToolSchema(params ?? undefined),
265
369
  responseFormat: Constants.CONTENT_AND_ARTIFACT,
266
370
  }
267
371
  );
@@ -830,6 +830,12 @@ export function formatCompletedResponse(
830
830
  {
831
831
  session_id: response.session_id,
832
832
  files: response.files,
833
+ ...(response.runtime_session_id != null
834
+ ? {
835
+ runtime_session_id: response.runtime_session_id,
836
+ runtime_status: response.runtime_status,
837
+ }
838
+ : {}),
833
839
  } satisfies t.ProgrammaticExecutionArtifact,
834
840
  ];
835
841
  }
@@ -878,8 +884,15 @@ export function createProgrammaticToolCallingTool(
878
884
  Partial<t.ProgrammaticCache> & {
879
885
  session_id?: string;
880
886
  _injected_files?: t.CodeEnvFile[];
887
+ _runtime_session_hint?: string;
881
888
  };
882
- const { toolMap, toolDefs, session_id, _injected_files } = toolCall;
889
+ const {
890
+ toolMap,
891
+ toolDefs,
892
+ session_id,
893
+ _injected_files,
894
+ _runtime_session_hint,
895
+ } = toolCall;
883
896
 
884
897
  if (toolMap == null || toolMap.size === 0) {
885
898
  throw new Error(
@@ -928,6 +941,16 @@ export function createProgrammaticToolCallingTool(
928
941
  );
929
942
  }
930
943
 
944
+ /* Stateful sessions: hint rides the INITIAL request only; the server
945
+ * binds continuation round-trips to the same runtime via the
946
+ * continuation_token. Additive — ignored by stateless servers. PTC
947
+ * keeps its stateless prompt in v1; only the wire hint plumbs here. */
948
+ const runtimeSessionHint =
949
+ typeof _runtime_session_hint === 'string' &&
950
+ _runtime_session_hint !== ''
951
+ ? _runtime_session_hint
952
+ : undefined;
953
+
931
954
  let response = await makeRequest(
932
955
  EXEC_ENDPOINT,
933
956
  {
@@ -936,6 +959,9 @@ export function createProgrammaticToolCallingTool(
936
959
  session_id,
937
960
  timeout,
938
961
  ...(files && files.length > 0 ? { files } : {}),
962
+ ...(runtimeSessionHint != null
963
+ ? { runtime_session_hint: runtimeSessionHint }
964
+ : {}),
939
965
  },
940
966
  proxy,
941
967
  initParams.authHeaders
@@ -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,
@@ -997,6 +998,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
997
998
  );
998
999
  }
999
1000
  }
1001
+
1002
+ /**
1003
+ * Stateful runtime session hint — orthogonal to the transient
1004
+ * exec-session above, and injected independently (a first call has a
1005
+ * hint but no exec session yet). Explicit host hint wins; otherwise
1006
+ * fall back to the conversation's thread_id.
1007
+ */
1008
+ const runtimeSessionHint = this.resolveRuntimeSessionHint(config);
1009
+ if (runtimeSessionHint != null) {
1010
+ invokeParams = {
1011
+ ...invokeParams,
1012
+ _runtime_session_hint: runtimeSessionHint,
1013
+ };
1014
+ }
1000
1015
  }
1001
1016
 
1002
1017
  /**
@@ -1783,6 +1798,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1783
1798
  );
1784
1799
  }
1785
1800
 
1801
+ /** Delegates to the shared resolver so the direct and event-driven planning
1802
+ * paths derive the runtime session hint identically. */
1803
+ private resolveRuntimeSessionHint(
1804
+ config: RunnableConfig
1805
+ ): string | undefined {
1806
+ return resolveRuntimeSessionHint(
1807
+ this.toolExecution,
1808
+ config.configurable?.thread_id as string | undefined
1809
+ );
1810
+ }
1811
+
1786
1812
  private storeCodeSessionFromResults(
1787
1813
  results: t.ToolExecuteResult[],
1788
1814
  requestMap: Map<string, t.ToolCallRequest>
@@ -2492,12 +2518,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2492
2518
  entry.call.name === Constants.READ_FILE
2493
2519
  ? this.getCodeSessionContext()
2494
2520
  : undefined;
2521
+ const runtimeSessionHint = this.participatesInCodeSession(
2522
+ entry.call.name
2523
+ )
2524
+ ? this.resolveRuntimeSessionHint(config)
2525
+ : undefined;
2495
2526
  return {
2496
2527
  id: entry.call.id,
2497
2528
  name: entry.call.name,
2498
2529
  args: entry.args,
2499
2530
  stepId: entry.stepId,
2500
2531
  codeSessionContext,
2532
+ runtimeSessionHint,
2501
2533
  };
2502
2534
  }),
2503
2535
  usageCount: this.toolUsageCount,
@@ -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
  });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type * as t from '@/types';
3
+ import {
4
+ buildToolExecutionRequestPlan,
5
+ resolveRuntimeSessionHint,
6
+ } from '../eagerEventExecution';
7
+
8
+ describe('buildToolExecutionRequestPlan — runtimeSessionHint', () => {
9
+ const usageCount = () => new Map<string, number>();
10
+
11
+ it('carries runtimeSessionHint onto the built ToolCallRequest', () => {
12
+ const plan = buildToolExecutionRequestPlan({
13
+ toolCalls: [
14
+ {
15
+ id: 'call_1',
16
+ name: 'execute_code',
17
+ args: { lang: 'py', code: 'print(1)' },
18
+ runtimeSessionHint: 'conv-42',
19
+ },
20
+ ],
21
+ usageCount: usageCount(),
22
+ });
23
+ expect(plan?.requests[0].runtimeSessionHint).toBe('conv-42');
24
+ });
25
+
26
+ it('omits the field entirely when the hint is absent or empty', () => {
27
+ const plan = buildToolExecutionRequestPlan({
28
+ toolCalls: [{ id: 'c1', name: 'execute_code', args: {} }],
29
+ usageCount: usageCount(),
30
+ });
31
+ expect('runtimeSessionHint' in (plan?.requests[0] as object)).toBe(false);
32
+
33
+ const empty = buildToolExecutionRequestPlan({
34
+ toolCalls: [
35
+ { id: 'c2', name: 'execute_code', args: {}, runtimeSessionHint: '' },
36
+ ],
37
+ usageCount: usageCount(),
38
+ });
39
+ expect('runtimeSessionHint' in (empty?.requests[0] as object)).toBe(false);
40
+ });
41
+
42
+ it('carries the hint onto invalid-arg (rejected) requests too', () => {
43
+ const plan = buildToolExecutionRequestPlan({
44
+ toolCalls: [
45
+ {
46
+ id: 'c1',
47
+ name: 'execute_code',
48
+ args: 'not-an-object',
49
+ runtimeSessionHint: 'conv-9',
50
+ },
51
+ ],
52
+ usageCount: usageCount(),
53
+ invalidArgsBehavior: 'error-result',
54
+ });
55
+ expect(plan?.allRequests[0].runtimeSessionHint).toBe('conv-9');
56
+ expect(plan?.rejectedResults).toHaveLength(1);
57
+ });
58
+ });
59
+
60
+ describe('resolveRuntimeSessionHint', () => {
61
+ const sandbox = (
62
+ o: Partial<t.SandboxExecutionConfig>
63
+ ): t.ToolExecutionConfig => ({
64
+ sandbox: o,
65
+ });
66
+
67
+ it('returns undefined unless statefulSessions is on', () => {
68
+ expect(resolveRuntimeSessionHint(undefined, 'thread-1')).toBeUndefined();
69
+ expect(resolveRuntimeSessionHint(sandbox({}), 'thread-1')).toBeUndefined();
70
+ expect(
71
+ resolveRuntimeSessionHint(
72
+ sandbox({ statefulSessions: false }),
73
+ 'thread-1'
74
+ )
75
+ ).toBeUndefined();
76
+ });
77
+
78
+ it('prefers an explicit hint, else falls back to thread_id', () => {
79
+ expect(
80
+ resolveRuntimeSessionHint(
81
+ sandbox({ statefulSessions: true, runtimeSessionHint: 'explicit' }),
82
+ 'thread-1'
83
+ )
84
+ ).toBe('explicit');
85
+ expect(
86
+ resolveRuntimeSessionHint(sandbox({ statefulSessions: true }), 'thread-1')
87
+ ).toBe('thread-1');
88
+ expect(
89
+ resolveRuntimeSessionHint(sandbox({ statefulSessions: true }), '')
90
+ ).toBeUndefined();
91
+ });
92
+ });