@librechat/agents 3.4.0 → 3.4.1
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/graphs/Graph.cjs +111 -27
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +83 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +60 -14
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +2 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +2 -2
- package/dist/cjs/main.cjs +14 -13
- package/dist/cjs/messages/format.cjs +40 -23
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/run.cjs +82 -15
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +37 -2
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/stream.cjs +1 -1
- package/dist/cjs/summarization/node.cjs +6 -3
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
- package/dist/cjs/tools/ToolNode.cjs +316 -94
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +630 -78
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
- package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/esm/graphs/Graph.mjs +111 -27
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +83 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/executeHooks.mjs +60 -14
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +2 -1
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +1 -1
- package/dist/esm/llm/invoke.mjs +2 -2
- package/dist/esm/main.mjs +11 -11
- package/dist/esm/messages/format.mjs +40 -23
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/run.mjs +83 -16
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +37 -2
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/stream.mjs +1 -1
- package/dist/esm/summarization/node.mjs +6 -3
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
- package/dist/esm/tools/ToolNode.mjs +317 -95
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +631 -79
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
- package/dist/esm/tools/toolOutputReferences.mjs +20 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/types/graphs/Graph.d.ts +16 -1
- package/dist/types/hooks/HookRegistry.d.ts +10 -1
- package/dist/types/hooks/executeHooks.d.ts +5 -1
- package/dist/types/hooks/index.d.ts +2 -2
- package/dist/types/hooks/types.d.ts +12 -0
- package/dist/types/run.d.ts +4 -1
- package/dist/types/session/AgentSession.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +23 -15
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +46 -7
- package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
- package/dist/types/tools/toolOutputReferences.d.ts +12 -0
- package/dist/types/types/graph.d.ts +5 -5
- package/dist/types/types/hitl.d.ts +15 -0
- package/package.json +6 -2
- package/src/graphs/Graph.ts +246 -52
- package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
- package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
- package/src/hooks/HookRegistry.ts +151 -1
- package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
- package/src/hooks/__tests__/executeHooks.test.ts +160 -0
- package/src/hooks/executeHooks.ts +133 -15
- package/src/hooks/index.ts +3 -1
- package/src/hooks/types.ts +16 -0
- package/src/messages/format.ts +54 -26
- package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
- package/src/messages/formatAgentMessages.steer.test.ts +16 -11
- package/src/messages/formatAgentMessages.test.ts +7 -2
- package/src/run.ts +164 -18
- package/src/session/AgentSession.ts +52 -2
- package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
- package/src/summarization/__tests__/node.test.ts +32 -14
- package/src/summarization/node.ts +15 -11
- package/src/tools/ToolNode.ts +631 -157
- package/src/tools/__tests__/SubagentExecutor.test.ts +542 -6
- package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
- package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
- package/src/tools/__tests__/hitl.test.ts +234 -5
- package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
- package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
- package/src/tools/subagent/SubagentExecutor.ts +1369 -131
- package/src/tools/subagent/SubagentReplay.ts +575 -0
- package/src/tools/toolOutputReferences.ts +40 -1
- package/src/types/graph.ts +5 -5
- package/src/types/hitl.ts +16 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
2
|
+
import { GraphInterrupt, MemorySaver } from '@langchain/langgraph';
|
|
2
3
|
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
3
4
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
5
|
import type {
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
summarizeEvent,
|
|
20
21
|
} from '../subagent';
|
|
21
22
|
import { sanitizeForwardedSubagentUpdateData } from '../subagent/SubagentExecutor';
|
|
23
|
+
import { SUBAGENT_PARENT_BATCH_CONFIG_KEY } from '../subagent/SubagentReplay';
|
|
22
24
|
import { Constants, Providers, GraphEvents, StepTypes } from '@/common';
|
|
23
25
|
import { StreamLimitExceededError } from '@/llm/streamLimits';
|
|
24
26
|
import { AgentContext } from '@/agents/AgentContext';
|
|
@@ -349,8 +351,7 @@ describe('buildChildInputs', () => {
|
|
|
349
351
|
/**
|
|
350
352
|
* Self-spawn: `resolveSubagentConfigs` fills `agentInputs` as a shallow
|
|
351
353
|
* spread of the parent's `_sourceInputs`, so the parent-scoped direct
|
|
352
|
-
* tool would leak into
|
|
353
|
-
* `No checkpointer set` — must be scrubbed.
|
|
354
|
+
* tool would leak into the child implicitly — it must be scrubbed.
|
|
354
355
|
*/
|
|
355
356
|
const selfConfig: ResolvedSubagentConfig = {
|
|
356
357
|
type: 'self',
|
|
@@ -365,9 +366,8 @@ describe('buildChildInputs', () => {
|
|
|
365
366
|
|
|
366
367
|
/**
|
|
367
368
|
* Explicit child config: a host that deliberately attaches its own
|
|
368
|
-
* in-process direct tools to a child keeps them (Codex #289 P2
|
|
369
|
-
* interrupt-capable tools
|
|
370
|
-
* direct tools are legitimate there).
|
|
369
|
+
* in-process direct tools to a child keeps them (Codex #289 P2). With
|
|
370
|
+
* HITL enabled, interrupt-capable child tools use the shared checkpointer.
|
|
371
371
|
*/
|
|
372
372
|
const explicitConfig: ResolvedSubagentConfig = {
|
|
373
373
|
type: 'researcher',
|
|
@@ -496,6 +496,232 @@ describe('SubagentExecutor', () => {
|
|
|
496
496
|
expect(result.messages).toEqual([]);
|
|
497
497
|
});
|
|
498
498
|
|
|
499
|
+
it('fails closed when resumable execution has no parent tool call ID', async () => {
|
|
500
|
+
const createChildGraph = jest.fn(makeNoopGraphFactory());
|
|
501
|
+
const executor = createExecutor({
|
|
502
|
+
humanInTheLoop: { enabled: true },
|
|
503
|
+
createChildGraph,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const result = await executor.execute({
|
|
507
|
+
description: 'Do something',
|
|
508
|
+
subagentType: 'researcher',
|
|
509
|
+
threadId: 'durable-thread',
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(result.content).toContain('requires a parent tool call ID');
|
|
513
|
+
expect(result.messages).toEqual([]);
|
|
514
|
+
expect(createChildGraph).not.toHaveBeenCalled();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('persists terminal replay outcomes without an active child workflow', async () => {
|
|
518
|
+
const checkpointer = new MemorySaver();
|
|
519
|
+
const hookRegistry = new HookRegistry();
|
|
520
|
+
hookRegistry.registerSession('original-run', 'PreToolUse', {
|
|
521
|
+
hooks: [async (): Promise<Record<string, never>> => ({})],
|
|
522
|
+
});
|
|
523
|
+
const initialExecutor = createExecutor({
|
|
524
|
+
checkpointer,
|
|
525
|
+
hookRegistry,
|
|
526
|
+
humanInTheLoop: { enabled: true },
|
|
527
|
+
parentRunId: 'original-run',
|
|
528
|
+
});
|
|
529
|
+
const call = {
|
|
530
|
+
id: 'call_terminal',
|
|
531
|
+
name: Constants.SUBAGENT,
|
|
532
|
+
args: {
|
|
533
|
+
description: 'blocked before execution',
|
|
534
|
+
subagent_type: 'researcher',
|
|
535
|
+
},
|
|
536
|
+
type: 'tool_call' as const,
|
|
537
|
+
};
|
|
538
|
+
const originalConfig = {
|
|
539
|
+
configurable: {
|
|
540
|
+
thread_id: 'durable-parent',
|
|
541
|
+
checkpoint_id: 'parent-fork',
|
|
542
|
+
run_id: 'original-run',
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
const settled = {
|
|
546
|
+
output: new ToolMessage({
|
|
547
|
+
content: 'Blocked: policy',
|
|
548
|
+
status: 'error' as const,
|
|
549
|
+
name: Constants.SUBAGENT,
|
|
550
|
+
tool_call_id: call.id,
|
|
551
|
+
}),
|
|
552
|
+
additionalContexts: ['persisted context'],
|
|
553
|
+
resolvedArgs: { description: 'rewritten' },
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
await initialExecutor.persistSettledToolOutput(
|
|
557
|
+
call,
|
|
558
|
+
originalConfig,
|
|
559
|
+
settled
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const rebuiltExecutor = createExecutor({
|
|
563
|
+
checkpointer,
|
|
564
|
+
hookRegistry,
|
|
565
|
+
humanInTheLoop: { enabled: true },
|
|
566
|
+
parentRunId: 'rebuilt-run',
|
|
567
|
+
});
|
|
568
|
+
const restored = await rebuiltExecutor.getSettledToolOutput(call, {
|
|
569
|
+
configurable: {
|
|
570
|
+
...originalConfig.configurable,
|
|
571
|
+
run_id: 'rebuilt-run',
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(restored?.output.content).toBe('Blocked: policy');
|
|
576
|
+
expect(restored?.additionalContexts).toEqual(['persisted context']);
|
|
577
|
+
expect(restored?.resolvedArgs).toEqual({ description: 'rewritten' });
|
|
578
|
+
expect(hookRegistry.hasHookFor('PreToolUse', 'original-run')).toBe(true);
|
|
579
|
+
expect(hookRegistry.hasHookFor('PreToolUse', 'rebuilt-run')).toBe(true);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('persists an ordinary child error after the active workflow is released', async () => {
|
|
583
|
+
const checkpointer = new MemorySaver();
|
|
584
|
+
const invoke = jest.fn().mockRejectedValue(new Error('child failed'));
|
|
585
|
+
const executor = createExecutor({
|
|
586
|
+
checkpointer,
|
|
587
|
+
humanInTheLoop: { enabled: true },
|
|
588
|
+
createChildGraph: (): StandardGraph =>
|
|
589
|
+
({
|
|
590
|
+
createWorkflow: () => ({
|
|
591
|
+
getState: jest.fn().mockResolvedValue({
|
|
592
|
+
values: {},
|
|
593
|
+
next: ['child-agent'],
|
|
594
|
+
tasks: [],
|
|
595
|
+
}),
|
|
596
|
+
invoke,
|
|
597
|
+
}),
|
|
598
|
+
clearHeavyState: jest.fn(),
|
|
599
|
+
}) as unknown as StandardGraph,
|
|
600
|
+
});
|
|
601
|
+
const call = {
|
|
602
|
+
id: 'call_error',
|
|
603
|
+
name: Constants.SUBAGENT,
|
|
604
|
+
args: { description: 'fail', subagent_type: 'researcher' },
|
|
605
|
+
type: 'tool_call' as const,
|
|
606
|
+
};
|
|
607
|
+
const runnableConfig = {
|
|
608
|
+
configurable: {
|
|
609
|
+
thread_id: 'durable-parent',
|
|
610
|
+
checkpoint_id: 'parent-fork',
|
|
611
|
+
run_id: 'original-run',
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
const result = await executor.execute({
|
|
615
|
+
description: 'fail',
|
|
616
|
+
subagentType: 'researcher',
|
|
617
|
+
threadId: 'durable-parent',
|
|
618
|
+
parentToolCallId: call.id,
|
|
619
|
+
parentConfigurable: runnableConfig.configurable,
|
|
620
|
+
});
|
|
621
|
+
expect(result.content).toBe('Subagent error: child failed');
|
|
622
|
+
|
|
623
|
+
await executor.persistSettledToolOutput(call, runnableConfig, {
|
|
624
|
+
output: new ToolMessage({
|
|
625
|
+
content: result.content,
|
|
626
|
+
name: Constants.SUBAGENT,
|
|
627
|
+
tool_call_id: call.id,
|
|
628
|
+
}),
|
|
629
|
+
additionalContexts: [],
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const restored = await createExecutor({
|
|
633
|
+
checkpointer,
|
|
634
|
+
humanInTheLoop: { enabled: true },
|
|
635
|
+
}).getSettledToolOutput(call, runnableConfig);
|
|
636
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
637
|
+
expect(restored?.output.content).toBe('Subagent error: child failed');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('continues a recovered in-progress child without reseeding its task', async () => {
|
|
641
|
+
const invoke = jest.fn().mockResolvedValue({
|
|
642
|
+
messages: [new AIMessage('continued')],
|
|
643
|
+
});
|
|
644
|
+
const executor = createExecutor({
|
|
645
|
+
checkpointer: new MemorySaver(),
|
|
646
|
+
humanInTheLoop: { enabled: true },
|
|
647
|
+
createChildGraph: (): StandardGraph =>
|
|
648
|
+
({
|
|
649
|
+
createWorkflow: () => ({
|
|
650
|
+
getState: jest.fn().mockResolvedValue({
|
|
651
|
+
values: {},
|
|
652
|
+
next: ['child-agent'],
|
|
653
|
+
tasks: [],
|
|
654
|
+
}),
|
|
655
|
+
invoke,
|
|
656
|
+
}),
|
|
657
|
+
clearHeavyState: jest.fn(),
|
|
658
|
+
}) as unknown as StandardGraph,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const result = await executor.execute({
|
|
662
|
+
description: 'Do not duplicate this task',
|
|
663
|
+
subagentType: 'researcher',
|
|
664
|
+
threadId: 'durable-parent',
|
|
665
|
+
parentToolCallId: 'call_in_progress',
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
expect(result.content).toBe('continued');
|
|
669
|
+
expect(invoke).toHaveBeenCalledWith(null, expect.any(Object));
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('preserves an existing nested approval scope', async () => {
|
|
673
|
+
const nestedScope = {
|
|
674
|
+
run_id: 'grandchild-run',
|
|
675
|
+
agent_id: 'grandchild-agent',
|
|
676
|
+
subagent_type: 'grandchild',
|
|
677
|
+
parent_tool_call_id: 'call_grandchild',
|
|
678
|
+
};
|
|
679
|
+
const nestedInterrupt = new GraphInterrupt([
|
|
680
|
+
{
|
|
681
|
+
id: 'nested-interrupt',
|
|
682
|
+
value: {
|
|
683
|
+
type: 'tool_approval',
|
|
684
|
+
action_requests: [],
|
|
685
|
+
review_configs: [],
|
|
686
|
+
subagent: nestedScope,
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
]);
|
|
690
|
+
const executor = createExecutor({
|
|
691
|
+
humanInTheLoop: { enabled: true },
|
|
692
|
+
createChildGraph: (): StandardGraph =>
|
|
693
|
+
({
|
|
694
|
+
createWorkflow: () => ({
|
|
695
|
+
getState: jest.fn().mockResolvedValue({
|
|
696
|
+
values: {},
|
|
697
|
+
next: ['agent'],
|
|
698
|
+
tasks: [],
|
|
699
|
+
}),
|
|
700
|
+
invoke: jest.fn().mockRejectedValue(nestedInterrupt),
|
|
701
|
+
}),
|
|
702
|
+
clearHeavyState: jest.fn(),
|
|
703
|
+
}) as unknown as StandardGraph,
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
let received: GraphInterrupt | undefined;
|
|
707
|
+
try {
|
|
708
|
+
await executor.execute({
|
|
709
|
+
description: 'Delegate again',
|
|
710
|
+
subagentType: 'researcher',
|
|
711
|
+
threadId: 'durable-thread',
|
|
712
|
+
parentToolCallId: 'call_child',
|
|
713
|
+
});
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (error instanceof GraphInterrupt) {
|
|
716
|
+
received = error;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
expect(received?.interrupts[0].value).toMatchObject({
|
|
721
|
+
subagent: nestedScope,
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
499
725
|
it('rethrows a child stream-limit trip instead of converting it to a tool result', async () => {
|
|
500
726
|
const executor = createExecutor({
|
|
501
727
|
createChildGraph: (): StandardGraph =>
|
|
@@ -1393,6 +1619,7 @@ describe('SubagentExecutor', () => {
|
|
|
1393
1619
|
checkpoint_id: 'parent-checkpoint-id',
|
|
1394
1620
|
checkpoint_map: { parent: 'checkpoint' },
|
|
1395
1621
|
checkpoint_ns: 'parent-checkpoint-ns',
|
|
1622
|
+
[SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch',
|
|
1396
1623
|
requestBody: { messageId: 'msg-1' },
|
|
1397
1624
|
thread_id: 'parent-thread',
|
|
1398
1625
|
user: { id: 'user_abc' },
|
|
@@ -1409,11 +1636,260 @@ describe('SubagentExecutor', () => {
|
|
|
1409
1636
|
expect(configurable.checkpoint_id).toBeUndefined();
|
|
1410
1637
|
expect(configurable.checkpoint_map).toBeUndefined();
|
|
1411
1638
|
expect(configurable.checkpoint_ns).toBeUndefined();
|
|
1639
|
+
expect(configurable[SUBAGENT_PARENT_BATCH_CONFIG_KEY]).toBeUndefined();
|
|
1412
1640
|
expect(configurable.requestBody).toEqual({ messageId: 'msg-1' });
|
|
1413
1641
|
expect(configurable.thread_id).toBe('parent-thread');
|
|
1414
1642
|
expect(configurable.user).toEqual({ id: 'user_abc' });
|
|
1415
1643
|
});
|
|
1416
1644
|
|
|
1645
|
+
it('isolates child checkpoints created from different parent forks', async () => {
|
|
1646
|
+
const invokedConfigs: Record<string, unknown>[] = [];
|
|
1647
|
+
const invoke = jest
|
|
1648
|
+
.fn()
|
|
1649
|
+
.mockImplementation(
|
|
1650
|
+
async (
|
|
1651
|
+
_input: unknown,
|
|
1652
|
+
config: Record<string, unknown>
|
|
1653
|
+
): Promise<{ messages: BaseMessage[] }> => {
|
|
1654
|
+
invokedConfigs.push(config);
|
|
1655
|
+
return { messages: [new AIMessage('done')] };
|
|
1656
|
+
}
|
|
1657
|
+
);
|
|
1658
|
+
const executor = createExecutor({
|
|
1659
|
+
humanInTheLoop: { enabled: true },
|
|
1660
|
+
createChildGraph: (): StandardGraph =>
|
|
1661
|
+
({
|
|
1662
|
+
createWorkflow: () => ({
|
|
1663
|
+
getState: jest.fn().mockResolvedValue({
|
|
1664
|
+
values: {},
|
|
1665
|
+
next: [],
|
|
1666
|
+
tasks: [],
|
|
1667
|
+
}),
|
|
1668
|
+
invoke,
|
|
1669
|
+
}),
|
|
1670
|
+
clearHeavyState: jest.fn(),
|
|
1671
|
+
}) as unknown as StandardGraph,
|
|
1672
|
+
});
|
|
1673
|
+
const common = {
|
|
1674
|
+
description: 'task',
|
|
1675
|
+
subagentType: 'researcher',
|
|
1676
|
+
threadId: 'parent-thread',
|
|
1677
|
+
parentToolCallId: 'call_shared',
|
|
1678
|
+
};
|
|
1679
|
+
|
|
1680
|
+
await executor.execute({
|
|
1681
|
+
...common,
|
|
1682
|
+
parentConfigurable: {
|
|
1683
|
+
thread_id: 'parent-thread',
|
|
1684
|
+
checkpoint_id: 'fork-a',
|
|
1685
|
+
},
|
|
1686
|
+
});
|
|
1687
|
+
await executor.execute({
|
|
1688
|
+
...common,
|
|
1689
|
+
parentConfigurable: {
|
|
1690
|
+
thread_id: 'parent-thread',
|
|
1691
|
+
checkpoint_id: 'fork-b',
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
expect(invoke).toHaveBeenCalledTimes(2);
|
|
1696
|
+
const childThreadIds = invokedConfigs.map(
|
|
1697
|
+
(config) =>
|
|
1698
|
+
(config.configurable as Record<string, unknown>).thread_id as string
|
|
1699
|
+
);
|
|
1700
|
+
expect(childThreadIds[0]).toMatch(/^subagent:/);
|
|
1701
|
+
expect(childThreadIds[1]).toMatch(/^subagent:/);
|
|
1702
|
+
expect(childThreadIds[0]).not.toBe(childThreadIds[1]);
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
it('isolates reused tool-call IDs across assistant batches', async () => {
|
|
1706
|
+
const invokedThreadIds: string[] = [];
|
|
1707
|
+
const executor = createExecutor({
|
|
1708
|
+
humanInTheLoop: { enabled: true },
|
|
1709
|
+
createChildGraph: (): StandardGraph =>
|
|
1710
|
+
({
|
|
1711
|
+
createWorkflow: () => ({
|
|
1712
|
+
getState: jest.fn().mockResolvedValue({
|
|
1713
|
+
values: {},
|
|
1714
|
+
next: [],
|
|
1715
|
+
tasks: [],
|
|
1716
|
+
}),
|
|
1717
|
+
invoke: jest
|
|
1718
|
+
.fn()
|
|
1719
|
+
.mockImplementation(
|
|
1720
|
+
async (
|
|
1721
|
+
_input: unknown,
|
|
1722
|
+
invokeConfig: Record<string, unknown>
|
|
1723
|
+
): Promise<{ messages: BaseMessage[] }> => {
|
|
1724
|
+
invokedThreadIds.push(
|
|
1725
|
+
(invokeConfig.configurable as Record<string, unknown>)
|
|
1726
|
+
.thread_id as string
|
|
1727
|
+
);
|
|
1728
|
+
return { messages: [new AIMessage('done')] };
|
|
1729
|
+
}
|
|
1730
|
+
),
|
|
1731
|
+
}),
|
|
1732
|
+
clearHeavyState: jest.fn(),
|
|
1733
|
+
}) as unknown as StandardGraph,
|
|
1734
|
+
});
|
|
1735
|
+
const common = {
|
|
1736
|
+
description: 'task',
|
|
1737
|
+
subagentType: 'researcher',
|
|
1738
|
+
threadId: 'parent-thread',
|
|
1739
|
+
parentToolCallId: 'call_reused',
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
await executor.execute({
|
|
1743
|
+
...common,
|
|
1744
|
+
parentConfigurable: {
|
|
1745
|
+
thread_id: 'parent-thread',
|
|
1746
|
+
[SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-1',
|
|
1747
|
+
},
|
|
1748
|
+
});
|
|
1749
|
+
await executor.execute({
|
|
1750
|
+
...common,
|
|
1751
|
+
parentConfigurable: {
|
|
1752
|
+
thread_id: 'parent-thread',
|
|
1753
|
+
[SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-2',
|
|
1754
|
+
},
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
expect(invokedThreadIds).toHaveLength(2);
|
|
1758
|
+
expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
it('keeps ambiguous parent agent and tool-call components collision-free', async () => {
|
|
1762
|
+
const invokedThreadIds: string[] = [];
|
|
1763
|
+
const createChildGraph = (): StandardGraph =>
|
|
1764
|
+
({
|
|
1765
|
+
createWorkflow: () => ({
|
|
1766
|
+
getState: jest.fn().mockResolvedValue({
|
|
1767
|
+
values: {},
|
|
1768
|
+
next: [],
|
|
1769
|
+
tasks: [],
|
|
1770
|
+
}),
|
|
1771
|
+
invoke: jest
|
|
1772
|
+
.fn()
|
|
1773
|
+
.mockImplementation(
|
|
1774
|
+
async (
|
|
1775
|
+
_input: unknown,
|
|
1776
|
+
invokeConfig: Record<string, unknown>
|
|
1777
|
+
): Promise<{ messages: BaseMessage[] }> => {
|
|
1778
|
+
invokedThreadIds.push(
|
|
1779
|
+
(invokeConfig.configurable as Record<string, unknown>)
|
|
1780
|
+
.thread_id as string
|
|
1781
|
+
);
|
|
1782
|
+
return { messages: [new AIMessage('done')] };
|
|
1783
|
+
}
|
|
1784
|
+
),
|
|
1785
|
+
}),
|
|
1786
|
+
clearHeavyState: jest.fn(),
|
|
1787
|
+
}) as unknown as StandardGraph;
|
|
1788
|
+
const common = {
|
|
1789
|
+
description: 'task',
|
|
1790
|
+
subagentType: 'researcher',
|
|
1791
|
+
threadId: 'parent',
|
|
1792
|
+
parentConfigurable: { thread_id: 'parent' },
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
await createExecutor({
|
|
1796
|
+
checkpointer: new MemorySaver(),
|
|
1797
|
+
humanInTheLoop: { enabled: true },
|
|
1798
|
+
parentAgentId: 'a_b',
|
|
1799
|
+
createChildGraph,
|
|
1800
|
+
}).execute({ ...common, parentToolCallId: 'c' });
|
|
1801
|
+
await createExecutor({
|
|
1802
|
+
checkpointer: new MemorySaver(),
|
|
1803
|
+
humanInTheLoop: { enabled: true },
|
|
1804
|
+
parentAgentId: 'a',
|
|
1805
|
+
createChildGraph,
|
|
1806
|
+
}).execute({ ...common, parentToolCallId: 'b_c' });
|
|
1807
|
+
|
|
1808
|
+
expect(invokedThreadIds).toHaveLength(2);
|
|
1809
|
+
expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it('retains owned child checkpoint threads after heavy-state cleanup', async () => {
|
|
1813
|
+
const executor = createExecutor({
|
|
1814
|
+
checkpointer: new MemorySaver(),
|
|
1815
|
+
humanInTheLoop: { enabled: true },
|
|
1816
|
+
createChildGraph: (): StandardGraph =>
|
|
1817
|
+
({
|
|
1818
|
+
createWorkflow: () => ({
|
|
1819
|
+
getState: jest.fn().mockResolvedValue({
|
|
1820
|
+
values: {},
|
|
1821
|
+
next: [],
|
|
1822
|
+
tasks: [],
|
|
1823
|
+
}),
|
|
1824
|
+
invoke: jest.fn().mockResolvedValue({
|
|
1825
|
+
messages: [new AIMessage('done')],
|
|
1826
|
+
}),
|
|
1827
|
+
}),
|
|
1828
|
+
clearHeavyState: jest.fn(),
|
|
1829
|
+
}) as unknown as StandardGraph,
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
await executor.execute({
|
|
1833
|
+
description: 'task',
|
|
1834
|
+
subagentType: 'researcher',
|
|
1835
|
+
threadId: 'parent',
|
|
1836
|
+
parentToolCallId: 'call_owned',
|
|
1837
|
+
});
|
|
1838
|
+
const beforeCleanup = executor.getChildCheckpointThreadIds();
|
|
1839
|
+
executor.clearHeavyState();
|
|
1840
|
+
|
|
1841
|
+
expect(beforeCleanup).toHaveLength(1);
|
|
1842
|
+
expect(executor.getChildCheckpointThreadIds()).toEqual(beforeCleanup);
|
|
1843
|
+
|
|
1844
|
+
executor.resetCheckpointThreadIds();
|
|
1845
|
+
expect(executor.getChildCheckpointThreadIds()).toEqual([]);
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
it('includes checkpoint threads from active descendant graphs', async () => {
|
|
1849
|
+
const grandchildThreadId = 'subagent:grandchild';
|
|
1850
|
+
const executor = createExecutor({
|
|
1851
|
+
checkpointer: new MemorySaver(),
|
|
1852
|
+
humanInTheLoop: { enabled: true },
|
|
1853
|
+
createChildGraph: (): StandardGraph =>
|
|
1854
|
+
({
|
|
1855
|
+
createWorkflow: () => ({
|
|
1856
|
+
getState: jest.fn().mockResolvedValue({
|
|
1857
|
+
values: {},
|
|
1858
|
+
next: ['child-agent'],
|
|
1859
|
+
tasks: [],
|
|
1860
|
+
}),
|
|
1861
|
+
invoke: jest.fn().mockRejectedValue(
|
|
1862
|
+
new GraphInterrupt([
|
|
1863
|
+
{
|
|
1864
|
+
id: 'grandchild-interrupt',
|
|
1865
|
+
value: {
|
|
1866
|
+
type: 'tool_approval',
|
|
1867
|
+
action_requests: [],
|
|
1868
|
+
review_configs: [],
|
|
1869
|
+
},
|
|
1870
|
+
},
|
|
1871
|
+
])
|
|
1872
|
+
),
|
|
1873
|
+
}),
|
|
1874
|
+
getChildCheckpointThreadIds: jest.fn(() => [grandchildThreadId]),
|
|
1875
|
+
clearHeavyState: jest.fn(),
|
|
1876
|
+
}) as unknown as StandardGraph,
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
await expect(
|
|
1880
|
+
executor.execute({
|
|
1881
|
+
description: 'nested task',
|
|
1882
|
+
subagentType: 'researcher',
|
|
1883
|
+
threadId: 'parent',
|
|
1884
|
+
parentToolCallId: 'call_child',
|
|
1885
|
+
})
|
|
1886
|
+
).rejects.toBeInstanceOf(GraphInterrupt);
|
|
1887
|
+
|
|
1888
|
+
expect(executor.getChildCheckpointThreadIds()).toEqual(
|
|
1889
|
+
expect.arrayContaining([grandchildThreadId])
|
|
1890
|
+
);
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1417
1893
|
it('does not require parentConfigurable (back-compat with hosts that omit it)', async () => {
|
|
1418
1894
|
const { factory, getInvokeConfig } = makeCapturingGraphFactory();
|
|
1419
1895
|
const executor = createExecutor({ createChildGraph: factory });
|
|
@@ -1427,7 +1903,6 @@ describe('SubagentExecutor', () => {
|
|
|
1427
1903
|
string,
|
|
1428
1904
|
unknown
|
|
1429
1905
|
>;
|
|
1430
|
-
// Only thread_id (childRunId fallback) is set when no parent context is supplied.
|
|
1431
1906
|
expect(Object.keys(configurable)).toEqual(['thread_id']);
|
|
1432
1907
|
});
|
|
1433
1908
|
});
|
|
@@ -1502,6 +1977,67 @@ describe('SubagentExecutor', () => {
|
|
|
1502
1977
|
expect(input.agentType).toBe('researcher');
|
|
1503
1978
|
});
|
|
1504
1979
|
|
|
1980
|
+
it('finishes missing child lifecycle after recovering a completed checkpoint', async () => {
|
|
1981
|
+
const stopHook = jest.fn().mockResolvedValue({});
|
|
1982
|
+
const hookRegistry = new HookRegistry();
|
|
1983
|
+
hookRegistry.register('SubagentStop', { hooks: [stopHook] });
|
|
1984
|
+
const updates: SubagentUpdateEvent[] = [];
|
|
1985
|
+
const handlerRegistry = new HandlerRegistry();
|
|
1986
|
+
handlerRegistry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
1987
|
+
handle: (_event, data): void => {
|
|
1988
|
+
updates.push(data as SubagentUpdateEvent);
|
|
1989
|
+
},
|
|
1990
|
+
});
|
|
1991
|
+
const invoke = jest.fn();
|
|
1992
|
+
const updateState = jest.fn().mockResolvedValue({});
|
|
1993
|
+
const executor = createExecutor({
|
|
1994
|
+
humanInTheLoop: { enabled: true },
|
|
1995
|
+
hookRegistry,
|
|
1996
|
+
parentHandlerRegistry: handlerRegistry,
|
|
1997
|
+
createChildGraph: (): StandardGraph =>
|
|
1998
|
+
({
|
|
1999
|
+
createWorkflow: () => ({
|
|
2000
|
+
getState: jest.fn().mockResolvedValue({
|
|
2001
|
+
values: { messages: [new AIMessage('persisted result')] },
|
|
2002
|
+
next: [],
|
|
2003
|
+
tasks: [],
|
|
2004
|
+
}),
|
|
2005
|
+
invoke,
|
|
2006
|
+
updateState,
|
|
2007
|
+
}),
|
|
2008
|
+
clearHeavyState: jest.fn(),
|
|
2009
|
+
}) as unknown as StandardGraph,
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
const result = await executor.execute({
|
|
2013
|
+
description: 'recover me',
|
|
2014
|
+
subagentType: 'researcher',
|
|
2015
|
+
threadId: 'durable-thread',
|
|
2016
|
+
parentToolCallId: 'call_recovered',
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
expect(result.content).toBe('persisted result');
|
|
2020
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
2021
|
+
expect(stopHook).toHaveBeenCalledTimes(1);
|
|
2022
|
+
expect(updates.filter((update) => update.phase === 'stop')).toHaveLength(
|
|
2023
|
+
1
|
|
2024
|
+
);
|
|
2025
|
+
expect(updateState).toHaveBeenCalledTimes(1);
|
|
2026
|
+
expect(updateState.mock.calls[0][1]).toMatchObject({
|
|
2027
|
+
messages: [
|
|
2028
|
+
{
|
|
2029
|
+
additional_kwargs: {
|
|
2030
|
+
__librechat_subagent_checkpoint: {
|
|
2031
|
+
version: 1,
|
|
2032
|
+
parentToolCallId: 'call_recovered',
|
|
2033
|
+
lifecycleComplete: true,
|
|
2034
|
+
},
|
|
2035
|
+
},
|
|
2036
|
+
},
|
|
2037
|
+
],
|
|
2038
|
+
});
|
|
2039
|
+
});
|
|
2040
|
+
|
|
1505
2041
|
it('SubagentStart deny blocks execution', async () => {
|
|
1506
2042
|
const registry = new HookRegistry();
|
|
1507
2043
|
registry.register('SubagentStart', {
|