@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,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
+ });
@@ -3996,6 +3996,54 @@ describe('AskUserQuestion — interrupt + resume', () => {
3996
3996
  expect(resumedAnswer).toBe('production');
3997
3997
  });
3998
3998
 
3999
+ it('carries multiSelect through the interrupt payload and resumes with the joined option values', async () => {
4000
+ const { askUserQuestion } = await import('@/hitl');
4001
+
4002
+ let resumedAnswer: string | undefined;
4003
+
4004
+ const builder = new StateGraph(MessagesAnnotation)
4005
+ .addNode('clarifier', () => {
4006
+ const resolution = askUserQuestion({
4007
+ question: 'Which environments?',
4008
+ options: [
4009
+ { label: 'Staging', value: 'staging' },
4010
+ { label: 'Production', value: 'production' },
4011
+ ],
4012
+ multiSelect: true,
4013
+ });
4014
+ resumedAnswer = resolution.answer;
4015
+ return { messages: [] };
4016
+ })
4017
+ .addEdge(START, 'clarifier')
4018
+ .addEdge('clarifier', END);
4019
+ const graph = builder.compile({ checkpointer: new MemorySaver() });
4020
+
4021
+ const config = { configurable: { thread_id: 'ask-q-multi-thread' } };
4022
+
4023
+ const interrupted = (await graph.invoke({ messages: [] }, config)) as {
4024
+ __interrupt__?: Array<{ id?: string; value?: t.HumanInterruptPayload }>;
4025
+ };
4026
+ const payload = interrupted.__interrupt__![0].value!;
4027
+ if (payload.type !== 'ask_user_question') {
4028
+ throw new Error('expected ask_user_question');
4029
+ }
4030
+ expect(payload.question.multiSelect).toBe(true);
4031
+ expect(payload.question.options).toHaveLength(2);
4032
+
4033
+ // Host joins the selected option values with ", ".
4034
+ const resolution: t.AskUserQuestionResolution = {
4035
+ answer: 'staging, production',
4036
+ };
4037
+ await resumeGraph(
4038
+ graph as unknown as CompiledMessagesGraph,
4039
+ interrupted,
4040
+ resolution,
4041
+ config
4042
+ );
4043
+
4044
+ expect(resumedAnswer).toBe('staging, production');
4045
+ });
4046
+
3999
4047
  it('a DIRECT tool in event-driven mode can raise ask_user_question from its body and resume with the answer as its ToolMessage', async () => {
4000
4048
  /**
4001
4049
  * The production host shape (e.g. LibreChat's `AgentInputs.graphTools`
@@ -52,8 +52,30 @@ export type ToolExecutionPlanCall = {
52
52
  args: unknown;
53
53
  stepId?: string;
54
54
  codeSessionContext?: t.ToolCallRequest['codeSessionContext'];
55
+ runtimeSessionHint?: string;
55
56
  };
56
57
 
58
+ /**
59
+ * Stateful runtime session hint for the remote sandbox: only when
60
+ * `toolExecution.sandbox.statefulSessions` is on; explicit host hint else the
61
+ * conversation `thread_id`. Undefined disables the wire field. Shared by the
62
+ * direct ToolNode path and both event-driven planners so they stay in lockstep.
63
+ */
64
+ export function resolveRuntimeSessionHint(
65
+ toolExecution: t.ToolExecutionConfig | undefined,
66
+ threadId: string | undefined
67
+ ): string | undefined {
68
+ const sandbox = toolExecution?.sandbox;
69
+ if (sandbox?.statefulSessions !== true) {
70
+ return undefined;
71
+ }
72
+ const explicit = sandbox.runtimeSessionHint;
73
+ if (explicit != null && explicit !== '') {
74
+ return explicit;
75
+ }
76
+ return threadId != null && threadId !== '' ? threadId : undefined;
77
+ }
78
+
57
79
  export type ToolExecutionRequestPlan = {
58
80
  allRequests: t.ToolCallRequest[];
59
81
  requests: t.ToolCallRequest[];
@@ -73,15 +95,12 @@ export function buildToolExecutionRequestPlan(args: {
73
95
  args: Record<string, unknown>;
74
96
  stepId?: string;
75
97
  codeSessionContext?: t.ToolCallRequest['codeSessionContext'];
98
+ runtimeSessionHint?: string;
76
99
  rejectedErrorMessage?: string;
77
100
  }> = [];
78
101
 
79
102
  for (const toolCall of args.toolCalls) {
80
- if (
81
- toolCall.id == null ||
82
- toolCall.id === '' ||
83
- toolCall.name === ''
84
- ) {
103
+ if (toolCall.id == null || toolCall.id === '' || toolCall.name === '') {
85
104
  return undefined;
86
105
  }
87
106
  const coercedArgs = coerceRecordArgs(toolCall.args);
@@ -95,6 +114,7 @@ export function buildToolExecutionRequestPlan(args: {
95
114
  args: {},
96
115
  stepId: toolCall.stepId,
97
116
  codeSessionContext: toolCall.codeSessionContext,
117
+ runtimeSessionHint: toolCall.runtimeSessionHint,
98
118
  rejectedErrorMessage:
99
119
  'Invalid tool call arguments: expected a JSON object.',
100
120
  });
@@ -106,6 +126,7 @@ export function buildToolExecutionRequestPlan(args: {
106
126
  args: coercedArgs,
107
127
  stepId: toolCall.stepId,
108
128
  codeSessionContext: toolCall.codeSessionContext,
129
+ runtimeSessionHint: toolCall.runtimeSessionHint,
109
130
  });
110
131
  }
111
132
 
@@ -123,6 +144,12 @@ export function buildToolExecutionRequestPlan(args: {
123
144
  if (toolCall.codeSessionContext != null) {
124
145
  request.codeSessionContext = toolCall.codeSessionContext;
125
146
  }
147
+ if (
148
+ toolCall.runtimeSessionHint != null &&
149
+ toolCall.runtimeSessionHint !== ''
150
+ ) {
151
+ request.runtimeSessionHint = toolCall.runtimeSessionHint;
152
+ }
126
153
  return request;
127
154
  });
128
155
  const requests = allRequests.filter(
package/src/types/hitl.ts CHANGED
@@ -140,6 +140,13 @@ export interface AskUserQuestionRequest {
140
140
  * UI allows it. Omit to require a free-form answer.
141
141
  */
142
142
  options?: AskUserQuestionOption[];
143
+ /**
144
+ * When `true`, the host UI may let the user pick several options; the
145
+ * resulting `AskUserQuestionResolution.answer` is the selected option
146
+ * values joined by `", "`. When omitted or `false`, hosts render a
147
+ * single-select picker. Only meaningful alongside `options`.
148
+ */
149
+ multiSelect?: boolean;
143
150
  }
144
151
 
145
152
  /**
@@ -168,9 +175,10 @@ export type HumanInterruptPayload =
168
175
  export interface AskUserQuestionResolution {
169
176
  /**
170
177
  * The human's answer. Free-form text, or — when `options` were
171
- * provided — one of the option `value`s. Hosts may also send any
172
- * structured object their custom UI defines; see the host docs for
173
- * what your downstream consumer expects.
178
+ * provided — one of the option `value`s (or, when the request set
179
+ * `multiSelect`, several option `value`s joined by `", "`). Hosts may
180
+ * also send any structured object their custom UI defines; see the
181
+ * host docs for what your downstream consumer expects.
174
182
  */
175
183
  answer: string;
176
184
  }
@@ -287,6 +295,44 @@ export function isAskUserQuestionInterrupt(
287
295
  * an interrupt. This applies equally to direct tools (handoffs,
288
296
  * subagents) and to event tools.
289
297
  *
298
+ * ### Guarding non-idempotent siblings via `interruptingToolNames`
299
+ *
300
+ * The "must be idempotent" rule above is unavoidable in the general
301
+ * case, but the SDK can protect siblings against the one interrupt
302
+ * shape it can predict: a tool whose *body* raises `interrupt()`
303
+ * mid-execution — the `ask_user_question` shape, where the tool
304
+ * suspends the run to collect a human answer. Declare such tools in
305
+ * `RunConfig.interruptingToolNames`
306
+ * ({@link ToolNodeOptions.interruptingToolNames}) and the ToolNode
307
+ * schedules them, within each batch, **ahead of** their
308
+ * non-interrupting direct siblings. When one interrupts, the batch
309
+ * unwinds before any declared-safe sibling has run, so the sibling
310
+ * executes exactly once (on resume) instead of twice. Empirically:
311
+ *
312
+ * - A **direct** sibling sharing the interrupter's in-process
313
+ * `Promise.all` is the only shape that double-executes; declaring
314
+ * the interrupter closes it.
315
+ * - An **event-dispatched** sibling is already safe without any
316
+ * config: the ToolNode awaits the whole direct group (where the
317
+ * body interrupt unwinds) before it dispatches event tools, so a
318
+ * dispatched sibling never runs on the first pass.
319
+ *
320
+ * This is a *scheduling* guard, not full resume idempotency: it only
321
+ * covers tools that interrupt from their own body and only protects
322
+ * siblings scheduled after them. It does not retroactively make a
323
+ * `PreToolUse` `'ask'` gate on tool B stop tool A (already executed)
324
+ * from re-running — unless B is itself declared interrupting, so it
325
+ * runs first. Tools with side effects should still be written
326
+ * idempotent as defense in depth.
327
+ *
328
+ * The guard only REORDERS the direct group — declaring a name does not
329
+ * force it onto the direct path. The interrupting tool must already be a
330
+ * real in-process graphTool (the only kind whose body can reach
331
+ * `interrupt()`). A name that resolves to a schema-only event stub (an
332
+ * inherited `toolDefinition` with no executable instance, e.g. in a
333
+ * self-spawned child that scrubs `graphTools`) stays event-dispatched
334
+ * and the ordering is a no-op for it.
335
+ *
290
336
  * ## Note on idempotency
291
337
  *
292
338
  * Same root cause as the resume re-execution above: LangGraph
package/src/types/run.ts CHANGED
@@ -193,6 +193,27 @@ export type RunConfig = {
193
193
  * the SDK stays name-agnostic.
194
194
  */
195
195
  codeSessionToolNames?: string[];
196
+ /**
197
+ * Names of host tools whose in-process body may raise a LangGraph
198
+ * `interrupt()` mid-execution — the canonical example is an
199
+ * `ask_user_question` tool that suspends the run to collect a human
200
+ * answer. Within a single tool-call batch, a named tool that is a real
201
+ * in-process graphTool (the only kind whose body can reach `interrupt()`;
202
+ * graphTools are auto-marked direct) is scheduled **ahead of** its
203
+ * non-interrupting direct siblings. That ordering guarantees a mid-body
204
+ * interrupt unwinds the tool batch before a non-idempotent sibling
205
+ * (send_email, billing) executes, so the sibling cannot run once on the
206
+ * first pass and AGAIN when LangGraph re-runs the interrupted batch on
207
+ * resume.
208
+ *
209
+ * This only reorders the direct group — it does NOT force a name onto
210
+ * the direct path. A name that is only an inherited event `toolDefinition`
211
+ * (schema-only stub, e.g. in a self-spawned child) stays event-dispatched;
212
+ * the guard applies only to tools that are independently direct.
213
+ * Host-declared so the SDK stays name-agnostic; omit to keep the prior
214
+ * (unguarded) behavior.
215
+ */
216
+ interruptingToolNames?: string[];
196
217
  /**
197
218
  * Selects the execution backend for built-in code tools. Omit this to keep
198
219
  * the remote LibreChat Code API sandbox. Set `{ engine: 'local' }` to run
@@ -89,10 +89,18 @@ export type ToolNodeOptions = {
89
89
  handleToolErrors?: boolean;
90
90
  loadRuntimeTools?: ToolRefGenerator;
91
91
  toolCallStepIds?: Map<string, string>;
92
+ /**
93
+ * Dispatches the error completion event for a failed tool call. Returns
94
+ * whether the event was actually dispatched — `false` (e.g. no run step is
95
+ * registered for the call yet, which happens when a tool fails fast on a
96
+ * resume pass) tells the ToolNode to fall back to its own completion
97
+ * dispatch for the error ToolMessage. A `void` resolution is treated as
98
+ * dispatched for backward compatibility.
99
+ */
92
100
  errorHandler?: (
93
101
  data: ToolErrorData,
94
102
  metadata?: Record<string, unknown>
95
- ) => Promise<void>;
103
+ ) => Promise<boolean | void>;
96
104
  /** Tool registry for lazy computation of programmatic tools and tool search */
97
105
  toolRegistry?: LCToolRegistry;
98
106
  /** Reference to Graph's sessions map for automatic session injection */
@@ -108,6 +116,34 @@ export type ToolNodeOptions = {
108
116
  executingAgentId?: string;
109
117
  /** Tool names that must be executed directly (via runTool) even in event-driven mode (e.g., graph-managed handoff tools) */
110
118
  directToolNames?: Set<string>;
119
+ /**
120
+ * Tool names whose in-process body may raise a LangGraph `interrupt()`
121
+ * mid-execution — e.g. an `ask_user_question` tool that suspends the
122
+ * run to collect a human answer.
123
+ *
124
+ * Within a single tool-call batch, a named tool that is *already*
125
+ * direct (a real in-process graphTool — the only kind whose body can
126
+ * reach `interrupt()`; graphTools are auto-marked direct by the graph)
127
+ * is executed as its own awaited group **before** its non-interrupting
128
+ * direct siblings. If it interrupts, the ToolNode unwinds before any
129
+ * sibling runs, so a non-idempotent sibling (send_email, billing)
130
+ * cannot execute once on the first pass and AGAIN when LangGraph re-runs
131
+ * the interrupted batch on resume.
132
+ *
133
+ * This set only REORDERS the direct group; it does NOT promote a name
134
+ * into it. A name that resolves to a schema-only event stub (an
135
+ * inherited `toolDefinition` with no executable instance — e.g. in a
136
+ * self-spawned child that scrubs `graphTools`) stays event-dispatched.
137
+ * Forcing such a name direct would invoke the stub, which throws
138
+ * "should not be invoked directly in event-driven mode". For the guard
139
+ * to apply, the interrupting tool must independently be direct.
140
+ *
141
+ * Opt-in and empty by default: when unset (or when no direct batch call
142
+ * matches), direct-batch execution is byte-for-byte unchanged. See the
143
+ * "Resume re-execution" section of {@link HumanInTheLoopConfig} for the
144
+ * batch re-execution contract this guards against.
145
+ */
146
+ interruptingToolNames?: Set<string>;
111
147
  /** Opt-in eager execution for event-driven tool calls. */
112
148
  eagerEventToolExecution?: EagerEventToolExecutionConfig;
113
149
  /**
@@ -292,6 +328,15 @@ export type CodeExecutionToolParams =
292
328
  files?: CodeEnvFile[];
293
329
  /** Optional host-supplied Code API auth headers. */
294
330
  authHeaders?: CodeApiAuthHeaders;
331
+ /**
332
+ * Advertise best-effort stateful sessions in the tool description
333
+ * (variables/files may persist between calls, may reset). Prompt text
334
+ * only, and it must be set here because the description is bound to the
335
+ * LLM at construction time. Pair it with the run-scoped
336
+ * `toolExecution.sandbox.statefulSessions` gate, which drives the wire
337
+ * hint — set both from one flag so the prompt and the backend agree.
338
+ */
339
+ statefulSessions?: boolean;
295
340
  };
296
341
 
297
342
  export type CodeApiAuthHeaderMap = Record<string, string>;
@@ -347,6 +392,13 @@ export type ExecuteResult = {
347
392
  stdout: string;
348
393
  stderr: string;
349
394
  files?: FileRefs;
395
+ /**
396
+ * Durable runtime session id echoed by a stateful Code API backend
397
+ * (hash of tenant+user+hint). Additive; absent on stateless servers.
398
+ */
399
+ runtime_session_id?: string;
400
+ /** Whether this execution reused a warm runtime session or started fresh. */
401
+ runtime_status?: 'new' | 'reused';
350
402
  };
351
403
 
352
404
  /** JSON Schema type definition for tool parameters */
@@ -413,6 +465,12 @@ export type ToolCallRequest = {
413
465
  session_id: string;
414
466
  files?: CodeEnvFile[];
415
467
  };
468
+ /**
469
+ * Stable runtime session hint for stateful sandbox sessions. Orthogonal to
470
+ * `codeSessionContext` (which threads the transient exec-session for file
471
+ * continuity): the hint identifies the durable server-side runtime session.
472
+ */
473
+ runtimeSessionHint?: string;
416
474
  };
417
475
 
418
476
  /** Batch request containing ALL tool calls for a graph step */
@@ -969,6 +1027,32 @@ export type CloudflareSandboxExecutionConfig = {
969
1027
  postEditSyntaxCheck?: LocalExecutionConfig['postEditSyntaxCheck'];
970
1028
  };
971
1029
 
1030
+ export type SandboxExecutionConfig = {
1031
+ /**
1032
+ * Opt into best-effort stateful runtime sessions on the remote Code API
1033
+ * (its warm per-session MicroVM backend). This gate is run-scoped: it only
1034
+ * controls the wire behavior (ToolNode injecting the session hint on
1035
+ * execute_code/bash calls). The transport is otherwise unchanged.
1036
+ *
1037
+ * It does NOT change the model-facing tool description. Tool descriptions are
1038
+ * bound to the LLM at construction time (`createCodeExecutionTool` /
1039
+ * `createBashExecutionTool`), before this run config is applied inside the
1040
+ * graph, so they can only be adjusted via the tools' own `statefulSessions`
1041
+ * factory param. Set BOTH from one flag (as LibreChat does): with this on but
1042
+ * the factory param off, the backend runs statefully while the model is still
1043
+ * told the environment is stateless (non-corrupting — the model just won't
1044
+ * exploit persistence).
1045
+ */
1046
+ statefulSessions?: boolean;
1047
+ /**
1048
+ * Stable identity for the runtime session (e.g. the conversation id). The
1049
+ * server derives the real session id as hash(tenant, user, hint), so this
1050
+ * is never a security boundary. Falls back to `configurable.thread_id` when
1051
+ * omitted.
1052
+ */
1053
+ runtimeSessionHint?: string;
1054
+ };
1055
+
972
1056
  export type ToolExecutionConfig = {
973
1057
  /** `sandbox` preserves the remote Code API behavior and is the default. */
974
1058
  engine?: ToolExecutionEngine;
@@ -976,6 +1060,8 @@ export type ToolExecutionConfig = {
976
1060
  local?: LocalExecutionConfig;
977
1061
  /** Cloudflare Sandbox execution settings used when `engine` is `cloudflare-sandbox`. */
978
1062
  cloudflare?: CloudflareSandboxExecutionConfig;
1063
+ /** Remote sandbox settings; applies when `engine` is `sandbox` or omitted. */
1064
+ sandbox?: SandboxExecutionConfig;
979
1065
  };
980
1066
 
981
1067
  export type ProgrammaticCache = {
@@ -1099,6 +1185,10 @@ export type ProgrammaticExecutionResponse = {
1099
1185
  stderr?: string;
1100
1186
  files?: FileRefs;
1101
1187
 
1188
+ /** Durable runtime session echo from a stateful backend (additive). */
1189
+ runtime_session_id?: string;
1190
+ runtime_status?: 'new' | 'reused';
1191
+
1102
1192
  /** Present when status='error' */
1103
1193
  error?: string;
1104
1194
  };
@@ -1110,6 +1200,9 @@ export type ProgrammaticExecutionArtifact = {
1110
1200
  /** Execution session — see `CodeSessionContext.session_id`. */
1111
1201
  session_id?: string;
1112
1202
  files?: FileRefs;
1203
+ /** Durable runtime session echo from a stateful backend (additive). */
1204
+ runtime_session_id?: string;
1205
+ runtime_status?: 'new' | 'reused';
1113
1206
  };
1114
1207
 
1115
1208
  /** Parameters for creating a bash execution tool (same API as CodeExecutor, bash-only) */
@@ -1134,6 +1227,11 @@ export type ProgrammaticToolCallingParams = {
1134
1227
  debug?: boolean;
1135
1228
  /** Optional host-supplied Code API auth headers. */
1136
1229
  authHeaders?: CodeApiAuthHeaders;
1230
+ /* No `statefulSessions` here: PTC is stateless in v1. The initial
1231
+ * /exec/programmatic request still forwards a ToolNode-injected
1232
+ * `_runtime_session_hint` when present, but there is no factory-level opt-in
1233
+ * to advertise (it would be a no-op). Re-add with real behavior when PTC
1234
+ * stateful prompting lands. */
1137
1235
  };
1138
1236
 
1139
1237
  // ============================================================================
@@ -1166,6 +1264,9 @@ export type CodeExecutionArtifact = {
1166
1264
  /** Execution session — see `CodeSessionContext.session_id`. */
1167
1265
  session_id?: string;
1168
1266
  files?: FileRefs;
1267
+ /** Durable runtime session echo from a stateful backend (additive). */
1268
+ runtime_session_id?: string;
1269
+ runtime_status?: 'new' | 'reused';
1169
1270
  };
1170
1271
 
1171
1272
  /**