@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
@@ -0,0 +1,194 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { HumanMessage } from '@langchain/core/messages';
4
+ import { MemorySaver, Command } from '@langchain/langgraph';
5
+ import type { RunnableConfig } from '@langchain/core/runnables';
6
+ import type * as t from '@/types';
7
+ import { GraphEvents, Providers } from '@/common';
8
+ import { FakeChatModel } from '@/llm/fake';
9
+ import { askUserQuestion } from '@/hitl';
10
+ import { StandardGraph } from '@/graphs';
11
+ import { Run } from '@/run';
12
+
13
+ /**
14
+ * Regression: a DIRECT (graphTools) tool that fails schema validation during
15
+ * the RESUME pass of an interrupted batch.
16
+ *
17
+ * LangGraph re-executes the whole interrupted batch on resume, and a
18
+ * fast-failing sibling (e.g. a zod reject) errors BEFORE the rebuilt graph
19
+ * has registered run steps for the replayed calls. `handleToolCallErrorStatic`
20
+ * used to throw (`No config provided`) in that state, which surfaced as a
21
+ * scary `Error in errorHandler` log on every such resume — the error itself
22
+ * was already handled (the model receives the error ToolMessage either way).
23
+ * It now reports `false` (nothing dispatched) so the ToolNode can decide, and
24
+ * the client-facing contract stays: the error completion event dispatches
25
+ * exactly ONCE, on the pass where the call's run step is live (the first
26
+ * pass), never zero times and never twice.
27
+ */
28
+ const askTool = tool(
29
+ async (input) => {
30
+ const { answer } = askUserQuestion(input as { question: string });
31
+ return answer;
32
+ },
33
+ {
34
+ name: 'ask_user_question',
35
+ description:
36
+ 'Ask the user a clarifying question and wait for their answer.',
37
+ schema: z.object({ question: z.string() }),
38
+ }
39
+ );
40
+
41
+ const strictTool = tool(
42
+ async ({ items }: { items: string[] }) => `got ${items.length}`,
43
+ {
44
+ name: 'strict_tool',
45
+ // Array cap mirrors the field repro: an ask_user_question sibling whose
46
+ // `options` array exceeded the 12-option schema cap.
47
+ description: 'A tool with a strict array-cap schema.',
48
+ schema: z.object({ items: z.array(z.string()).max(1) }),
49
+ }
50
+ );
51
+
52
+ describe('Direct tool schema error across interrupt/resume', () => {
53
+ jest.setTimeout(30000);
54
+
55
+ const threadConfig: Partial<RunnableConfig> & {
56
+ version: 'v1' | 'v2';
57
+ streamMode: string;
58
+ } = {
59
+ configurable: { thread_id: 'tool-error-resume-thread' },
60
+ streamMode: 'values',
61
+ version: 'v2' as const,
62
+ };
63
+
64
+ const completedToolEvents: Array<{ name?: string; output?: string }> = [];
65
+ const customHandlers = {
66
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
67
+ handle: (_event: GraphEvents, data: t.StreamEventData): void => {
68
+ const result = (data as { result?: t.ToolCompleteEvent }).result;
69
+ if (result?.type === 'tool_call') {
70
+ completedToolEvents.push({
71
+ name: result.tool_call.name,
72
+ output: result.tool_call.output,
73
+ });
74
+ }
75
+ },
76
+ },
77
+ };
78
+
79
+ async function buildRun(
80
+ saver: MemorySaver,
81
+ runId: string
82
+ ): Promise<Run<t.IState>> {
83
+ const run = await Run.create<t.IState>({
84
+ runId,
85
+ graphConfig: {
86
+ type: 'standard',
87
+ agents: [
88
+ {
89
+ agentId: 'resume-error-agent',
90
+ provider: Providers.OPENAI,
91
+ clientOptions: {
92
+ model: 'gpt-4o-mini',
93
+ streaming: true,
94
+ streamUsage: false,
95
+ },
96
+ instructions: 'You are a helpful assistant.',
97
+ maxContextTokens: 8000,
98
+ /** Non-empty toolDefinitions flips the ToolNode to event dispatch —
99
+ * the production shape — while graphTools ride the direct path. */
100
+ toolDefinitions: [
101
+ { name: 'dummy_event_tool', description: 'host tool' },
102
+ ],
103
+ graphTools: [askTool, strictTool],
104
+ },
105
+ ],
106
+ compileOptions: { checkpointer: saver },
107
+ },
108
+ returnContent: true,
109
+ skipCleanup: true,
110
+ customHandlers,
111
+ eagerEventToolExecution: {
112
+ enabled: true,
113
+ excludeToolNames: ['ask_user_question', 'strict_tool'],
114
+ },
115
+ });
116
+ run.Graph!.overrideModel = new FakeChatModel({
117
+ responses: ['calling tools', 'done after resume'],
118
+ toolCalls: [
119
+ {
120
+ name: 'ask_user_question',
121
+ args: { question: 'pick one' },
122
+ id: 'call_ask_1',
123
+ type: 'tool_call',
124
+ },
125
+ {
126
+ name: 'strict_tool',
127
+ args: { items: ['a', 'b', 'c'] },
128
+ id: 'call_strict_1',
129
+ type: 'tool_call',
130
+ },
131
+ ],
132
+ });
133
+ return run;
134
+ }
135
+
136
+ test('error completion dispatches exactly once; resume-pass handler reports false instead of throwing', async () => {
137
+ const saver = new MemorySaver();
138
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
139
+ const staticSpy = jest.spyOn(StandardGraph, 'handleToolCallErrorStatic');
140
+
141
+ try {
142
+ /** Pass 1: the strict tool rejects its args while the ask tool
143
+ * interrupts — the run pauses AND the schema error completes. */
144
+ const run1 = await buildRun(saver, 'run-pass-1');
145
+ await run1.processStream(
146
+ { messages: [new HumanMessage('go')] },
147
+ threadConfig
148
+ );
149
+
150
+ const strictCompletions = completedToolEvents.filter(
151
+ (e) =>
152
+ e.name === 'strict_tool' &&
153
+ e.output?.includes('Error processing tool') === true
154
+ );
155
+ expect(strictCompletions).toHaveLength(1);
156
+ // Stable LangChain wrapper text (version-independent), proving the
157
+ // completion carried the schema-validation failure.
158
+ expect(strictCompletions[0].output).toContain(
159
+ 'did not match expected schema'
160
+ );
161
+
162
+ /** Pass 2: fresh instance (host restart shape), resume with the
163
+ * answer. The batch re-executes; the strict tool fails again before
164
+ * any run step is registered on the rebuilt graph. */
165
+ const run2 = await buildRun(saver, 'run-pass-2');
166
+ await run2.processStream(
167
+ new Command({ resume: { answer: 'blue' } }) as unknown as t.IState,
168
+ threadConfig
169
+ );
170
+
171
+ /** The handler reported "not dispatched" (no run step yet) rather than
172
+ * throwing — so nothing was logged and no duplicate completion event
173
+ * reached the client. */
174
+ const resumeResults = await Promise.all(
175
+ staticSpy.mock.results.map((r) => r.value as Promise<boolean>)
176
+ );
177
+ expect(resumeResults).toContain(false);
178
+ const handlerFailures = errorSpy.mock.calls.filter((args) =>
179
+ String(args[0]).includes('Error in errorHandler')
180
+ );
181
+ expect(handlerFailures).toHaveLength(0);
182
+ expect(
183
+ completedToolEvents.filter(
184
+ (e) =>
185
+ e.name === 'strict_tool' &&
186
+ e.output?.includes('Error processing tool') === true
187
+ )
188
+ ).toHaveLength(1);
189
+ } finally {
190
+ errorSpy.mockRestore();
191
+ staticSpy.mockRestore();
192
+ }
193
+ });
194
+ });
package/src/stream.ts CHANGED
@@ -142,7 +142,18 @@ function isEagerExecutionExcludedTool(
142
142
  // side-effecting: never prestart it speculatively (a revised/superseded turn
143
143
  // would leave the write applied). Implies exclusion without the host having
144
144
  // to also list the name in excludeToolNames.
145
- return graph.codeSessionToolNames?.includes(name) === true;
145
+ if (graph.codeSessionToolNames?.includes(name) === true) {
146
+ return true;
147
+ }
148
+ // With stateful sessions on, execute_code/bash run against a DURABLE warm
149
+ // runtime. Speculative prestart there is unsafe: if the model revises the
150
+ // args, ToolNode discards the eager result but the mutation has already
151
+ // landed in the session workspace, corrupting later runs. Stateless mode
152
+ // uses a throwaway VM per call, so eager prestart stays safe there.
153
+ return (
154
+ graph.toolExecution?.sandbox?.statefulSessions === true &&
155
+ CODE_EXECUTION_TOOLS.has(name)
156
+ );
146
157
  }
