@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.
Files changed (114) hide show
  1. package/dist/cjs/graphs/Graph.cjs +111 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs.map +1 -1
  8. package/dist/cjs/hooks/types.cjs +2 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/llm/init.cjs +3 -3
  11. package/dist/cjs/llm/invoke.cjs +2 -2
  12. package/dist/cjs/main.cjs +14 -13
  13. package/dist/cjs/messages/format.cjs +40 -23
  14. package/dist/cjs/messages/format.cjs.map +1 -1
  15. package/dist/cjs/run.cjs +82 -15
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/session/AgentSession.cjs +37 -2
  18. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  19. package/dist/cjs/stream.cjs +1 -1
  20. package/dist/cjs/summarization/node.cjs +6 -3
  21. package/dist/cjs/summarization/node.cjs.map +1 -1
  22. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  23. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  24. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  25. package/dist/cjs/tools/ToolNode.cjs +316 -94
  26. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  27. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  28. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +630 -78
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  30. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  32. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  33. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  34. package/dist/cjs/utils/index.cjs +2 -2
  35. package/dist/esm/graphs/Graph.mjs +111 -27
  36. package/dist/esm/graphs/Graph.mjs.map +1 -1
  37. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  38. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  39. package/dist/esm/hooks/executeHooks.mjs +60 -14
  40. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  41. package/dist/esm/hooks/index.mjs.map +1 -1
  42. package/dist/esm/hooks/types.mjs +2 -1
  43. package/dist/esm/hooks/types.mjs.map +1 -1
  44. package/dist/esm/llm/init.mjs +1 -1
  45. package/dist/esm/llm/invoke.mjs +2 -2
  46. package/dist/esm/main.mjs +11 -11
  47. package/dist/esm/messages/format.mjs +40 -23
  48. package/dist/esm/messages/format.mjs.map +1 -1
  49. package/dist/esm/run.mjs +83 -16
  50. package/dist/esm/run.mjs.map +1 -1
  51. package/dist/esm/session/AgentSession.mjs +37 -2
  52. package/dist/esm/session/AgentSession.mjs.map +1 -1
  53. package/dist/esm/stream.mjs +1 -1
  54. package/dist/esm/summarization/node.mjs +6 -3
  55. package/dist/esm/summarization/node.mjs.map +1 -1
  56. package/dist/esm/tools/BashExecutor.mjs +1 -1
  57. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  58. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  59. package/dist/esm/tools/ToolNode.mjs +317 -95
  60. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  61. package/dist/esm/tools/ToolSearch.mjs +1 -1
  62. package/dist/esm/tools/subagent/SubagentExecutor.mjs +631 -79
  63. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  65. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  66. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  67. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  68. package/dist/esm/utils/index.mjs +2 -2
  69. package/dist/types/graphs/Graph.d.ts +16 -1
  70. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  71. package/dist/types/hooks/executeHooks.d.ts +5 -1
  72. package/dist/types/hooks/index.d.ts +2 -2
  73. package/dist/types/hooks/types.d.ts +12 -0
  74. package/dist/types/run.d.ts +4 -1
  75. package/dist/types/session/AgentSession.d.ts +1 -0
  76. package/dist/types/tools/ToolNode.d.ts +23 -15
  77. package/dist/types/tools/subagent/SubagentExecutor.d.ts +46 -7
  78. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  79. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  80. package/dist/types/types/graph.d.ts +5 -5
  81. package/dist/types/types/hitl.d.ts +15 -0
  82. package/package.json +6 -2
  83. package/src/graphs/Graph.ts +246 -52
  84. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  85. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  86. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  87. package/src/hooks/HookRegistry.ts +151 -1
  88. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  89. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  90. package/src/hooks/executeHooks.ts +133 -15
  91. package/src/hooks/index.ts +3 -1
  92. package/src/hooks/types.ts +16 -0
  93. package/src/messages/format.ts +54 -26
  94. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  95. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  96. package/src/messages/formatAgentMessages.test.ts +7 -2
  97. package/src/run.ts +164 -18
  98. package/src/session/AgentSession.ts +52 -2
  99. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  100. package/src/summarization/__tests__/node.test.ts +32 -14
  101. package/src/summarization/node.ts +15 -11
  102. package/src/tools/ToolNode.ts +631 -157
  103. package/src/tools/__tests__/SubagentExecutor.test.ts +542 -6
  104. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  105. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  106. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  107. package/src/tools/__tests__/hitl.test.ts +234 -5
  108. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  109. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  110. package/src/tools/subagent/SubagentExecutor.ts +1369 -131
  111. package/src/tools/subagent/SubagentReplay.ts +575 -0
  112. package/src/tools/toolOutputReferences.ts +40 -1
  113. package/src/types/graph.ts +5 -5
  114. 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 a checkpointer-less child graph and fail with
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 still fail in children, but non-interrupting
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', {