@librechat/agents 3.1.80-dev.1 → 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.
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.2",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -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
@@ -512,6 +512,188 @@ describe('ToolNode code execution session management', () => {
512
512
  expect(chartFile!.storage_session_id).toBe('new-sess');
513
513
  });
514
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
+
515
697
  it('preserves existing files when new execution has no files', () => {
516
698
  const sessions: t.ToolSessionMap = new Map();
517
699
  sessions.set(Constants.EXECUTE_CODE, {