147
158
 
148
159
  function isDirectGraphTool(
@@ -668,6 +679,11 @@ function createEagerToolExecutionPlan(args: {
668
679
  return undefined;
669
680
  }
670
681
 
682
+ /* No runtimeSessionHint here on purpose: the eager path is speculative, and
683
+ * code-session tools (the only ones that carry a hint) are excluded from
684
+ * eager prestart entirely when stateful sessions are on — see
685
+ * isEagerExecutionExcludedTool. The durable runtime is only ever touched by
686
+ * the committed ToolNode path. */
671
687
  const plan = buildToolExecutionRequestPlan({
672
688
  toolCalls: candidateToolCalls.map((toolCall) => ({
673
689
  id: toolCall.id,
@@ -57,6 +57,29 @@ Usage:
57
57
  - NEVER use this tool to execute malicious commands.
58
58
  `.trim();
59
59
 
60
+ /**
61
+ * Bash statefulness is filesystem-tier: on a warm session the machine (files
62
+ * including /tmp, installed packages, background processes) persists between
63
+ * calls, but each call may start a fresh shell — so shell variables and cwd
64
+ * are NOT reliable, and the machine can be reset at any time. Only /mnt/data
65
+ * is durable.
66
+ */
67
+ export const STATEFUL_BASH_NOTE =
68
+ 'Session state (best-effort): commands in this conversation usually run on the same machine, so files (including /tmp), installed packages, and running background processes from earlier calls typically persist. Each call may still start a fresh shell — do not rely on shell variables or the working directory carrying over — and the machine may be reset at any time. Only /mnt/data is durable.';
69
+
70
+ export const StatefulBashExecutionToolDescription = `
71
+ Runs bash commands and returns stdout/stderr output from a session-based execution environment, similar to a long-running machine.
72
+
73
+ ${STATEFUL_BASH_NOTE}
74
+
75
+ Usage:
76
+ - No network access available.
77
+ - Generated files are automatically delivered; **DO NOT** provide download links.
78
+ - ${CODE_ARTIFACT_PATH_GUIDANCE}
79
+ - ${BASH_SHELL_GUIDANCE}
80
+ - NEVER use this tool to execute malicious commands.
81
+ `.trim();
82
+
60
83
  /**
61
84
  * Supplemental prompt documenting the tool-output reference feature.
62
85
  *
@@ -87,11 +110,45 @@ Referencing previous tool outputs:
87
110
  */
88
111
  export function buildBashExecutionToolDescription(options?: {
89
112
  enableToolOutputReferences?: boolean;
113
+ statefulSessions?: boolean;
90
114
  }): string {
115
+ const base =
116
+ options?.statefulSessions === true
117
+ ? StatefulBashExecutionToolDescription
118
+ : BashExecutionToolDescription;
91
119
  if (options?.enableToolOutputReferences === true) {
92
- return `${BashExecutionToolDescription}\n\n${BashToolOutputReferencesGuide}`;
120
+ return `${base}\n\n${BashToolOutputReferencesGuide}`;
93
121
  }
94
- return BashExecutionToolDescription;
122
+ return base;
123
+ }
124
+
125
+ const STATELESS_BASH_PARAM_NOTE =
126
+ 'The environment is stateless; variables and state don\'t persist between executions.';
127
+ const STATEFUL_BASH_PARAM_NOTE =
128
+ 'Files, installed packages, and background processes usually persist between calls, but each call may start a fresh shell (do not rely on shell variables or cwd) and the machine may reset. Only /mnt/data is durable.';
129
+
130
+ export function buildBashExecutionToolSchema(opts?: {
131
+ statefulSessions?: boolean;
132
+ }): typeof BashExecutionToolSchema {
133
+ const note =
134
+ opts?.statefulSessions === true
135
+ ? STATEFUL_BASH_PARAM_NOTE
136
+ : STATELESS_BASH_PARAM_NOTE;
137
+ const commandDescription =
138
+ BashExecutionToolSchema.properties.command.description.replace(
139
+ STATELESS_BASH_PARAM_NOTE,
140
+ note
141
+ );
142
+ return {
143
+ ...BashExecutionToolSchema,
144
+ properties: {
145
+ ...BashExecutionToolSchema.properties,
146
+ command: {
147
+ ...BashExecutionToolSchema.properties.command,
148
+ description: commandDescription,
149
+ },
150
+ },
151
+ } as typeof BashExecutionToolSchema;
95
152
  }
96
153
 
97
154
  export const BashExecutionToolName = Constants.BASH_TOOL;
@@ -117,15 +174,32 @@ function createBashExecutionTool(
117
174
  ): DynamicStructuredTool {
118
175
  return tool(
119
176
  async (rawInput, config) => {
120
- const { authHeaders, ...executionParams } = params ?? {};
121
- const { command, ...rest } = rawInput as {
177
+ /* `statefulSessions` is prompt-only keep it out of the wire body. */
178
+ const {
179
+ authHeaders,
180
+ statefulSessions: _statefulSessions,
181
+ ...executionParams
182
+ } = params ?? {};
183
+ void _statefulSessions;
184
+ /* Drop any model-supplied `runtime_session_hint` from the raw args: the
185
+ * hint must only come from ToolNode's injected `_runtime_session_hint`
186
+ * (below), never from the tool call itself. */
187
+ const {
188
+ command,
189
+ runtime_session_hint: _ignoredModelHint,
190
+ ...rest
191
+ } = rawInput as {
122
192
  command: string;
193
+ runtime_session_hint?: unknown;
123
194
  args?: string[];
124
195
  };
125
- const { session_id, _injected_files } = (config.toolCall ?? {}) as {
126
- session_id?: string;
127
- _injected_files?: t.CodeEnvFile[];
128
- };
196
+ void _ignoredModelHint;
197
+ const { session_id, _injected_files, _runtime_session_hint } =
198
+ (config.toolCall ?? {}) as {
199
+ session_id?: string;
200
+ _injected_files?: t.CodeEnvFile[];
201
+ _runtime_session_hint?: string;
202
+ };
129
203
 
130
204
  const postData: Record<string, unknown> = {
131
205
  lang: 'bash',
@@ -134,6 +208,13 @@ function createBashExecutionTool(
134
208
  ...executionParams,
135
209
  };
136
210
 
211
+ if (
212
+ typeof _runtime_session_hint === 'string' &&
213
+ _runtime_session_hint !== ''
214
+ ) {
215
+ postData.runtime_session_hint = _runtime_session_hint;
216
+ }
217
+
137
218
  /* See `CodeExecutor.ts` for the rationale — `/files/<session_id>`
138
219
  * HTTP fallback was removed because codeapi's sessionAuth requires
139
220
  * kind/id query params unavailable at this point. */
@@ -187,12 +268,24 @@ function createBashExecutionTool(
187
268
  command
188
269
  );
189
270
  const hasFiles = result.files != null && result.files.length > 0;
271
+ const runtimeEcho =
272
+ result.runtime_session_id != null
273
+ ? {
274
+ runtime_session_id: result.runtime_session_id,
275
+ runtime_status: result.runtime_status,
276
+ }
277
+ : {};
190
278
  return [
191
279
  appendCodeSessionFileSummary(outputWithReminder, result.files),
192
280
  (hasFiles
193
- ? { session_id: result.session_id, files: result.files }
281
+ ? {
282
+ session_id: result.session_id,
283
+ files: result.files,
284
+ ...runtimeEcho,
285
+ }
194
286
  : {
195
287
  session_id: result.session_id,
288
+ ...runtimeEcho,
196
289
  }) satisfies t.CodeExecutionArtifact,
197
290
  ];
198
291
  } catch (error) {
@@ -200,15 +293,15 @@ function createBashExecutionTool(
200
293
  (error as Error | undefined)?.message ?? '',
201
294
  command
202
295
  );
203
- throw new Error(
204
- `Execution error:\n\n${messageWithReminder}`
205
- );
296
+ throw new Error(`Execution error:\n\n${messageWithReminder}`);
206
297
  }
207
298
  },
208
299
  {
209
300
  name: BashExecutionToolName,
210
- description: BashExecutionToolDescription,
211
- schema: BashExecutionToolSchema,
301
+ description: buildBashExecutionToolDescription({
302
+ statefulSessions: params?.statefulSessions,
303
+ }),
304
+ schema: buildBashExecutionToolSchema(params ?? undefined),
212
305
  responseFormat: Constants.CONTENT_AND_ARTIFACT,
213
306
  }
214
307
  );
@@ -304,8 +304,15 @@ export function createBashProgrammaticToolCallingTool(
304
304
  Partial<t.ProgrammaticCache> & {
305
305
  session_id?: string;
306
306
  _injected_files?: t.CodeEnvFile[];
307
+ _runtime_session_hint?: string;
307
308
  };
308
- const { toolMap, toolDefs, session_id, _injected_files } = toolCall;
309
+ const {
310
+ toolMap,
311
+ toolDefs,
312
+ session_id,
313
+ _injected_files,
314
+ _runtime_session_hint,
315
+ } = toolCall;
309
316
 
310
317
  if (toolMap == null || toolMap.size === 0) {
311
318
  throw new Error(
@@ -351,6 +358,15 @@ export function createBashProgrammaticToolCallingTool(
351
358
  );
352
359
  }
353
360
 
361
+ /* Stateful sessions: hint on the INITIAL request only (continuations
362
+ * bind via continuation_token). Wire-only in v1 — BashPTC keeps its
363
+ * stateless prompt. */
364
+ const runtimeSessionHint =
365
+ typeof _runtime_session_hint === 'string' &&
366
+ _runtime_session_hint !== ''
367
+ ? _runtime_session_hint
368
+ : undefined;
369
+
354
370
  let response = await makeRequest(
355
371
  EXEC_ENDPOINT,
356
372
  {
@@ -360,6 +376,9 @@ export function createBashProgrammaticToolCallingTool(
360
376
  session_id,
361
377
  timeout,
362
378
  ...(files && files.length > 0 ? { files } : {}),
379
+ ...(runtimeSessionHint != null
380
+ ? { runtime_session_hint: runtimeSessionHint }
381
+ : {}),
363
382
  },
364
383
  proxy,
365
384
  initParams.authHeaders
@@ -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