@librechat/agents 3.2.55 → 3.2.57

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.
@@ -81,6 +81,7 @@ export declare abstract class Graph<T extends t.BaseGraphState = t.BaseGraphStat
81
81
  * consumes the settled promises while preserving final ToolMessage order.
82
82
  */
83
83
  eagerEventToolExecution: t.EagerEventToolExecutionConfig | undefined;
84
+ codeSessionToolNames: string[] | undefined;
84
85
  eagerEventToolExecutions: Map<string, t.EagerEventToolExecution>;
85
86
  eagerEventToolUsageCount: Map<string, number>;
86
87
  private eagerEventToolUsageCountsByAgentId;
@@ -13,6 +13,7 @@ export declare class Run<_T extends t.BaseGraphState> {
13
13
  private langfuse?;
14
14
  private toolOutputReferences?;
15
15
  private eagerEventToolExecution?;
16
+ private codeSessionToolNames?;
16
17
  private toolExecution?;
17
18
  private subagentUsageSink?;
18
19
  private indexTokenCountMap?;
@@ -91,6 +91,8 @@ export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
91
91
  private eventDrivenMode;
92
92
  /** Opt-in stream-layer prestart config for event-driven tools. */
93
93
  private eagerEventToolExecution?;
94
+ /** Host tools that write to the code sandbox and share its exec session. */
95
+ private codeSessionToolNames?;
94
96
  /** Shared per-run prestarted tool registry populated by ChatModelStreamHandler. */
95
97
  private eagerEventToolExecutions?;
96
98
  /** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
@@ -145,7 +147,7 @@ export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
145
147
  * other's in-flight state.
146
148
  */
147
149
  private anonBatchCounter;
148
- constructor({ tools, toolMap, name, tags, trace, runLangfuse, agentLangfuse, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, eagerEventToolExecution, eagerEventToolExecutions, eagerEventToolUsageCount, agentId, executingAgentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, humanInTheLoop, toolOutputReferences, toolOutputRegistry, toolExecution, fileCheckpointer, }: t.ToolNodeConstructorParams);
150
+ constructor({ tools, toolMap, name, tags, trace, runLangfuse, agentLangfuse, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, eagerEventToolExecution, eagerEventToolExecutions, eagerEventToolUsageCount, agentId, executingAgentId, directToolNames, codeSessionToolNames, maxContextTokens, maxToolResultChars, hookRegistry, humanInTheLoop, toolOutputReferences, toolOutputRegistry, toolExecution, fileCheckpointer, }: t.ToolNodeConstructorParams);
149
151
  invoke(input: any, options?: Partial<RunnableConfig>): Promise<any>;
150
152
  /**
151
153
  * Returns the run-scoped tool output registry, or `undefined` when
@@ -304,6 +306,14 @@ export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
304
306
  * Extracts code execution session context from tool results and stores in Graph.sessions.
305
307
  * Mirrors the session storage logic in handleRunToolCompletions for direct execution.
306
308
  */
309
+ /**
310
+ * True when a tool's successful result should fold its returned exec
311
+ * `session_id` into the shared code session: built-in `CODE_EXECUTION_TOOLS`,
312
+ * plus host-declared sandbox-writing tools (`codeSessionToolNames`, e.g.
313
+ * create_file/edit_file). Kept name-scoped rather than a blanket artifact
314
+ * opt-in so only host-declared tools can influence the shared session.
315
+ */
316
+ private participatesInCodeSession;
307
317
  private storeCodeSessionFromResults;
308
318
  /**
309
319
  * Post-processes standard runTool outputs: dispatches ON_RUN_STEP_COMPLETED
@@ -170,6 +170,15 @@ export type RunConfig = {
170
170
  * eager dispatch unsafe.
171
171
  */
172
172
  eagerEventToolExecution?: EagerEventToolExecutionConfig;
173
+ /**
174
+ * Names of host tools that write to the code-execution sandbox but are not
175
+ * built-in `CODE_EXECUTION_TOOLS` (e.g. LibreChat's create_file/edit_file).
176
+ * Their successful results fold the returned exec `session_id` into the
177
+ * shared code session, so a file such a tool writes is visible to later
178
+ * bash_tool/execute_code calls running in the same sandbox. Host-declared so
179
+ * the SDK stays name-agnostic.
180
+ */
181
+ codeSessionToolNames?: string[];
173
182
  /**
174
183
  * Selects the execution backend for built-in code tools. Omit this to keep
175
184
  * the remote LibreChat Code API sandbox. Set `{ engine: 'local' }` to run
@@ -31,6 +31,16 @@ export type EagerEventToolExecutionConfig = {
31
31
  * ToolMessages so provider message ordering is preserved.
32
32
  */
33
33
  enabled?: boolean;
34
+ /**
35
+ * Tool names that must never be started eagerly. Eager execution
36
+ * speculates on tool args before the model turn commits, so
37
+ * side-effecting tools (e.g. file writes) should opt out: a
38
+ * speculative write can land even if the turn is superseded, and a
39
+ * later arg revision would otherwise trip the "changed after eager
40
+ * execution" guard. Excluded calls fall through to normal ToolNode
41
+ * execution with final args.
42
+ */
43
+ excludeToolNames?: string[];
34
44
  };
