@librechat/agents 3.1.80-dev.1 → 3.1.80-dev.3

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 (33) hide show
  1. package/dist/cjs/main.cjs +1 -2
  2. package/dist/cjs/main.cjs.map +1 -1
  3. package/dist/cjs/tools/BashExecutor.cjs +20 -78
  4. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  5. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +5 -1
  6. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  7. package/dist/cjs/tools/CodeExecutor.cjs +26 -106
  8. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  9. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +12 -31
  10. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +37 -14
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/esm/main.mjs +1 -1
  14. package/dist/esm/tools/BashExecutor.mjs +20 -78
  15. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  16. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +6 -2
  17. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  18. package/dist/esm/tools/CodeExecutor.mjs +26 -105
  19. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  20. package/dist/esm/tools/ProgrammaticToolCalling.mjs +12 -31
  21. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  22. package/dist/esm/tools/ToolNode.mjs +37 -14
  23. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  24. package/dist/types/tools/CodeExecutor.d.ts +1 -7
  25. package/dist/types/tools/ProgrammaticToolCalling.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/tools/BashExecutor.ts +24 -104
  28. package/src/tools/BashProgrammaticToolCalling.ts +7 -2
  29. package/src/tools/CodeExecutor.ts +30 -133
  30. package/src/tools/ProgrammaticToolCalling.ts +14 -49
  31. package/src/tools/ToolNode.ts +47 -15
  32. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +32 -131
  33. package/src/tools/__tests__/ToolNode.session.test.ts +182 -0
@@ -1,14 +1,8 @@
1
1
  import { DynamicStructuredTool } from '@langchain/core/tools';
2
2
  import type * as t from '@/types';
3
3
  import { Constants } from '@/common';
4
- export declare const imageExtRegex: RegExp;
5
4
  export declare const getCodeBaseURL: () => string;
