@librechat/agents 3.1.80-dev.0 → 3.1.80-dev.2

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.
@@ -144,14 +144,33 @@ export declare const CODE_ENV_KINDS: readonly ["skill", "agent", "user"];
144
144
  export type CodeEnvKind = (typeof CODE_ENV_KINDS)[number];
145
145
  type CodeEnvFileBase = {
146
146
  /**
147
- * Resource identity. Semantics depend on `kind`:
148
- * - `skill`: skill `_id` (sessionKey-meaningful, cross-user shared).
149
- * - `agent`: agent id (sessionKey-meaningful, cross-user shared).
150
- * - `user`: informational only codeapi derives sessionKey from
151
- * the auth-context user. Kept on the type for shape uniformity;
152
- * do not rely on it for routing.
147
+ * **Storage file id** the per-file uuid the file_server returns
148
+ * at upload time. Identifies the bytes at
149
+ * `<storage_session_id>/<id>` in the object bucket; used by the
150
+ * worker to fetch the file and by codeapi's auth layer for the
151
+ * upload-existence check.
152
+ *
153
+ * Distinct from `resource_id` below — that's the entity that owns
154
+ * this file's storage session (skill `_id`, agent id, etc.), used
155
+ * for sessionKey resolution. Conflating the two caused
156
+ * shared-kind authorization to fail because the sessionKey
157
+ * re-derivation used the storage nanoid where the resource id was
158
+ * required. See codeapi #1455 review.
153
159
  */
154
160
  id: string;
161
+ /**
162
+ * **Resource id** — the entity that owns this file's storage
163
+ * session. Skill `_id` for `kind: 'skill'`, agent id for `'agent'`,
164
+ * informational only for `'user'` (codeapi resolves user identity
165
+ * from auth context). Kept on the type for shape uniformity across
166
+ * kinds.
167
+ *
168
+ * Drives codeapi's `resolveSessionKey` switch — the sessionKey
169
+ * embeds this value (`<tenant>:<kind>:<resource_id>[:v:<version>]`)
170
+ * so cross-user-within-tenant sharing for shared kinds is a
171
+ * designed property of the kind switch.
172
+ */
173
+ resource_id: string;
155
174
  name: string;
156
175
  /**
157
176
  * Storage session — the long-lived bucket where this file's bytes
@@ -189,12 +208,17 @@ export type CodeExecutionToolParams = undefined | {
189
208
  };
190
209
  export type FileRef = {
191
210
  /**
192
- * Resource identity. Semantics depend on `kind` (when present):
193
- * - `skill` / `agent`: shared resource id (sessionKey-meaningful).
194
- * - `user`: informational only — codeapi derives sessionKey from
195
- * the auth-context user. Do not rely on it for routing.
211
+ * Storage file id (the per-file uuid). See `CodeEnvFile.id` for
212
+ * the full motivation behind the storage-vs-resource split.
196
213
  */
197
214
  id: string;
215
+ /**
216
+ * Resource id — the entity that owns this file's storage session.
217
+ * Optional on `FileRef` (post-execute artifact refs may not carry
218
+ * resource provenance for outputs); required on `CodeEnvFile`
219
+ * (the input wire shape).
220
+ */
221
+ resource_id?: string;
198
222
  name: string;
199
223
  path?: string;
200
224
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.1.80-dev.0",
3
+ "version": "3.1.80-dev.2",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -168,6 +168,9 @@ function createBashExecutionTool(
168
168
  * to the user; tag them user-private. */
169
169
  kind: 'user' as const,
170
170
  id,
171
+ /* `resource_id` informational for `kind: 'user'` —
172
+ * codeapi derives sessionKey from auth context. */
173
+ resource_id: id,
171
174
  name: file.metadata['original-filename'],
172
175
  };
173
176
  });
@@ -194,6 +194,11 @@ function createCodeExecutionTool(
194
194
  * to the user; tag them user-private. */
195
195
  kind: 'user' as const,
196
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,
197
202
  name: file.metadata['original-filename'],
198
203
  };
199
204
  });