35
45
  export type EagerEventToolExecutionOutcome = {
36
46
  results: ToolExecuteResult[];
@@ -90,6 +100,12 @@ export type ToolNodeOptions = {
90
100
  directToolNames?: Set<string>;
91
101
  /** Opt-in eager execution for event-driven tool calls. */
92
102
  eagerEventToolExecution?: EagerEventToolExecutionConfig;
103
+ /**
104
+ * Host tool names that write to the code-execution sandbox but are not
105
+ * built-in `CODE_EXECUTION_TOOLS`. Their exec `session_id` is folded into the
106
+ * shared code session so later bash_tool/execute_code calls see written files.
107
+ */
108
+ codeSessionToolNames?: string[];
93
109
  /** Shared per-run eager execution registry populated by the stream handler. */
94
110
  eagerEventToolExecutions?: Map<string, EagerEventToolExecution>;
95
111
  /** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.2.55",
3
+ "version": "3.2.57",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -4726,4 +4726,148 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
4726
4726
  expect(toolExecuteCalls).toHaveLength(0);
4727
4727
  expect(graph.eagerEventToolExecutions.size).toBe(0);
4728
4728
  });
4729
+
4730
+ it('does not prestart tools listed in excludeToolNames', async () => {
4731
+ const graph = createGraph({
4732
+ eagerEventToolExecution: {
4733
+ enabled: true,
4734
+ excludeToolNames: ['create_file'],
4735
+ },
4736
+ });
4737
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
4738
+ jest
4739
+ .spyOn(events, 'safeDispatchCustomEvent')
4740
+ .mockImplementation(async (event, data): Promise<void> => {
4741
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
4742
+ return;
4743
+ }
4744
+ const batch = data as t.ToolExecuteBatchRequest;
4745
+ toolExecuteCalls.push(batch);
4746
+ batch.resolve([
4747
+ { toolCallId: 'call_file', status: 'success', content: 'ok' },
4748
+ ]);
4749
+ });
4750
+
4751
+ await new ChatModelStreamHandler().handle(
4752
+ GraphEvents.CHAT_MODEL_STREAM,
4753
+ {
4754
+ chunk: {
4755
+ content: '',
4756
+ tool_calls: [
4757
+ {
4758
+ id: 'call_file',
4759
+ name: 'create_file',
4760
+ args: { path: '/mnt/data/x.py', content: 'print(1)' },
4761
+ },
4762
+ ],
4763
+ response_metadata: finalToolCallResponseMetadata,
4764
+ } as unknown as t.StreamChunk,
4765
+ },
4766
+ { langgraph_node: 'agent' },
4767
+ graph
4768
+ );
4769
+
4770
+ // Excluded: no eager execution started; the call falls through to ToolNode.
4771
+ expect(toolExecuteCalls).toHaveLength(0);
4772
+ expect(graph.eagerEventToolExecutions.has('call_file')).toBe(false);
4773
+ });
4774
+
4775
+ it('does not prestart codeSessionToolNames tools even without excludeToolNames', async () => {
4776
+ // A declared session-writing host tool is side-effecting, so it must not be
4777
+ // eagerly prestarted even when the host didn't also list it in excludeToolNames.
4778
+ const graph = createGraph({
4779
+ eagerEventToolExecution: { enabled: true },
4780
+ codeSessionToolNames: ['create_file', 'edit_file'],
4781
+ });
4782
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
4783
+ jest
4784
+ .spyOn(events, 'safeDispatchCustomEvent')
4785
+ .mockImplementation(async (event, data): Promise<void> => {
4786
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
4787
+ return;
4788
+ }
4789
+ const batch = data as t.ToolExecuteBatchRequest;
4790
+ toolExecuteCalls.push(batch);
4791
+ batch.resolve([
4792
+ { toolCallId: 'call_cf2', status: 'success', content: 'ok' },
4793
+ ]);
4794
+ });
4795
+
4796
+ await new ChatModelStreamHandler().handle(
4797
+ GraphEvents.CHAT_MODEL_STREAM,
4798
+ {
4799
+ chunk: {
4800
+ content: '',
4801
+ tool_calls: [
4802
+ {
4803
+ id: 'call_cf2',
4804
+ name: 'create_file',
4805
+ args: { path: '/mnt/data/y.py', content: 'print(2)' },
4806
+ },
4807
+ ],
4808
+ response_metadata: finalToolCallResponseMetadata,
4809
+ } as unknown as t.StreamChunk,
4810
+ },
4811
+ { langgraph_node: 'agent' },
4812
+ graph
4813
+ );
4814
+
4815
+ expect(toolExecuteCalls).toHaveLength(0);
4816
+ expect(graph.eagerEventToolExecutions.has('call_cf2')).toBe(false);
4817
+ });
4818
+
4819
+ it('keeps the direct-tool batch guard when an excluded tool is also direct', async () => {
4820
+ // edit_file is both a direct graph tool AND excluded from eager. A mixed
4821
+ // batch with a direct tool must suppress eager for the whole batch, so the
4822
+ // sibling event tool must NOT prestart — excluding edit_file must not hide
4823
+ // it from the batch-level direct-tool guard.
4824
+ const graph = createGraph({
4825
+ eagerEventToolExecution: {
4826
+ enabled: true,
4827
+ excludeToolNames: ['edit_file'],
4828
+ },
4829
+ getAgentContext: jest.fn(() => ({
4830
+ provider: Providers.ANTHROPIC,
4831
+ reasoningKey: 'reasoning',
4832
+ toolDefinitions: [{ name: 'weather' }, { name: 'edit_file' }],
4833
+ graphTools: [{ name: 'edit_file' }],
4834
+ agentId: 'agent_1',
4835
+ })) as unknown as StandardGraph['getAgentContext'],
4836
+ });
4837
+ const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
4838
+ jest
4839
+ .spyOn(events, 'safeDispatchCustomEvent')
4840
+ .mockImplementation(async (event, data): Promise<void> => {
4841
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
4842
+ return;
4843
+ }
4844
+ toolExecuteCalls.push(data as t.ToolExecuteBatchRequest);
4845
+ (data as t.ToolExecuteBatchRequest).resolve([]);
4846
+ });
4847
+
4848
+ await new ChatModelStreamHandler().handle(
4849
+ GraphEvents.CHAT_MODEL_STREAM,
4850
+ {
4851
+ chunk: {
4852
+ content: '',
4853
+ tool_calls: [
4854
+ { id: 'call_weather', name: 'weather', args: { city: 'NYC' } },
4855
+ {
4856
+ id: 'call_edit',
4857
+ name: 'edit_file',
4858
+ args: { path: '/mnt/data/x.py' },
4859
+ },
4860
+ ],
4861
+ response_metadata: finalToolCallResponseMetadata,
4862
+ } as unknown as t.StreamChunk,
4863
+ },
4864
+ { langgraph_node: 'agent' },
4865
+ graph
4866
+ );
4867
+
4868
+ // Direct tool in batch suppresses eager for the whole batch.
4869
+ expect(toolExecuteCalls).toHaveLength(0);
4870
+ expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(false);
4871
+ expect(graph.eagerEventToolExecutions.has('call_edit')).toBe(false);
4872
+ });
4729
4873
  });
@@ -66,8 +66,8 @@ import {
66
66
  type CallbackEntry,
67
67
  } from '@/utils/callbacks';
68
68
  import { partitionAndMarkOpenRouterToolCache } from '@/llm/openrouter/toolCache';
69
- import { shouldTraceToolNodeForLangfuse } from '@/langfuseToolOutputTracing';
70
69
  import { ToolNode as CustomToolNode, toolsCondition } from '@/tools/ToolNode';
70
+ import { shouldTraceToolNodeForLangfuse } from '@/langfuseToolOutputTracing';
71
71
  import { createLocalCodingToolBundle } from '@/tools/local/LocalCodingTools';
72
72
  import { SubagentExecutor, resolveSubagentConfigs } from '@/tools/subagent';
73
73
  import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
@@ -608,6 +608,7 @@ export abstract class Graph<
608
608
  * consumes the settled promises while preserving final ToolMessage order.
609
609
  */
610
610
  eagerEventToolExecution: t.EagerEventToolExecutionConfig | undefined;
611
+ codeSessionToolNames: string[] | undefined;
611
612
  eagerEventToolExecutions: Map<string, t.EagerEventToolExecution> = new Map();
612
613
  eagerEventToolUsageCount: Map<string, number> = new Map();
613
614
  private eagerEventToolUsageCountsByAgentId: Map<string, Map<string, number>> =
@@ -656,6 +657,7 @@ export abstract class Graph<
656
657
  this.humanInTheLoop = undefined;
657
658
  this.toolOutputReferences = undefined;
658
659
  this.eagerEventToolExecution = undefined;
660
+ this.codeSessionToolNames = undefined;
659
661
  this.eagerEventToolExecutions.clear();
660
662
  this.clearEagerEventToolUsageCounts();
661
663
  this.eagerEventToolCallChunks.clear();
@@ -1260,6 +1262,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1260
1262
  hookRegistry: this.hookRegistry,
1261
1263
  humanInTheLoop: this.humanInTheLoop,
1262
1264
  eagerEventToolExecution: this.eagerEventToolExecution,
1265
+ codeSessionToolNames: this.codeSessionToolNames,
1263
1266
  eagerEventToolExecutions: this.eagerEventToolExecutions,
1264
1267
  eagerEventToolUsageCount: this.getEagerEventToolUsageCount(
1265
1268
  agentContext?.agentId
@@ -1309,6 +1312,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1309
1312
  toolRegistry: agentContext?.toolRegistry,
1310
1313
  sessions: this.sessions,
1311
1314
  toolExecution: this.toolExecution,
1315
+ codeSessionToolNames: this.codeSessionToolNames,
1312
1316
  hookRegistry: this.hookRegistry,
1313
1317
  humanInTheLoop: this.humanInTheLoop,
1314
1318
  maxContextTokens: agentContext?.maxContextTokens,
@@ -2333,6 +2337,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2333
2337
  */
2334
2338
  childGraph.toolOutputReferences = this.toolOutputReferences;
2335
2339
  childGraph.eagerEventToolExecution = this.eagerEventToolExecution;
2340
+ childGraph.codeSessionToolNames = this.codeSessionToolNames;
2336
2341
  childGraph.toolExecution = this.toolExecution;
2337
2342
  childGraph.eventToolExecutionAvailable =
2338
2343
  this.handlerRegistry?.getHandler(GraphEvents.ON_TOOL_EXECUTE) !=
package/src/run.ts CHANGED
@@ -127,6 +127,7 @@ export class Run<_T extends t.BaseGraphState> {
127
127
  private langfuse?: t.LangfuseConfig;
128
128
  private toolOutputReferences?: t.ToolOutputReferencesConfig;
129
129
  private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
130
+ private codeSessionToolNames?: string[];
130
131
  private toolExecution?: t.ToolExecutionConfig;
131
132
  private subagentUsageSink?: t.SubagentUsageSink;
132
133
  private indexTokenCountMap?: Record<string, number>;
@@ -182,6 +183,7 @@ export class Run<_T extends t.BaseGraphState> {
182
183
  this.langfuse = config.langfuse;
183
184
  this.toolOutputReferences = config.toolOutputReferences;
184
185
  this.eagerEventToolExecution = config.eagerEventToolExecution;
186
+ this.codeSessionToolNames = config.codeSessionToolNames;
185
187
  this.toolExecution = config.toolExecution;
186
188
  this.subagentUsageSink = config.subagentUsageSink;
187
189
 
@@ -268,6 +270,7 @@ export class Run<_T extends t.BaseGraphState> {
268
270
  standardGraph.humanInTheLoop = this.humanInTheLoop;
269
271
  standardGraph.toolOutputReferences = this.toolOutputReferences;
270
272
  standardGraph.eagerEventToolExecution = this.eagerEventToolExecution;
273
+ standardGraph.codeSessionToolNames = this.codeSessionToolNames;
271
274
  standardGraph.toolExecution = this.toolExecution;
272
275
  this.Graph = standardGraph;
273
276
  return standardGraph.createWorkflow();
@@ -297,6 +300,7 @@ export class Run<_T extends t.BaseGraphState> {
297
300
  multiAgentGraph.humanInTheLoop = this.humanInTheLoop;
298
301
  multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
299
302
  multiAgentGraph.eagerEventToolExecution = this.eagerEventToolExecution;
303
+ multiAgentGraph.codeSessionToolNames = this.codeSessionToolNames;
300
304
  multiAgentGraph.toolExecution = this.toolExecution;
301
305
  this.Graph = multiAgentGraph;
302
306
  return multiAgentGraph.createWorkflow();
package/src/stream.ts CHANGED
@@ -127,6 +127,24 @@ function hasToolOutputReference(value: unknown): boolean {
127
127
  return false;
128
128
  }
129
129
 
130
+ function isEagerExecutionExcludedTool(
131
+ name: string,
132
+ graph: StandardGraph
133
+ ): boolean {
134
+ if (name === '') {
135
+ return false;
136
+ }
137
+ const excluded = graph.eagerEventToolExecution?.excludeToolNames;
138
+ if (excluded != null && excluded.includes(name)) {
139
+ return true;
140
+ }
141
+ // A code-session participant writes to the shared sandbox, so it is
142
+ // side-effecting: never prestart it speculatively (a revised/superseded turn
143
+ // would leave the write applied). Implies exclusion without the host having
144
+ // to also list the name in excludeToolNames.
145
+ return graph.codeSessionToolNames?.includes(name) === true;
146
+ }
147
+
130
148
  function isDirectGraphTool(
131
149
  name: string,
132
150
  agentContext: AgentContext | undefined
@@ -184,7 +202,8 @@ function getCodeSessionContext(
184
202
  if (
185
203
  !CODE_EXECUTION_TOOLS.has(name) &&
186
204
  name !== Constants.SKILL_TOOL &&
187
- name !== Constants.READ_FILE
205
+ name !== Constants.READ_FILE &&
206
+ graph.codeSessionToolNames?.includes(name) !== true
188
207
  ) {
189
208
  return undefined;
190
209
  }
@@ -615,7 +634,7 @@ function createEagerToolExecutionPlan(args: {
615
634
  return undefined;
616
635
  }
617
636
 
618
- const candidateToolCalls = skipExisting
637
+ const unstartedToolCalls = skipExisting
619
638
  ? toolCalls.filter((toolCall) => {
620
639
  if (toolCall.id == null || toolCall.id === '') {
621
640
  return true;
@@ -623,6 +642,13 @@ function createEagerToolExecutionPlan(args: {
623
642
  return !graph.eagerEventToolExecutions.has(toolCall.id);
624
643
  })
625
644
  : toolCalls;
645
+ // Drop host-excluded tools only AFTER the batch-level guards above have run
646
+ // against the full batch, so excluding a call never hides a sibling direct
647
+ // tool from `hasDirectToolCallInBatch`. Excluded calls fall through to normal
648
+ // ToolNode execution; siblings may still eager-execute.
649
+ const candidateToolCalls = unstartedToolCalls.filter(
650
+ (toolCall) => !isEagerExecutionExcludedTool(toolCall.name, graph)
651
+ );
626
652
  if (candidateToolCalls.length === 0) {
627
653
  return [];
628
654
  }
@@ -439,6 +439,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
439
439
  private eventDrivenMode: boolean = false;
440
440
  /** Opt-in stream-layer prestart config for event-driven tools. */
441
441
  private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
442
+ /** Host tools that write to the code sandbox and share its exec session. */
443
+ private codeSessionToolNames?: ReadonlySet<string>;
442
444
  /** Shared per-run prestarted tool registry populated by ChatModelStreamHandler. */
443
445
  private eagerEventToolExecutions?: Map<string, t.EagerEventToolExecution>;
444
446
  /** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
@@ -515,6 +517,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
515
517
  agentId,
516
518
  executingAgentId,
517
519
  directToolNames,
520
+ codeSessionToolNames,
518
521
  maxContextTokens,
519
522
  maxToolResultChars,
520
523
  hookRegistry,
@@ -552,6 +555,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
552
555
  // existing agentId option) still get attribution without knowing the new option.
553
556
  this.executingAgentId = executingAgentId ?? agentId;
554
557
  this.directToolNames = directToolNames;
558
+ this.codeSessionToolNames =
559
+ codeSessionToolNames != null && codeSessionToolNames.length > 0
560
+ ? new Set(codeSessionToolNames)
561
+ : undefined;
555
562
  this.maxToolResultChars =
556
563
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
557
564
  this.hookRegistry = hookRegistry;
@@ -973,7 +980,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
973
980
  * still need to travel through `_injected_files`; the legacy
974
981
  * `/files/<session_id>` fallback was removed from the executors.
975
982
  */
976
- if (CODE_EXECUTION_TOOLS.has(call.name)) {
983
+ if (this.participatesInCodeSession(call.name)) {
977
984
  const codeSession = this.sessions?.get(Constants.EXECUTE_CODE) as
978
985
  | t.CodeSessionContext
979
986
  | undefined;
@@ -1759,6 +1766,23 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1759
1766
  * Extracts code execution session context from tool results and stores in Graph.sessions.
1760
1767
  * Mirrors the session storage logic in handleRunToolCompletions for direct execution.
1761
1768
  */
1769
+ /**
1770
+ * True when a tool's successful result should fold its returned exec
1771
+ * `session_id` into the shared code session: built-in `CODE_EXECUTION_TOOLS`,
1772
+ * plus host-declared sandbox-writing tools (`codeSessionToolNames`, e.g.
1773
+ * create_file/edit_file). Kept name-scoped rather than a blanket artifact
1774
+ * opt-in so only host-declared tools can influence the shared session.
1775
+ */
1776
+ private participatesInCodeSession(name: string): boolean {
1777
+ if (name === '') {
1778
+ return false;
1779
+ }
1780
+ return (
1781
+ CODE_EXECUTION_TOOLS.has(name) ||
1782
+ this.codeSessionToolNames?.has(name) === true
1783
+ );
1784
+ }
1785
+
1762
1786
  private storeCodeSessionFromResults(
1763
1787
  results: t.ToolExecuteResult[],
1764
1788
  requestMap: Map<string, t.ToolCallRequest>
@@ -1777,7 +1801,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1777
1801
  if (
1778
1802
  request?.name == null ||
1779
1803
  request.name === '' ||
1780
- (!CODE_EXECUTION_TOOLS.has(request.name) &&
1804
+ (!this.participatesInCodeSession(request.name) &&
1781
1805
  request.name !== Constants.SKILL_TOOL)
1782
1806
  ) {
1783
1807
  continue;
@@ -1830,7 +1854,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1830
1854
  continue;
1831
1855
  }
1832
1856
 
1833
- if (this.sessions && CODE_EXECUTION_TOOLS.has(call.name)) {
1857
+ if (this.sessions && this.participatesInCodeSession(call.name)) {
1834
1858
  const artifact = toolMessage.artifact as
1835
1859
  | t.CodeExecutionArtifact
1836
1860
  | undefined;
@@ -2463,7 +2487,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2463
2487
  const plan = buildToolExecutionRequestPlan({
2464
2488
  toolCalls: approvedEntries.map((entry) => {
2465
2489
  const codeSessionContext =
2466
- CODE_EXECUTION_TOOLS.has(entry.call.name) ||
2490
+ this.participatesInCodeSession(entry.call.name) ||
2467
2491
  entry.call.name === Constants.SKILL_TOOL ||
2468
2492
  entry.call.name === Constants.READ_FILE
2469
2493
  ? this.getCodeSessionContext()
@@ -140,6 +140,52 @@ describe('ToolNode code execution session management', () => {
140
140
  expect(capturedConfigs[0]._injected_files).toBeUndefined();
141
141
  });
142
142
 
143
+ it('injects the existing session into declared host tools on the direct path', async () => {
144
+ const capturedConfigs: Record<string, unknown>[] = [];
145
+ const sessions: t.ToolSessionMap = new Map();
146
+ sessions.set(Constants.EXECUTE_CODE, {
147
+ session_id: 'host-session',
148
+ files: [
149
+ { id: 'g1', name: 'gen.png', storage_session_id: 'host-session' },
150
+ ],
151
+ lastUpdated: Date.now(),
152
+ } satisfies t.CodeSessionContext);
153
+
154
+ const cfTool = tool(
155
+ async (_input, config) => {
156
+ capturedConfigs.push({ ...(config.toolCall ?? {}) });
157
+ return 'Created /mnt/data/x.py';
158
+ },
159
+ {
160
+ name: 'create_file',
161
+ description: 'write a file',
162
+ schema: z.object({ path: z.string(), content: z.string() }),
163
+ }
164
+ ) as unknown as StructuredToolInterface;
165
+
166
+ const toolNode = new ToolNode({
167
+ tools: [cfTool],
168
+ sessions,
169
+ codeSessionToolNames: ['create_file', 'edit_file'],
170
+ });
171
+
172
+ const aiMsg = new AIMessage({
173
+ content: '',
174
+ tool_calls: [
175
+ {
176
+ id: 'call_cf',
177
+ name: 'create_file',
178
+ args: { path: '/mnt/data/x.py', content: 'print(1)' },
179
+ },
180
+ ],
181
+ });
182
+ await toolNode.invoke({ messages: [aiMsg] });
183
+
184
+ // Declared host tool writes into the current sandbox, not a fresh one.
185
+ expect(capturedConfigs).toHaveLength(1);
186
+ expect(capturedConfigs[0].session_id).toBe('host-session');
187
+ });
188
+
143
189
  it('preserves per-file storage_session_id for multi-session files', async () => {
144
190
  const capturedConfigs: Record<string, unknown>[] = [];
145
191
  const sessions: t.ToolSessionMap = new Map();
@@ -774,6 +820,78 @@ describe('ToolNode code execution session management', () => {
774
820
  expect(sessions.has(Constants.EXECUTE_CODE)).toBe(false);
775
821
  });
776
822
 
823
+ it('stores the exec session for host tools declared in codeSessionToolNames', () => {
824
+ const sessions: t.ToolSessionMap = new Map();
825
+ const toolNode = new ToolNode({
826
+ tools: [createMockCodeTool({ capturedConfigs: [] })],
827
+ sessions,
828
+ eventDrivenMode: true,
829
+ codeSessionToolNames: ['create_file', 'edit_file'],
830
+ });
831
+
832
+ const storeMethod = (
833
+ toolNode as unknown as {
834
+ storeCodeSessionFromResults: (
835
+ results: t.ToolExecuteResult[],
836
+ requestMap: Map<string, t.ToolCallRequest>
837
+ ) => void;
838
+ }
839
+ ).storeCodeSessionFromResults.bind(toolNode);
840
+
841
+ storeMethod(
842
+ [
843
+ {
844
+ toolCallId: 'tc-cf',
845
+ content: 'Created /mnt/data/x.py (10 chars).',
846
+ artifact: { session_id: 'authoring-session' },
847
+ status: 'success',
848
+ },
849
+ ],
850
+ new Map([['tc-cf', { id: 'tc-cf', name: 'create_file', args: {} }]])
851
+ );
852
+
853
+ // create_file's exec session is folded into the shared code session, so a
854
+ // later bash_tool/execute_code call reuses the same sandbox.
855
+ const stored = sessions.get(Constants.EXECUTE_CODE) as
856
+ | t.CodeSessionContext
857
+ | undefined;
858
+ expect(stored?.session_id).toBe('authoring-session');
859
+ });
860
+
861
+ it('does not store a host authoring tool session when not declared', () => {
862
+ const sessions: t.ToolSessionMap = new Map();
863
+ const toolNode = new ToolNode({
864
+ tools: [createMockCodeTool({ capturedConfigs: [] })],
865
+ sessions,
866
+ eventDrivenMode: true,
867
+ // codeSessionToolNames omitted — create_file must NOT be treated as a
868
+ // code-session participant.
869
+ });
870
+
871
+ const storeMethod = (
872
+ toolNode as unknown as {
873
+ storeCodeSessionFromResults: (
874
+ results: t.ToolExecuteResult[],
875
+ requestMap: Map<string, t.ToolCallRequest>
876
+ ) => void;
877
+ }
878
+ ).storeCodeSessionFromResults.bind(toolNode);
879
+
880
+ storeMethod(
881
+ [
882
+ {
883
+ toolCallId: 'tc-cf2',
884
+ content: 'Created /mnt/data/x.py (10 chars).',
885
+ artifact: { session_id: 'authoring-session' },
886
+ status: 'success',
887
+ },
888
+ ],
889
+ new Map([['tc-cf2', { id: 'tc-cf2', name: 'create_file', args: {} }]])
890
+ );
891
+
892
+ expect(sessions.has(Constants.EXECUTE_CODE)).toBe(false);
893
+ });
894
+
777
895
  it('ignores error results', () => {
778
896
  const sessions: t.ToolSessionMap = new Map();
779
897
 
@@ -1102,5 +1220,47 @@ describe('ToolNode code execution session management', () => {
1102
1220
  expect(capturedRequests[0].name).toBe('web_search');
1103
1221
  expect(capturedRequests[0].codeSessionContext).toBeUndefined();
1104
1222
  });
1223
+
1224
+ it('attaches codeSessionContext to declared host tools so they write into the current sandbox', async () => {
1225
+ const sessions: t.ToolSessionMap = new Map();
1226
+ sessions.set(Constants.EXECUTE_CODE, {
1227
+ session_id: 'cf-session',
1228
+ files: [
1229
+ { id: 'g1', name: 'gen.png', storage_session_id: 'cf-session' },
1230
+ ],
1231
+ lastUpdated: Date.now(),
1232
+ } satisfies t.CodeSessionContext);
1233
+
1234
+ const { capturedRequests } = captureBatchRequests();
1235
+
1236
+ const toolNode = new ToolNode({
1237
+ tools: [createDummyTool('create_file')],
1238
+ sessions,
1239
+ eventDrivenMode: true,
1240
+ codeSessionToolNames: ['create_file', 'edit_file'],
1241
+ toolCallStepIds: new Map([['call_cf', 'step_cf']]),
1242
+ });
1243
+
1244
+ const aiMsg = new AIMessage({
1245
+ content: '',
1246
+ tool_calls: [
1247
+ {
1248
+ id: 'call_cf',
1249
+ name: 'create_file',
1250
+ args: { path: '/mnt/data/x.py', content: 'print(1)' },
1251
+ },
1252
+ ],
1253
+ });
1254
+
1255
+ await toolNode.invoke({ messages: [aiMsg] });
1256
+
1257
+ expect(capturedRequests).toHaveLength(1);
1258
+ expect(capturedRequests[0].name).toBe('create_file');
1259
+ // Existing session is injected so create_file writes into the current
1260
+ // sandbox instead of spawning a separate one.
1261
+ expect(capturedRequests[0].codeSessionContext?.session_id).toBe(
1262
+ 'cf-session'
1263
+ );
1264
+ });
1105
1265
  });
1106
1266
  });
package/src/types/run.ts CHANGED
@@ -184,6 +184,15 @@ export type RunConfig = {
184
184
  * eager dispatch unsafe.
185
185
  */
186
186
  eagerEventToolExecution?: EagerEventToolExecutionConfig;
187
+ /**
188
+ * Names of host tools that write to the code-execution sandbox but are not
189
+ * built-in `CODE_EXECUTION_TOOLS` (e.g. LibreChat's create_file/edit_file).
190
+ * Their successful results fold the returned exec `session_id` into the
191
+ * shared code session, so a file such a tool writes is visible to later
192
+ * bash_tool/execute_code calls running in the same sandbox. Host-declared so
193
+ * the SDK stays name-agnostic.
194
+ */
195
+ codeSessionToolNames?: string[];
187
196
  /**
188
197
  * Selects the execution backend for built-in code tools. Omit this to keep
189
198
  * the remote LibreChat Code API sandbox. Set `{ engine: 'local' }` to run