6
- /**
7
- * Renders one section of the post-execution file listing. Used by the
8
- * code/bash tool formatters to keep generated outputs and inherited
9
- * inputs visually separated. See BashExecutor for full docs.
10
- */
11
- export declare function renderFileSection(header: string, files: t.FileRefs, defaultMessage: string): string;
5
+ export declare const emptyOutputMessage = "stdout: Empty. Ensure you're writing output explicitly.\n";
12
6
  export declare const CodeExecutionToolSchema: {
13
7
  readonly type: "object";
14
8
  readonly properties: {
@@ -107,6 +107,11 @@ export declare function unwrapToolResponse(result: unknown, isMCPTool: boolean):
107
107
  export declare function executeTools(toolCalls: t.PTCToolCall[], toolMap: t.ToolMap): Promise<t.PTCToolResult[]>;
108
108
  /**
109
109
  * Formats the completed response for the agent.
110
+ *
111
+ * Output is stdout/stderr only — see `CodeExecutor.ts`. The
112
+ * artifact still carries every file so the host's session map
113
+ * stays in sync; the LLM doesn't see them in the tool result text.
114
+ *
110
115
  * @param response - The completed API response
111
116
  * @returns Tuple of [formatted string, artifact]
112
117
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.1.80-dev.1",
3
+ "version": "3.1.80-dev.3",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -3,24 +3,11 @@ import fetch, { RequestInit } from 'node-fetch';
3
3
  import { HttpsProxyAgent } from 'https-proxy-agent';
4
4
  import { tool, DynamicStructuredTool } from '@langchain/core/tools';
5
5
  import type * as t from '@/types';
6
- import { getCodeBaseURL, renderFileSection } from './CodeExecutor';
6
+ import { emptyOutputMessage, getCodeBaseURL } from './CodeExecutor';
7
7
  import { Constants } from '@/common';
8
8
 
9
9
  config();
10
10
 
11
- const otherMessage = 'File is already downloaded by the user';
12
- const inheritedFileMessage =
13
- 'Available as an input — already known to the user';
14
- const accessMessage =
15
- 'Note: Files from previous executions are automatically available and can be modified.';
16
- const emptyOutputMessage =
17
- 'stdout: Empty. Ensure you\'re writing output explicitly.\n';
18
- const inheritedFilesHeader =
19
- 'Available files (inputs, not generated by this execution):';
20
- const generatedFilesHeader = 'Generated files:';
21
- const inheritedNote =
22
- 'Note: Files in "Available files" are inputs the user (or a skill) already provided to the sandbox. They were not produced by this execution and you should not present them as new outputs in your response.';
23
-
24
11
  const baseEndpoint = getCodeBaseURL();
25
12
  const EXEC_ENDPOINT = `${baseEndpoint}/exec`;
26
13
 
@@ -133,54 +120,20 @@ function createBashExecutionTool(
133
120
  ...params,
134
121
  };
135
122
 
123
+ /* See `CodeExecutor.ts` for the rationale — `/files/<session_id>`
124
+ * HTTP fallback was removed because codeapi's sessionAuth requires
125
+ * kind/id query params unavailable at this point. */
136
126
  if (_injected_files && _injected_files.length > 0) {
137
127
  postData.files = _injected_files;
138
- } else if (session_id != null && session_id.length > 0) {
139
- try {
140
- const filesEndpoint = `${baseEndpoint}/files/${session_id}?detail=full`;
141
- const fetchOptions: RequestInit = {
142
- method: 'GET',
143
- headers: {
144
- 'User-Agent': 'LibreChat/1.0',
145
- },
146
- };
147
-
148
- if (process.env.PROXY != null && process.env.PROXY !== '') {
149
- fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
150
- }
151
-
152
- const response = await fetch(filesEndpoint, fetchOptions);
153
- if (!response.ok) {
154
- throw new Error(
155
- `Failed to fetch files for session: ${response.status}`
156
- );
157
- }
158
-
159
- const files = await response.json();
160
- if (Array.isArray(files) && files.length > 0) {
161
- const fileReferences: t.CodeEnvFile[] = files.map((file) => {
162
- const nameParts = file.name.split('/');
163
- const id = nameParts.length > 1 ? nameParts[1].split('.')[0] : '';
164
-
165
- return {
166
- storage_session_id: session_id,
167
- /* `/files` fallback returns code-output files belonging
168
- * to the user; tag them user-private. */
169
- kind: 'user' as const,
170
- id,
171
- /* `resource_id` informational for `kind: 'user'` —
172
- * codeapi derives sessionKey from auth context. */
173
- resource_id: id,
174
- name: file.metadata['original-filename'],
175
- };
176
- });
177
-
178
- postData.files = fileReferences;
179
- }
180
- } catch {
181
- // eslint-disable-next-line no-console
182
- console.warn(`Failed to fetch files for session: ${session_id}`);
183
- }
128
+ } else if (
129
+ session_id != null &&
130
+ session_id.length > 0 &&
131
+ !Array.isArray(postData.files)
132
+ ) {
133
+ // eslint-disable-next-line no-console
134
+ console.debug(
135
+ `[BashExecutor] No injected files for session_id=${session_id} — exec will run without input files`
136
+ );
184
137
  }
185
138
 
186
139
  try {
@@ -202,6 +155,11 @@ function createBashExecutionTool(
202
155
  }
203
156
 
204
157
  const result: t.ExecuteResult = await response.json();
158
+ /* See `CodeExecutor.ts` — file listings were removed from the
159
+ * LLM-facing tool result. Bash especially benefits: models
160
+ * naturally `ls /mnt/data/` to discover what's available
161
+ * rather than relying on a prescriptive summary that
162
+ * misleads as often as it helps. */
205
163
  let formattedOutput = '';
206
164
  if (result.stdout) {
207
165
  formattedOutput += `stdout:\n${result.stdout}\n`;
@@ -209,53 +167,15 @@ function createBashExecutionTool(
209
167
  formattedOutput += emptyOutputMessage;
210
168
  }
211
169
  if (result.stderr) formattedOutput += `stderr:\n${result.stderr}\n`;
212
- if (result.files && result.files.length > 0) {
213
- /* Split inherited (read-only / unchanged-input passthroughs from
214
- * codeapi) from genuine generated outputs. The LLM was previously
215
- * shown skill files under "Generated files:" with the message
216
- * "File is already downloaded by the user", which led it to
217
- * (a) believe it had just produced files it merely referenced
218
- * and (b) sometimes invent paths like /mnt/user-data/uploads/
219
- * trying to find the "originals". Labeling them as inputs makes
220
- * the mental model accurate. */
221
- const inheritedFiles = result.files.filter(
222
- (f) => f.inherited === true
223
- );
224
- const generatedFiles = result.files.filter(
225
- (f) => f.inherited !== true
226
- );
227
-
228
- formattedOutput += renderFileSection(
229
- generatedFilesHeader,
230
- generatedFiles,
231
- otherMessage
232
- );
233
- formattedOutput += renderFileSection(
234
- inheritedFilesHeader,
235
- inheritedFiles,
236
- inheritedFileMessage
237
- );
238
-
239
- if (generatedFiles.length > 0) {
240
- formattedOutput += `\n\n${accessMessage}`;
241
- }
242
- if (inheritedFiles.length > 0) {
243
- formattedOutput += `\n\n${inheritedNote}`;
244
- }
245
- return [
246
- formattedOutput.trim(),
247
- {
248
- session_id: result.session_id,
249
- files: result.files,
250
- } satisfies t.CodeExecutionArtifact,
251
- ];
252
- }
253
170
 
171
+ const hasFiles = result.files != null && result.files.length > 0;
254
172
  return [
255
173
  formattedOutput.trim(),
256
- {
257
- session_id: result.session_id,
258
- } satisfies t.CodeExecutionArtifact,
174
+ (hasFiles
175
+ ? { session_id: result.session_id, files: result.files }
176
+ : {
177
+ session_id: result.session_id,
178
+ }) satisfies t.CodeExecutionArtifact,
259
179
  ];
260
180
  } catch (error) {
261
181
  throw new Error(
@@ -5,7 +5,6 @@ import type * as t from '@/types';
5
5
  import {
6
6
  makeRequest,
7
7
  executeTools,
8
- fetchSessionFiles,
9
8
  formatCompletedResponse,
10
9
  } from './ProgrammaticToolCalling';
11
10
  import { getCodeBaseURL } from './CodeExecutor';
@@ -290,11 +289,17 @@ export function createBashProgrammaticToolCallingTool(
290
289
  );
291
290
  }
292
291
 
292
+ /* `/files/<session_id>` HTTP fallback removed — codeapi's
293
+ * sessionAuth requires kind/id query params unavailable at
294
+ * this point. See `CodeExecutor.ts` for full rationale. */
293
295
  let files: t.CodeEnvFile[] | undefined;
294
296
  if (_injected_files && _injected_files.length > 0) {
295
297
  files = _injected_files;
296
298
  } else if (session_id != null && session_id.length > 0) {
297
- files = await fetchSessionFiles(baseUrl, session_id, proxy);
299
+ // eslint-disable-next-line no-console
300
+ console.debug(
301
+ `[BashProgrammaticToolCalling] No injected files for session_id=${session_id} — exec will run without input files`
302
+ );
298
303
  }
299
304
 
300
305
  let response = await makeRequest(
@@ -8,48 +8,12 @@ import { EnvVar, Constants } from '@/common';
8
8
 
9
9
  config();
10
10
 
11
- export const imageExtRegex = /\.(jpg|jpeg|png|gif|webp)$/i;
12
11
  export const getCodeBaseURL = (): string =>
13
12
  getEnvironmentVariable(EnvVar.CODE_BASEURL) ??
14
13
  Constants.OFFICIAL_CODE_BASEURL;
15
14
 
16
- const imageMessage = 'Image is already displayed to the user';
17
- const otherMessage = 'File is already downloaded by the user';
18
- const inheritedFileMessage =
19
- 'Available as an input — already known to the user';
20
- const accessMessage =
21
- 'Note: Files from previous executions are automatically available and can be modified.';
22
- const emptyOutputMessage =
15
+ export const emptyOutputMessage =
23
16
  'stdout: Empty. Ensure you\'re writing output explicitly.\n';
24
- const inheritedFilesHeader =
25
- 'Available files (inputs, not generated by this execution):';
26
- const generatedFilesHeader = 'Generated files:';
27
- const inheritedNote =
28
- 'Note: Files in "Available files" are inputs the user (or a skill) already provided to the sandbox. They were not produced by this execution and you should not present them as new outputs in your response.';
29
-
30
- /**
31
- * Renders one section of the post-execution file listing. Used by the
32
- * code/bash tool formatters to keep generated outputs and inherited
33
- * inputs visually separated. See BashExecutor for full docs.
34
- */
35
- export function renderFileSection(
36
- header: string,
37
- files: t.FileRefs,
38
- defaultMessage: string
39
- ): string {
40
- if (files.length === 0) return '';
41
- let out = `${header}\n`;
42
- for (let i = 0; i < files.length; i++) {
43
- const file = files[i];
44
- const isImage = imageExtRegex.test(file.name);
45
- out += `- /mnt/data/${file.name} | ${isImage ? imageMessage : defaultMessage}`;
46
- if (i < files.length - 1) {
47
- out += files.length <= 3 ? ', ' : ',\n';
48
- }
49
- }
50
- out += '\n';
51
- return out;
52
- }
53
17
 
54
18
  const SUPPORTED_LANGUAGES = [
55
19
  'py',
@@ -150,65 +114,24 @@ function createCodeExecutionTool(
150
114
  ...params,
151
115
  };
152
116
 
153
- /**
154
- * File injection priority:
155
- * 1. Use _injected_files from ToolNode (avoids /files endpoint race condition)
156
- * 2. Fall back to fetching from /files endpoint if session_id
157
- * provided but no injected files. The /files lookup still uses the
158
- * same id value codeapi stores output files under the exec id as
159
- * their storage prefix, so the two values coincide here.
160
- */
117
+ /* File injection: `_injected_files` from ToolNode (set when host
118
+ * primes a CodeSessionContext) or `params.files` from tool
119
+ * factory (set by hosts that pre-resolve at construction time).
120
+ * The legacy `/files/<session_id>` HTTP fallback was removed
121
+ * codeapi's `sessionAuth` middleware now requires kind/id query
122
+ * params the tool can't supply at this point, so the fetch 400'd
123
+ * silently and the catch swallowed the failure. */
161
124
  if (_injected_files && _injected_files.length > 0) {
162
125
  postData.files = _injected_files;
163
- } else if (session_id != null && session_id.length > 0) {
164
- /** Fallback: fetch from /files endpoint (may have race condition issues) */
165
- try {
166
- const filesEndpoint = `${baseEndpoint}/files/${session_id}?detail=full`;
167
- const fetchOptions: RequestInit = {
168
- method: 'GET',
169
- headers: {
170
- 'User-Agent': 'LibreChat/1.0',
171
- },
172
- };
173
-
174
- if (process.env.PROXY != null && process.env.PROXY !== '') {
175
- fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
176
- }
177
-
178
- const response = await fetch(filesEndpoint, fetchOptions);
179
- if (!response.ok) {
180
- throw new Error(
181
- `Failed to fetch files for session: ${response.status}`
182
- );
183
- }
184
-
185
- const files = await response.json();
186
- if (Array.isArray(files) && files.length > 0) {
187
- const fileReferences: t.CodeEnvFile[] = files.map((file) => {
188
- const nameParts = file.name.split('/');
189
- const id = nameParts.length > 1 ? nameParts[1].split('.')[0] : '';
190
-
191
- return {
192
- storage_session_id: session_id,
193
- /* `/files` fallback returns code-output files belonging
194
- * to the user; tag them user-private. */
195
- kind: 'user' as const,
196
- id,
197
- /* `resource_id` is informational for `kind: 'user'`
198
- * (codeapi derives sessionKey from auth context); use
199
- * the same value as `id` since the `/files` fallback
200
- * doesn't carry separate provenance. */
201
- resource_id: id,
202
- name: file.metadata['original-filename'],
203
- };
204
- });
205
-
206
- postData.files = fileReferences;
207
- }
208
- } catch {
209
- // eslint-disable-next-line no-console
210
- console.warn(`Failed to fetch files for session: ${session_id}`);
211
- }
126
+ } else if (
127
+ session_id != null &&
128
+ session_id.length > 0 &&
129
+ !Array.isArray(postData.files)
130
+ ) {
131
+ // eslint-disable-next-line no-console
132
+ console.debug(
133
+ `[CodeExecutor] No injected files for session_id=${session_id} — exec will run without input files`
134
+ );
212
135
  }
213
136
 
214
137
  try {
@@ -230,6 +153,13 @@ function createCodeExecutionTool(
230
153
  }
231
154
 
232
155
  const result: t.ExecuteResult = await response.json();
156
+ /* Output is stdout/stderr only — file listings were removed
157
+ * because the LLM-facing summary (split inherited/generated
158
+ * with prescriptive notes) caused more confusion than help,
159
+ * especially for bash where models naturally explore
160
+ * `/mnt/data/` themselves. The artifact still carries every
161
+ * file so the host's session map stays in sync; the LLM
162
+ * doesn't see them in the tool result text. */
233
163
  let formattedOutput = '';
234
164
  if (result.stdout) {
235
165
  formattedOutput += `stdout:\n${result.stdout}\n`;
@@ -237,48 +167,15 @@ function createCodeExecutionTool(
237
167
  formattedOutput += emptyOutputMessage;
238
168
  }
239
169
  if (result.stderr) formattedOutput += `stderr:\n${result.stderr}\n`;
240
- if (result.files && result.files.length > 0) {
241
- /* See BashExecutor for the rationale: split inherited (read-only
242
- * passthrough) inputs from real generated outputs so the LLM
243
- * doesn't conflate skill files with newly-produced artifacts. */
244
- const inheritedFiles = result.files.filter(
245
- (f) => f.inherited === true
246
- );
247
- const generatedFiles = result.files.filter(
248
- (f) => f.inherited !== true
249
- );
250
-
251
- formattedOutput += renderFileSection(
252
- generatedFilesHeader,
253
- generatedFiles,
254
- otherMessage
255
- );
256
- formattedOutput += renderFileSection(
257
- inheritedFilesHeader,
258
- inheritedFiles,
259
- inheritedFileMessage
260
- );
261
-
262
- if (generatedFiles.length > 0) {
263
- formattedOutput += `\n\n${accessMessage}`;
264
- }
265
- if (inheritedFiles.length > 0) {
266
- formattedOutput += `\n\n${inheritedNote}`;
267
- }
268
- return [
269
- formattedOutput.trim(),
270
- {
271
- session_id: result.session_id,
272
- files: result.files,
273
- } satisfies t.CodeExecutionArtifact,
274
- ];
275
- }
276
170
 
171
+ const hasFiles = result.files != null && result.files.length > 0;
277
172
  return [
278
173
  formattedOutput.trim(),
279
- {
280
- session_id: result.session_id,
281
- } satisfies t.CodeExecutionArtifact,
174
+ (hasFiles
175
+ ? { session_id: result.session_id, files: result.files }
176
+ : {
177
+ session_id: result.session_id,
178
+ }) satisfies t.CodeExecutionArtifact,
282
179
  ];
283
180
  } catch (error) {
284
181
  throw new Error(
@@ -5,28 +5,11 @@ import { HttpsProxyAgent } from 'https-proxy-agent';
5
5
  import { tool, DynamicStructuredTool } from '@langchain/core/tools';
6
6
  import type { ToolCall } from '@langchain/core/messages/tool';
7
7
  import type * as t from '@/types';
8
- import { getCodeBaseURL, renderFileSection } from './CodeExecutor';
8
+ import { emptyOutputMessage, getCodeBaseURL } from './CodeExecutor';
9
9
  import { Constants } from '@/common';
10
10
 
11
11
  config();
12
12
 
13
- // ============================================================================
14
- // Constants
15
- // ============================================================================
16
-
17
- const otherMessage = 'File is already downloaded by the user';
18
- const inheritedFileMessage =
19
- 'Available as an input — already known to the user';
20
- const inheritedFilesHeader =
21
- 'Available files (inputs, not generated by this execution):';
22
- const generatedFilesHeader = 'Generated files:';
23
- const inheritedNote =
24
- 'Note: Files in "Available files" are inputs the user (or a skill) already provided to the sandbox. They were not produced by this execution and you should not present them as new outputs in your response.';
25
- const accessMessage =
26
- 'Note: Files from previous executions are automatically available and can be modified.';
27
- const emptyOutputMessage =
28
- 'stdout: Empty. Ensure you\'re writing output explicitly.\n';
29
-
30
13
  /** Default max round-trips to prevent infinite loops */
31
14
  const DEFAULT_MAX_ROUND_TRIPS = 20;
32
15
 
@@ -545,6 +528,11 @@ export async function executeTools(
545
528
 
546
529
  /**
547
530
  * Formats the completed response for the agent.
531
+ *
532
+ * Output is stdout/stderr only — see `CodeExecutor.ts`. The
533
+ * artifact still carries every file so the host's session map
534
+ * stays in sync; the LLM doesn't see them in the tool result text.
535
+ *
548
536
  * @param response - The completed API response
549
537
  * @returns Tuple of [formatted string, artifact]
550
538
  */
@@ -563,32 +551,6 @@ export function formatCompletedResponse(
563
551
  formatted += `stderr:\n${response.stderr}\n`;
564
552
  }
565
553
 
566
- if (response.files && response.files.length > 0) {
567
- /* See BashExecutor for the rationale: split inherited (read-only
568
- * passthrough) inputs from real generated outputs so the LLM doesn't
569
- * conflate skill files with newly-produced artifacts. */
570
- const inheritedFiles = response.files.filter((f) => f.inherited === true);
571
- const generatedFiles = response.files.filter((f) => f.inherited !== true);
572
-
573
- formatted += renderFileSection(
574
- generatedFilesHeader,
575
- generatedFiles,
576
- otherMessage
577
- );
578
- formatted += renderFileSection(
579
- inheritedFilesHeader,
580
- inheritedFiles,
581
- inheritedFileMessage
582
- );
583
-
584
- if (generatedFiles.length > 0) {
585
- formatted += `\n\n${accessMessage}`;
586
- }
587
- if (inheritedFiles.length > 0) {
588
- formatted += `\n\n${inheritedNote}`;
589
- }
590
- }
591
-
592
554
  return [
593
555
  formatted.trim(),
594
556
  {
@@ -675,16 +637,19 @@ export function createProgrammaticToolCallingTool(
675
637
  }
676
638
 
677
639
  /**
678
- * File injection priority:
679
- * 1. Use _injected_files from ToolNode (avoids /files endpoint race condition)
680
- * 2. Fall back to fetching from /files endpoint if session_id
681
- * provided but no injected files.
640
+ * File injection: `_injected_files` from ToolNode session
641
+ * context. The legacy `/files/<session_id>` HTTP fallback was
642
+ * removed (see `CodeExecutor.ts`) codeapi's sessionAuth now
643
+ * requires kind/id query params unavailable at this point.
682
644
  */
683
645
  let files: t.CodeEnvFile[] | undefined;
684
646
  if (_injected_files && _injected_files.length > 0) {
685
647
  files = _injected_files;
686
648
  } else if (session_id != null && session_id.length > 0) {
687
- files = await fetchSessionFiles(baseUrl, session_id, proxy);
649
+ // eslint-disable-next-line no-console
650
+ console.debug(
651
+ `[ProgrammaticToolCalling] No injected files for session_id=${session_id} — exec will run without input files`
652
+ );
688
653
  }
689
654
 
690
655
  let response = await makeRequest(
@@ -313,6 +313,15 @@ function toInjectedFileRef(
313
313
  return { ...base, kind: 'user' };
314
314
  }
315
315
 
316
+ /* Stable file identity = `(storage_session_id, id)`. Same name in
317
+ * different storage sessions are distinct files. */
318
+ function fileIdentityKey(file: {
319
+ storage_session_id?: string;
320
+ id: string;
321
+ }): string {
322
+ return `${file.storage_session_id ?? ''}\0${file.id}`;
323
+ }
324
+
316
325
  function updateCodeSession(
317
326
  sessions: t.ToolSessionMap,
318
327
  execSessionId: string,
@@ -324,27 +333,50 @@ function updateCodeSession(
324
333
  | undefined;
325
334
  const existingFiles = existingSession?.files ?? [];
326
335
 
327
- if (newFiles.length > 0) {
328
- const filesWithSession: t.FileRefs = newFiles.map((file) => ({
329
- ...file,
330
- storage_session_id: file.storage_session_id ?? execSessionId,
331
- }));
332
- const newFileNames = new Set(filesWithSession.map((f) => f.name));
333
- const filteredExisting = existingFiles.filter(
334
- (f) => !newFileNames.has(f.name)
335
- );
336
- sessions.set(Constants.EXECUTE_CODE, {
337
- session_id: execSessionId,
338
- files: [...filteredExisting, ...filesWithSession],
339
- lastUpdated: Date.now(),
340
- });
341
- } else {
336
+ if (newFiles.length === 0) {
342
337
  sessions.set(Constants.EXECUTE_CODE, {
343
338
  session_id: execSessionId,
344
339
  files: existingFiles,
345
340
  lastUpdated: Date.now(),
346
341
  });
342
+ return;
343
+ }
344
+
345
+ /* Worker echoes lack ownership identity (kind/resource_id/version) —
346
+ * sandbox doesn't re-attest; that's signed at upload. Merge by
347
+ * (storage_session_id, id) so prior identity survives the echo. */
348
+ const filesWithSession: t.FileRefs = [];
349
+ const newFileNames = new Set<string>();
350
+ const incomingByIdentity = new Map<string, number>();
351
+ for (const file of newFiles) {
352
+ const withSession = {
353
+ ...file,
354
+ storage_session_id: file.storage_session_id ?? execSessionId,
355
+ };
356
+ incomingByIdentity.set(
357
+ fileIdentityKey(withSession),
358
+ filesWithSession.length
359
+ );
360
+ newFileNames.add(withSession.name);
361
+ filesWithSession.push(withSession);
347
362
  }
363
+
364
+ const filteredExisting: t.FileRefs = [];
365
+ for (const e of existingFiles) {
366
+ const idx = incomingByIdentity.get(fileIdentityKey(e));
367
+ if (idx !== undefined) {
368
+ filesWithSession[idx] = { ...e, ...filesWithSession[idx] };
369
+ }
370
+ if (!newFileNames.has(e.name)) {
371
+ filteredExisting.push(e);
372
+ }
373
+ }
374
+
375
+ sessions.set(Constants.EXECUTE_CODE, {
376
+ session_id: execSessionId,
377
+ files: [...filteredExisting, ...filesWithSession],
378
+ lastUpdated: Date.now(),
379
+ });
348
380
  }
349
381
 
350
382
  // eslint-disable-next-line @typescript-eslint/no-explicit-any