@@ -309,6 +309,9 @@ export async function fetchSessionFiles(
309
309
  * the user; tag them user-private. */
310
310
  kind: 'user' as const,
311
311
  id,
312
+ /* `resource_id` informational for `kind: 'user'` —
313
+ * codeapi derives sessionKey from auth context. */
314
+ resource_id: id,
312
315
  name: (file.metadata as Record<string, unknown>)[
313
316
  'original-filename'
314
317
  ] as string,
@@ -274,10 +274,20 @@ function normalizeApprovalDecisions(
274
274
  * the upstream contract bug surfaces as a degraded sessionKey rather
275
275
  * than a runtime crash; primeSkillFiles is the only writer, and it
276
276
  * always sets `version` — see LC packages/api/src/agents/skillFiles.ts.
277
+ *
278
+ * `resource_id` carries the entity-that-owns-this-file's-session
279
+ * identity (skill `_id` etc.); falls back to `id` (the storage
280
+ * file_id) for inputs that haven't been updated to send the field
281
+ * explicitly. The fallback degrades sessionKey resolution on the
282
+ * codeapi side for shared kinds (it'll match the storage nanoid
283
+ * against a skill _id and 403) — but won't crash, so an unmigrated
284
+ * client still produces a diagnosable error instead of a stack
285
+ * trace.
277
286
  */
278
287
  function toInjectedFileRef(
279
288
  file: {
280
289
  id: string;
290
+ resource_id?: string;
281
291
  name: string;
282
292
  storage_session_id?: string;
283
293
  kind?: t.CodeEnvKind;
@@ -287,6 +297,7 @@ function toInjectedFileRef(
287
297
  ): t.CodeEnvFile {
288
298
  const base = {
289
299
  id: file.id,
300
+ resource_id: file.resource_id ?? file.id,
290
301
  name: file.name,
291
302
  /* Inline `content` files have no persistent storage location;
292
303
  * fall back to the execution session id for those entries. */
@@ -302,6 +313,15 @@ function toInjectedFileRef(
302
313
  return { ...base, kind: 'user' };
303
314
  }
304
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
+
305
325
  function updateCodeSession(
306
326
  sessions: t.ToolSessionMap,
307
327
  execSessionId: string,
@@ -313,27 +333,50 @@ function updateCodeSession(
313
333
  | undefined;
314
334
  const existingFiles = existingSession?.files ?? [];
315
335
 
316
- if (newFiles.length > 0) {
317
- const filesWithSession: t.FileRefs = newFiles.map((file) => ({
318
- ...file,
319
- storage_session_id: file.storage_session_id ?? execSessionId,
320
- }));
321
- const newFileNames = new Set(filesWithSession.map((f) => f.name));
322
- const filteredExisting = existingFiles.filter(
323
- (f) => !newFileNames.has(f.name)
324
- );
325
- sessions.set(Constants.EXECUTE_CODE, {
326
- session_id: execSessionId,
327
- files: [...filteredExisting, ...filesWithSession],
328
- lastUpdated: Date.now(),
329
- });
330
- } else {
336
+ if (newFiles.length === 0) {
331
337
  sessions.set(Constants.EXECUTE_CODE, {
332
338
  session_id: execSessionId,
333
339
  files: existingFiles,
334
340
  lastUpdated: Date.now(),
335
341
  });
342
+ return;
336
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);
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
+ });
337
380
  }
338
381
 
339
382
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -84,12 +84,20 @@ describe('ToolNode code execution session management', () => {
84
84
  expect(capturedConfigs[0]._injected_files).toEqual([
85
85
  {
86
86
  id: 'file1',
87
+ /* `resource_id` defaults to `id` (the storage uuid) when
88
+ * the input ref didn't supply it — informational for
89
+ * `kind: 'user'` since codeapi derives sessionKey from
90
+ * auth context. The real LC priming chain DOES supply
91
+ * resource_id explicitly; the default is a back-compat
92
+ * landing for inputs that haven't been updated. */
93
+ resource_id: 'file1',
87
94
  name: 'data.csv',
88
95
  storage_session_id: 'prev-session-abc',
89
96
  kind: 'user',
90
97
  },
91
98
  {
92
99
  id: 'file2',
100
+ resource_id: 'file2',
93
101
  name: 'chart.png',
94
102
  storage_session_id: 'prev-session-abc',
95
103
  kind: 'user',
@@ -195,6 +203,9 @@ describe('ToolNode code execution session management', () => {
195
203
  expect(files).toEqual([
196
204
  {
197
205
  id: 'skill-123',
206
+ /* Default fallback when input doesn't supply `resource_id`.
207
+ * Production LC sets it explicitly to the skill's `_id`. */
208
+ resource_id: 'skill-123',
198
209
  name: 'demo/SKILL.md',
199
210
  storage_session_id: 'session-A',
200
211
  kind: 'skill',
@@ -202,6 +213,7 @@ describe('ToolNode code execution session management', () => {
202
213
  },
203
214
  {
204
215
  id: 'user-file',
216
+ resource_id: 'user-file',
205
217
  name: 'attachment.csv',
206
218
  storage_session_id: 'session-B',
207
219
  kind: 'user',
@@ -241,6 +253,9 @@ describe('ToolNode code execution session management', () => {
241
253
  files: [
242
254
  {
243
255
  id: 'ef1',
256
+ /* `resource_id` defaults to `id` when input doesn't carry
257
+ * separate provenance — see toInjectedFileRef. */
258
+ resource_id: 'ef1',
244
259
  name: 'out.parquet',
245
260
  storage_session_id: 'evt-session',
246
261
  kind: 'user',
@@ -327,6 +342,8 @@ describe('ToolNode code execution session management', () => {
327
342
  files: [
328
343
  {
329
344
  id: 'skill-abc',
345
+ /* Default fallback when input doesn't supply `resource_id`. */
346
+ resource_id: 'skill-abc',
330
347
  name: 'demo/SKILL.md',
331
348
  storage_session_id: 'evt-session',
332
349
  kind: 'skill',
@@ -334,6 +351,7 @@ describe('ToolNode code execution session management', () => {
334
351
  },
335
352
  {
336
353
  id: 'usr1',
354
+ resource_id: 'usr1',
337
355
  name: 'data.csv',
338
356
  storage_session_id: 'evt-session',
339
357
  kind: 'user',
@@ -494,6 +512,188 @@ describe('ToolNode code execution session management', () => {
494
512
  expect(chartFile!.storage_session_id).toBe('new-sess');
495
513
  });
496
514
 
515
+ it('preserves prior kind/resource_id/version when worker echoes inherited file (skill 403 regression)', () => {
516
+ /**
517
+ * Regression for the codeapi `session_key_mismatch` 403 that
518
+ * fires on the second `/exec` after a successful skill prime.
519
+ *
520
+ * Worker `inherited: true` echoes carry only
521
+ * `(id, name, storage_session_id)` — the sandbox doesn't know
522
+ * the resource identity (`kind`, `resource_id`, `version`),
523
+ * which was signed at upload time. If `updateCodeSession`
524
+ * replaces the prior entry verbatim from the echo, the next
525
+ * `_injected_files` derivation reads `kind: undefined` and
526
+ * `toInjectedFileRef` falls back to `kind: 'user'` +
527
+ * `resource_id: file.id`. Codeapi then resolves
528
+ * `legacy:user:<authContext.userId>`, which doesn't match the
529
+ * cached `legacy:skill:<skillId>:v:<v>` set at upload, and
530
+ * authorization 403s.
531
+ *
532
+ * The merge must overlay the echo onto the prior entry by
533
+ * `(storage_session_id, id)` so identity survives.
534
+ */
535
+ const SKILL_ID = '69dcf561f37f717858d4d072';
536
+ const SKILL_VERSION = 59;
537
+ const STORAGE_SESSION = 'p28pbmz0ejTZ8MMEkObN8';
538
+ const FILE_ID = 'JqnzC4f6gpirzW0yq_obR';
539
+
540
+ const sessions: t.ToolSessionMap = new Map();
541
+ sessions.set(Constants.EXECUTE_CODE, {
542
+ session_id: STORAGE_SESSION,
543
+ files: [
544
+ {
545
+ id: FILE_ID,
546
+ resource_id: SKILL_ID,
547
+ name: 'pptx/editing.md',
548
+ storage_session_id: STORAGE_SESSION,
549
+ kind: 'skill',
550
+ version: SKILL_VERSION,
551
+ },
552
+ ],
553
+ lastUpdated: Date.now(),
554
+ } satisfies t.CodeSessionContext);
555
+
556
+ const mockTool = createMockCodeTool({ capturedConfigs: [] });
557
+ const toolNode = new ToolNode({
558
+ tools: [mockTool],
559
+ sessions,
560
+ eventDrivenMode: true,
561
+ });
562
+
563
+ const storeMethod = (
564
+ toolNode as unknown as {
565
+ storeCodeSessionFromResults: (
566
+ results: t.ToolExecuteResult[],
567
+ requestMap: Map<string, t.ToolCallRequest>
568
+ ) => void;
569
+ }
570
+ ).storeCodeSessionFromResults.bind(toolNode);
571
+
572
+ /* Worker echo shape — exactly what codeapi sends on
573
+ * `inherited: true` files. No kind, no resource_id, no version. */
574
+ storeMethod(
575
+ [
576
+ {
577
+ toolCallId: 'tc-skill-rerun',
578
+ content: 'output',
579
+ artifact: {
580
+ session_id: 'exec-sess-2',
581
+ files: [
582
+ {
583
+ id: FILE_ID,
584
+ name: 'pptx/editing.md',
585
+ storage_session_id: STORAGE_SESSION,
586
+ inherited: true,
587
+ } as unknown as t.FileRefs[number],
588
+ ],
589
+ },
590
+ status: 'success',
591
+ },
592
+ ],
593
+ new Map([
594
+ [
595
+ 'tc-skill-rerun',
596
+ { id: 'tc-skill-rerun', name: Constants.EXECUTE_CODE, args: {} },
597
+ ],
598
+ ])
599
+ );
600
+
601
+ const stored = sessions.get(
602
+ Constants.EXECUTE_CODE
603
+ ) as t.CodeSessionContext;
604
+ const merged = stored.files!.find((f) => f.id === FILE_ID);
605
+ expect(merged).toBeDefined();
606
+ /* Identity preserved from prior entry: */
607
+ expect(merged!.kind).toBe('skill');
608
+ expect(merged!.resource_id).toBe(SKILL_ID);
609
+ expect((merged as { version?: number }).version).toBe(SKILL_VERSION);
610
+ /* Echo-owned fields propagated: */
611
+ expect((merged as { inherited?: boolean }).inherited).toBe(true);
612
+ });
613
+
614
+ it('uses fresh kind defaults for genuinely new files (no prior entry to merge from)', () => {
615
+ /**
616
+ * The merge keys on `(storage_session_id, id)`, not `name`.
617
+ * A file the worker emits for the first time (typical
618
+ * code-output / generated artifact) has no prior to inherit
619
+ * identity from — it lands as `kind: 'user'` via the standard
620
+ * fallback in `toInjectedFileRef`. Locks the contract that
621
+ * the merge doesn't accidentally re-tag user output as skill.
622
+ */
623
+ const sessions: t.ToolSessionMap = new Map();
624
+ sessions.set(Constants.EXECUTE_CODE, {
625
+ session_id: 'old-sess',
626
+ files: [
627
+ {
628
+ id: 'skill-f1',
629
+ resource_id: 'skill-id-1',
630
+ name: 'pptx/editing.md',
631
+ storage_session_id: 'skill-storage',
632
+ kind: 'skill',
633
+ version: 7,
634
+ },
635
+ ],
636
+ lastUpdated: Date.now(),
637
+ } satisfies t.CodeSessionContext);
638
+
639
+ const mockTool = createMockCodeTool({ capturedConfigs: [] });
640
+ const toolNode = new ToolNode({
641
+ tools: [mockTool],
642
+ sessions,
643
+ eventDrivenMode: true,
644
+ });
645
+
646
+ const storeMethod = (
647
+ toolNode as unknown as {
648
+ storeCodeSessionFromResults: (
649
+ results: t.ToolExecuteResult[],
650
+ requestMap: Map<string, t.ToolCallRequest>
651
+ ) => void;
652
+ }
653
+ ).storeCodeSessionFromResults.bind(toolNode);
654
+
655
+ storeMethod(
656
+ [
657
+ {
658
+ toolCallId: 'tc-output',
659
+ content: 'output',
660
+ artifact: {
661
+ session_id: 'output-sess',
662
+ files: [
663
+ {
664
+ id: 'fresh-output-id',
665
+ name: 'generated-chart.png',
666
+ storage_session_id: 'output-sess',
667
+ } as unknown as t.FileRefs[number],
668
+ ],
669
+ },
670
+ status: 'success',
671
+ },
672
+ ],
673
+ new Map([
674
+ [
675
+ 'tc-output',
676
+ { id: 'tc-output', name: Constants.EXECUTE_CODE, args: {} },
677
+ ],
678
+ ])
679
+ );
680
+
681
+ const stored = sessions.get(
682
+ Constants.EXECUTE_CODE
683
+ ) as t.CodeSessionContext;
684
+ const fresh = stored.files!.find((f) => f.id === 'fresh-output-id');
685
+ expect(fresh).toBeDefined();
686
+ /* No prior to inherit from — kind/resource_id/version absent
687
+ * on the entry; toInjectedFileRef will default kind to 'user'
688
+ * downstream. */
689
+ expect(fresh!.kind).toBeUndefined();
690
+ expect(fresh!.resource_id).toBeUndefined();
691
+ expect((fresh as { version?: number }).version).toBeUndefined();
692
+ /* Skill file untouched. */
693
+ const skillFile = stored.files!.find((f) => f.id === 'skill-f1');
694
+ expect(skillFile!.kind).toBe('skill');
695
+ });
696
+
497
697
  it('preserves existing files when new execution has no files', () => {
498
698
  const sessions: t.ToolSessionMap = new Map();
499
699
  sessions.set(Constants.EXECUTE_CODE, {
@@ -835,6 +1035,9 @@ describe('ToolNode code execution session management', () => {
835
1035
  files: [
836
1036
  {
837
1037
  id: 'rf1',
1038
+ /* Default fallback for inputs that don't carry separate
1039
+ * provenance — see toInjectedFileRef. */
1040
+ resource_id: 'rf1',
838
1041
  name: 'data.csv',
839
1042
  storage_session_id: 'rf-session',
840
1043
  kind: 'user',
@@ -158,14 +158,33 @@ export type CodeEnvKind = (typeof CODE_ENV_KINDS)[number];
158
158
 
159
159
  type CodeEnvFileBase = {
160
160
  /**
161
- * Resource identity. Semantics depend on `kind`:
162
- * - `skill`: skill `_id` (sessionKey-meaningful, cross-user shared).
163
- * - `agent`: agent id (sessionKey-meaningful, cross-user shared).
164
- * - `user`: informational only codeapi derives sessionKey from
165
- * the auth-context user. Kept on the type for shape uniformity;
166
- * do not rely on it for routing.
161
+ * **Storage file id** the per-file uuid the file_server returns
162
+ * at upload time. Identifies the bytes at
163
+ * `<storage_session_id>/<id>` in the object bucket; used by the
164
+ * worker to fetch the file and by codeapi's auth layer for the
165
+ * upload-existence check.
166
+ *
167
+ * Distinct from `resource_id` below — that's the entity that owns
168
+ * this file's storage session (skill `_id`, agent id, etc.), used
169
+ * for sessionKey resolution. Conflating the two caused
170
+ * shared-kind authorization to fail because the sessionKey
171
+ * re-derivation used the storage nanoid where the resource id was
172
+ * required. See codeapi #1455 review.
167
173
  */
168
174
  id: string;
175
+ /**
176
+ * **Resource id** — the entity that owns this file's storage
177
+ * session. Skill `_id` for `kind: 'skill'`, agent id for `'agent'`,
178
+ * informational only for `'user'` (codeapi resolves user identity
179
+ * from auth context). Kept on the type for shape uniformity across
180
+ * kinds.
181
+ *
182
+ * Drives codeapi's `resolveSessionKey` switch — the sessionKey
183
+ * embeds this value (`<tenant>:<kind>:<resource_id>[:v:<version>]`)
184
+ * so cross-user-within-tenant sharing for shared kinds is a
185
+ * designed property of the kind switch.
186
+ */
187
+ resource_id: string;
169
188
  name: string;
170
189
  /**
171
190
  * Storage session — the long-lived bucket where this file's bytes
@@ -204,12 +223,17 @@ export type CodeExecutionToolParams =
204
223
 
205
224
  export type FileRef = {
206
225
  /**
207
- * Resource identity. Semantics depend on `kind` (when present):
208
- * - `skill` / `agent`: shared resource id (sessionKey-meaningful).
209
- * - `user`: informational only — codeapi derives sessionKey from
210
- * the auth-context user. Do not rely on it for routing.
226
+ * Storage file id (the per-file uuid). See `CodeEnvFile.id` for
227
+ * the full motivation behind the storage-vs-resource split.
211
228
  */
212
229
  id: string;
230
+ /**
231
+ * Resource id — the entity that owns this file's storage session.
232
+ * Optional on `FileRef` (post-execute artifact refs may not carry
233
+ * resource provenance for outputs); required on `CodeEnvFile`
234
+ * (the input wire shape).
235
+ */
236
+ resource_id?: string;
213
237
  name: string;
214
238
  path?: string;
215
239
  /**