@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/dist/cjs/tools/ToolNode.cjs +37 -14
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +37 -14
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/package.json +1 -1
- package/src/tools/ToolNode.ts +47 -15
- package/src/tools/__tests__/ToolNode.session.test.ts +182 -0
package/package.json
CHANGED
package/src/tools/ToolNode.ts
CHANGED
|
@@ -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
|
|
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, {
|