@librechat/agents 3.4.1 → 3.4.3

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 (128) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +57 -33
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  6. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
  7. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
  8. package/dist/cjs/graphs/createGraph.cjs +15 -0
  9. package/dist/cjs/graphs/createGraph.cjs.map +1 -0
  10. package/dist/cjs/graphs/index.cjs +1 -0
  11. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  12. package/dist/cjs/main.cjs +20 -11
  13. package/dist/cjs/run.cjs +48 -38
  14. package/dist/cjs/run.cjs.map +1 -1
  15. package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
  16. package/dist/cjs/tools/SubagentTool.cjs +4 -4
  17. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  18. package/dist/cjs/tools/ToolNode.cjs +13 -9
  19. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  20. package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
  21. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
  22. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
  23. package/dist/cjs/tools/search/rerankers.cjs +154 -1
  24. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  25. package/dist/cjs/tools/search/tool.cjs +4 -1
  26. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  27. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
  28. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +672 -343
  30. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs +3 -1
  32. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  33. package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
  34. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
  35. package/dist/cjs/tools/subagent/index.cjs +1 -0
  36. package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
  37. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
  38. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  39. package/dist/esm/graphs/Graph.mjs +57 -33
  40. package/dist/esm/graphs/Graph.mjs.map +1 -1
  41. package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
  42. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  43. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
  44. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
  45. package/dist/esm/graphs/createGraph.mjs +15 -0
  46. package/dist/esm/graphs/createGraph.mjs.map +1 -0
  47. package/dist/esm/graphs/index.mjs +1 -0
  48. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  49. package/dist/esm/main.mjs +12 -10
  50. package/dist/esm/run.mjs +48 -38
  51. package/dist/esm/run.mjs.map +1 -1
  52. package/dist/esm/session/JsonlSessionStore.mjs +2 -2
  53. package/dist/esm/tools/SubagentTool.mjs +4 -4
  54. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  55. package/dist/esm/tools/ToolNode.mjs +13 -9
  56. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  57. package/dist/esm/tools/eagerEventExecution.mjs +1 -1
  58. package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
  59. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
  60. package/dist/esm/tools/search/rerankers.mjs +154 -1
  61. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  62. package/dist/esm/tools/search/tool.mjs +4 -1
  63. package/dist/esm/tools/search/tool.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
  65. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
  66. package/dist/esm/tools/subagent/SubagentExecutor.mjs +672 -343
  67. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  68. package/dist/esm/tools/subagent/SubagentReplay.mjs +3 -1
  69. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  70. package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
  71. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
  72. package/dist/esm/tools/subagent/index.mjs +1 -0
  73. package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
  74. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
  75. package/dist/types/agents/AgentContext.d.ts +1 -1
  76. package/dist/types/graphs/Graph.d.ts +7 -2
  77. package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
  78. package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
  79. package/dist/types/graphs/createGraph.d.ts +13 -0
  80. package/dist/types/graphs/graphFactory.d.ts +13 -0
  81. package/dist/types/graphs/index.d.ts +2 -0
  82. package/dist/types/run.d.ts +2 -2
  83. package/dist/types/tools/SubagentTool.d.ts +1 -1
  84. package/dist/types/tools/search/rerankers.d.ts +28 -0
  85. package/dist/types/tools/search/types.d.ts +38 -1
  86. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
  87. package/dist/types/tools/subagent/SubagentExecutor.d.ts +32 -37
  88. package/dist/types/tools/subagent/SubagentReplay.d.ts +5 -1
  89. package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
  90. package/dist/types/tools/subagent/index.d.ts +1 -1
  91. package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
  92. package/dist/types/types/graph.d.ts +184 -19
  93. package/package.json +5 -3
  94. package/src/agents/AgentContext.ts +1 -1
  95. package/src/graphs/Graph.ts +111 -60
  96. package/src/graphs/MultiAgentGraph.ts +149 -47
  97. package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
  98. package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
  99. package/src/graphs/__tests__/createGraph.test.ts +206 -0
  100. package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
  101. package/src/graphs/createGraph.ts +32 -0
  102. package/src/graphs/graphFactory.ts +12 -0
  103. package/src/graphs/index.ts +2 -0
  104. package/src/run.ts +50 -38
  105. package/src/specs/graph-subagent.live.test.ts +242 -0
  106. package/src/specs/graph-subagent.test.ts +322 -0
  107. package/src/specs/langfuse-routing.integration.test.ts +120 -1
  108. package/src/specs/subagent.test.ts +130 -4
  109. package/src/tools/SubagentTool.ts +4 -4
  110. package/src/tools/ToolNode.ts +22 -10
  111. package/src/tools/__tests__/SubagentExecutor.test.ts +198 -9
  112. package/src/tools/__tests__/SubagentReplay.test.ts +29 -0
  113. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +28 -2
  114. package/src/tools/__tests__/subagentHooks.test.ts +90 -3
  115. package/src/tools/search/rag-api-reranker.test.ts +891 -0
  116. package/src/tools/search/rerankers.ts +260 -0
  117. package/src/tools/search/tool.ts +6 -0
  118. package/src/tools/search/types.ts +45 -1
  119. package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
  120. package/src/tools/subagent/SubagentExecutor.ts +1245 -563
  121. package/src/tools/subagent/SubagentReplay.ts +10 -1
  122. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
  123. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
  124. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
  125. package/src/tools/subagent/childGraphConfig.ts +694 -0
  126. package/src/tools/subagent/index.ts +6 -0
  127. package/src/tools/subagent/runtimeLimits.ts +7 -0
  128. package/src/types/graph.ts +217 -19
@@ -205,6 +205,22 @@ type RunToolBatchContext<T = unknown> = {
205
205
  errorOwnership?: ToolErrorOwnership;
206
206
  };
207
207
 
208
+ function withSubagentReplayBatch(
209
+ config: RunnableConfig,
210
+ replayBatchKey: string | undefined
211
+ ): RunnableConfig {
212
+ if (replayBatchKey == null) {
213
+ return config;
214
+ }
215
+ return {
216
+ ...config,
217
+ configurable: {
218
+ ...config.configurable,
219
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: replayBatchKey,
220
+ },
221
+ };
222
+ }
223
+
208
224
  type SettledDirectToolResult = {
209
225
  output: BaseMessage | Command;
210
226
  additionalContexts: string[];
@@ -1632,15 +1648,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1632
1648
  this.toolMap.get(call.name) as ReplayableSubagentTool | undefined
1633
1649
  )?.[SUBAGENT_REPLAY_CONTROLLER];
1634
1650
  const replayConfig =
1635
- replayController == null || batchContext.replayBatchKey == null
1651
+ replayController == null
1636
1652
  ? config
1637
- : {
1638
- ...config,
1639
- configurable: {
1640
- ...config.configurable,
1641
- [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: batchContext.replayBatchKey,
1642
- },
1643
- };
1653
+ : withSubagentReplayBatch(config, batchContext.replayBatchKey);
1644
1654
  const settledOutput = await replayController?.getSettledOutput(
1645
1655
  call,
1646
1656
  replayConfig
@@ -4166,8 +4176,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4166
4176
  SubagentResumeManifest['executions'][number]
4167
4177
  >();
4168
4178
  for (const controller of controllers) {
4169
- const manifest =
4170
- await controller.getResumeManifest?.(parentToolCallIds);
4179
+ const manifest = await controller.getResumeManifest?.(
4180
+ parentToolCallIds,
4181
+ withSubagentReplayBatch(config, baseContext.replayBatchKey)
4182
+ );
4171
4183
  if (manifest != null) {
4172
4184
  for (const execution of manifest.executions) {
4173
4185
  executionsByParentCall.set(execution.parentToolCallId, execution);
@@ -4,7 +4,9 @@ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
4
4
  import type { BaseMessage } from '@langchain/core/messages';
5
5
  import type {
6
6
  AgentInputs,
7
+ GraphSubagentConfig,
7
8
  ResolvedSubagentConfig,
9
+ ResolvedSingleAgentSubagentConfig,
8
10
  StandardGraphInput,
9
11
  SubagentUpdateEvent,
10
12
  SubagentUsageEvent,
@@ -14,6 +16,7 @@ import type {
14
16
  import type { StandardGraph } from '@/graphs/Graph';
15
17
  import {
16
18
  SubagentExecutor,
19
+ filterGraphSubagentResult,
17
20
  filterSubagentResult,
18
21
  resolveSubagentConfigs,
19
22
  buildChildInputs,
@@ -39,8 +42,8 @@ const makeChildInputs = (agentId = 'child-agent'): AgentInputs => ({
39
42
 
40
43
  const makeConfig = (
41
44
  type = 'researcher',
42
- overrides: Partial<ResolvedSubagentConfig> = {}
43
- ): ResolvedSubagentConfig => ({
45
+ overrides: Partial<ResolvedSingleAgentSubagentConfig> = {}
46
+ ): ResolvedSingleAgentSubagentConfig => ({
44
47
  type,
45
48
  name: 'Test Researcher',
46
49
  description: 'Researches things',
@@ -49,6 +52,18 @@ const makeConfig = (
49
52
  });
50
53
 
51
54
  describe('filterSubagentResult', () => {
55
+ it('treats a result member with no new AI message as textless completion', () => {
56
+ expect(
57
+ filterGraphSubagentResult(
58
+ {
59
+ messages: [new AIMessage('worker output')],
60
+ subagentResult: { agentId: 'result' },
61
+ },
62
+ 'result'
63
+ )
64
+ ).toBe('Task completed');
65
+ });
66
+
52
67
  it('extracts text from last AIMessage string content', () => {
53
68
  const messages: BaseMessage[] = [
54
69
  new HumanMessage('task'),
@@ -220,7 +235,8 @@ describe('resolveSubagentConfigs', () => {
220
235
  const parentContext = AgentContext.fromConfig(parentInputs);
221
236
  const resolved = resolveSubagentConfigs([config], parentContext);
222
237
  expect(resolved).toHaveLength(1);
223
- expect(resolved[0].agentInputs.agentId).toBe('child-agent');
238
+ const resolvedConfig = resolved[0];
239
+ expect(resolvedConfig.agentInputs.agentId).toBe('child-agent');
224
240
  });
225
241
 
226
242
  it('resolves self-spawn from parent _sourceInputs', () => {
@@ -233,8 +249,9 @@ describe('resolveSubagentConfigs', () => {
233
249
  const parentContext = AgentContext.fromConfig(parentInputs);
234
250
  const resolved = resolveSubagentConfigs([selfConfig], parentContext);
235
251
  expect(resolved).toHaveLength(1);
236
- expect(resolved[0].agentInputs.provider).toBe(Providers.OPENAI);
237
- expect(resolved[0].agentInputs.instructions).toBe(
252
+ const resolvedConfig = resolved[0];
253
+ expect(resolvedConfig.agentInputs.provider).toBe(Providers.OPENAI);
254
+ expect(resolvedConfig.agentInputs.instructions).toBe(
238
255
  'You are a parent agent.'
239
256
  );
240
257
  });
@@ -496,6 +513,55 @@ describe('SubagentExecutor', () => {
496
513
  expect(result.messages).toEqual([]);
497
514
  });
498
515
 
516
+ it('fails graph HITL before factories, hooks, events, or usage can run', async () => {
517
+ const graphConfig: GraphSubagentConfig = {
518
+ kind: 'graph',
519
+ type: 'graph-team',
520
+ name: 'Graph Team',
521
+ description: 'Runs a graph.',
522
+ agents: [makeChildInputs('graph-member')],
523
+ edges: [],
524
+ entryAgentId: 'graph-member',
525
+ resultAgentId: 'graph-member',
526
+ };
527
+ const createChildGraph = jest.fn(makeNoopGraphFactory());
528
+ const createChildGraphByKind = jest.fn(
529
+ (): StandardGraph => makeNoopGraphFactory()()
530
+ );
531
+ const hook = jest.fn(async (): Promise<Record<string, never>> => ({}));
532
+ const hookRegistry = new HookRegistry();
533
+ hookRegistry.register('SubagentStart', { hooks: [hook] });
534
+ const update = jest.fn();
535
+ const handlerRegistry = new HandlerRegistry();
536
+ handlerRegistry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
537
+ handle: update,
538
+ });
539
+ const usageSink = jest.fn();
540
+ const executor = createExecutor({
541
+ configs: new Map([[graphConfig.type, graphConfig]]),
542
+ humanInTheLoop: { enabled: true },
543
+ createChildGraph,
544
+ createChildGraphByKind,
545
+ hookRegistry,
546
+ parentHandlerRegistry: handlerRegistry,
547
+ usageSink,
548
+ });
549
+
550
+ const result = await executor.execute({
551
+ description: 'Do not run.',
552
+ subagentType: graphConfig.type,
553
+ });
554
+
555
+ expect(result.content).toBe(
556
+ 'Error: Human-in-the-loop execution is not yet supported for graph subagents.'
557
+ );
558
+ expect(createChildGraph).not.toHaveBeenCalled();
559
+ expect(createChildGraphByKind).not.toHaveBeenCalled();
560
+ expect(hook).not.toHaveBeenCalled();
561
+ expect(update).not.toHaveBeenCalled();
562
+ expect(usageSink).not.toHaveBeenCalled();
563
+ });
564
+
499
565
  it('fails closed when resumable execution has no parent tool call ID', async () => {
500
566
  const createChildGraph = jest.fn(makeNoopGraphFactory());
501
567
  const executor = createExecutor({
@@ -2558,6 +2624,107 @@ describe('SubagentExecutor', () => {
2558
2624
  expect(phases[phases.length - 1]).toBe('stop');
2559
2625
  });
2560
2626
 
2627
+ it('does not let a stalled observational update handler block child completion', async () => {
2628
+ jest.useFakeTimers();
2629
+ try {
2630
+ const registry = new HandlerRegistry();
2631
+ const updateHandler = jest.fn(
2632
+ (): Promise<void> => new Promise<void>(() => {})
2633
+ );
2634
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
2635
+ handle: updateHandler,
2636
+ });
2637
+ const { factory } = makeStubGraphFactory({
2638
+ messages: [new AIMessage('ok')],
2639
+ });
2640
+ const executor = createExecutor({
2641
+ createChildGraph: factory,
2642
+ parentHandlerRegistry: registry,
2643
+ });
2644
+
2645
+ const execution = executor.execute({
2646
+ description: 'Task',
2647
+ subagentType: 'researcher',
2648
+ });
2649
+ const outcome = Promise.race([
2650
+ execution.then(({ content }) => content),
2651
+ new Promise<string>((resolve) =>
2652
+ setTimeout(() => resolve('stalled'), 20_000)
2653
+ ),
2654
+ ]);
2655
+
2656
+ await jest.advanceTimersByTimeAsync(20_000);
2657
+
2658
+ await expect(outcome).resolves.toBe('ok');
2659
+ expect(updateHandler).toHaveBeenCalledTimes(2);
2660
+ } finally {
2661
+ jest.useRealTimers();
2662
+ }
2663
+ });
2664
+
2665
+ it('does not let a stalled queued update block the terminal stop envelope', async () => {
2666
+ jest.useFakeTimers();
2667
+ try {
2668
+ const phases: SubagentUpdateEvent['phase'][] = [];
2669
+ const registry = new HandlerRegistry();
2670
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
2671
+ handle: (_event, rawData): void | Promise<void> => {
2672
+ const update = rawData as SubagentUpdateEvent;
2673
+ phases.push(update.phase);
2674
+ if (update.phase === 'run_step') {
2675
+ return new Promise<void>(() => {});
2676
+ }
2677
+ },
2678
+ });
2679
+ const factory: () => StandardGraph = (): StandardGraph =>
2680
+ ({
2681
+ createWorkflow: (): { invoke: jest.Mock } => ({
2682
+ invoke: jest.fn().mockImplementation(async (_state, options) => {
2683
+ const opts = options as { callbacks?: unknown[] };
2684
+ const forwarder = (opts.callbacks ?? [])[0] as {
2685
+ handleCustomEvent?: (
2686
+ eventName: string,
2687
+ data: unknown
2688
+ ) => Promise<void> | void;
2689
+ };
2690
+ for (let index = 0; index < 80; index++) {
2691
+ await forwarder.handleCustomEvent?.(GraphEvents.ON_RUN_STEP, {
2692
+ id: `step_${index}`,
2693
+ type: StepTypes.MESSAGE_CREATION,
2694
+ agentId: 'researcher',
2695
+ index,
2696
+ });
2697
+ }
2698
+ return { messages: [new AIMessage('ok')] };
2699
+ }),
2700
+ }),
2701
+ clearHeavyState: jest.fn(),
2702
+ }) as unknown as StandardGraph;
2703
+ const executor = createExecutor({
2704
+ createChildGraph: factory,
2705
+ parentHandlerRegistry: registry,
2706
+ });
2707
+
2708
+ const execution = executor.execute({
2709
+ description: 'Task',
2710
+ subagentType: 'researcher',
2711
+ });
2712
+ const outcome = Promise.race([
2713
+ execution.then(({ content }) => content),
2714
+ new Promise<string>((resolve) =>
2715
+ setTimeout(() => resolve('stalled'), 20_000)
2716
+ ),
2717
+ ]);
2718
+
2719
+ await jest.advanceTimersByTimeAsync(20_000);
2720
+
2721
+ await expect(outcome).resolves.toBe('ok');
2722
+ expect(phases).toEqual(['start', 'run_step', 'stop']);
2723
+ } finally {
2724
+ jest.useRealTimers();
2725
+ }
2726
+ });
2727
+
2561
2728
  it('allowlists forwarded run step payloads before wrapping them in ON_SUBAGENT_UPDATE', async () => {
2562
2729
  const subagentUpdates: SubagentUpdateEvent[] = [];
2563
2730
  const registry = new HandlerRegistry();
@@ -2685,18 +2852,32 @@ describe('SubagentExecutor', () => {
2685
2852
  expect(serialized).not.toContain('nested-completed-secret');
2686
2853
  });
2687
2854
 
2688
- it('does not drop non-droppable updates when the forwarding queue overflows', async () => {
2855
+ it('bounds queued updates under overload while preserving lifecycle envelopes', async () => {
2856
+ const phases: SubagentUpdateEvent['phase'][] = [];
2689
2857
  const completedIds: string[] = [];
2858
+ let releaseFirstCompleted!: () => void;
2859
+ const firstCompletedBlocked = new Promise<void>((resolve) => {
2860
+ releaseFirstCompleted = resolve;
2861
+ });
2862
+ let markAllEmitted!: () => void;
2863
+ const allEmitted = new Promise<void>((resolve) => {
2864
+ markAllEmitted = resolve;
2865
+ });
2866
+ let blockedFirstCompleted = false;
2690
2867
  const registry = new HandlerRegistry();
2691
2868
  registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
2692
2869
  handle: async (_event, rawData): Promise<void> => {
2693
2870
  const update = rawData as SubagentUpdateEvent;
2871
+ phases.push(update.phase);
2694
2872
  if (update.phase === 'run_step_completed') {
2695
2873
  const data = update.data as { result?: { id?: string } };
2696
2874
  if (data.result?.id != null) {
2697
2875
  completedIds.push(data.result.id);
2698
2876
  }
2699
- await new Promise((resolve) => setTimeout(resolve, 1));
2877
+ if (!blockedFirstCompleted) {
2878
+ blockedFirstCompleted = true;
2879
+ await firstCompletedBlocked;
2880
+ }
2700
2881
  }
2701
2882
  },
2702
2883
  });
@@ -2731,6 +2912,7 @@ describe('SubagentExecutor', () => {
2731
2912
  }
2732
2913
  );
2733
2914
  }
2915
+ markAllEmitted();
2734
2916
  return { messages: [new AIMessage('ok')] };
2735
2917
  }),
2736
2918
  }),
@@ -2742,14 +2924,21 @@ describe('SubagentExecutor', () => {
2742
2924
  parentHandlerRegistry: registry,
2743
2925
  });
2744
2926
 
2745
- await executor.execute({
2927
+ const execution = executor.execute({
2746
2928
  description: 'Task',
2747
2929
  subagentType: 'researcher',
2748
2930
  });
2931
+ await allEmitted;
2932
+ releaseFirstCompleted();
2933
+ await execution;
2749
2934
 
2750
- expect(completedIds).toHaveLength(80);
2935
+ expect(completedIds).toHaveLength(65);
2751
2936
  expect(completedIds[0]).toBe('step_0');
2937
+ expect(completedIds).not.toContain('step_1');
2938
+ expect(completedIds).toContain('step_16');
2752
2939
  expect(completedIds[completedIds.length - 1]).toBe('step_79');
2940
+ expect(phases[0]).toBe('start');
2941
+ expect(phases[phases.length - 1]).toBe('stop');
2753
2942
  });
2754
2943
 
2755
2944
  it('does NOT forward ON_TOOL_EXECUTE when the parent registry has no handler (safe fallback)', async () => {
@@ -9,6 +9,8 @@ describe('SubagentReplay manifest', () => {
9
9
  const execution = {
10
10
  parentToolCallId: 'call_parent',
11
11
  childRunId: 'child-run',
12
+ subagentType: 'researcher',
13
+ configId: 'researcher@v1',
12
14
  approvalExecutionScope: 'child-approval-scope',
13
15
  checkpoints: [
14
16
  {
@@ -82,6 +84,33 @@ describe('SubagentReplay manifest', () => {
82
84
  });
83
85
  });
84
86
 
87
+ it('accepts legacy executions without a bound subagent type', () => {
88
+ const { subagentType: _subagentType, ...legacyExecution } = execution;
89
+ const manifest = { version: 1 as const, executions: [legacyExecution] };
90
+ const payload = attachSubagentResumeManifest(
91
+ { type: 'tool_approval' },
92
+ manifest
93
+ );
94
+
95
+ expect(getSubagentResumeManifest(payload)).toEqual(manifest);
96
+ });
97
+
98
+ it.each(['', 42, { type: 'researcher' }])(
99
+ 'rejects malformed bound subagent type %#',
100
+ (subagentType) => {
101
+ const manifest = {
102
+ version: 1 as const,
103
+ executions: [{ ...execution, subagentType }],
104
+ };
105
+
106
+ expect(
107
+ getSubagentResumeManifest({
108
+ __librechat_subagent_resume_manifest: manifest,
109
+ })
110
+ ).toBeUndefined();
111
+ }
112
+ );
113
+
85
114
  it.each([
86
115
  ['string', 'approve this child'],
87
116
  ['number', 42],
@@ -138,9 +138,21 @@ describe('direct-path HITL: resume scope', () => {
138
138
  };
139
139
  const replayableTool = directTool as StructuredToolInterface &
140
140
  ReplayableSubagentTool;
141
+ const getResumeManifest = jest.fn(
142
+ async (
143
+ _parentToolCallIds?: ReadonlySet<string>,
144
+ _config?: RunnableConfig
145
+ ) => manifest
146
+ );
147
+ const getSettledOutput = jest.fn(
148
+ async (
149
+ _call: ToolCall,
150
+ _config: RunnableConfig
151
+ ): Promise<SettledSubagentToolOutput | undefined> => undefined
152
+ );
141
153
  replayableTool[SUBAGENT_REPLAY_CONTROLLER] = {
142
- getResumeManifest: async () => manifest,
143
- getSettledOutput: async () => undefined,
154
+ getResumeManifest,
155
+ getSettledOutput,
144
156
  persistSettledOutput: async () => {},
145
157
  };
146
158
  const node = new ToolNode({
@@ -166,6 +178,20 @@ describe('direct-path HITL: resume scope', () => {
166
178
  throw new Error('expected primitive child interrupt');
167
179
  }
168
180
  const internalPayload = interrupted.__interrupt__[0].value;
181
+ const replayBatchKey =
182
+ getSettledOutput.mock.calls[0]?.[1].configurable?.[
183
+ SUBAGENT_PARENT_BATCH_CONFIG_KEY
184
+ ];
185
+ expect(replayBatchKey).toEqual(expect.any(String));
186
+ expect(getResumeManifest).toHaveBeenCalledWith(
187
+ new Set(['call_primitive']),
188
+ expect.objectContaining({
189
+ configurable: expect.objectContaining({
190
+ thread_id: 'parent-thread',
191
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: replayBatchKey,
192
+ }),
193
+ })
194
+ );
169
195
  expect(getSubagentResumeManifest(internalPayload)).toEqual(manifest);
170
196
  expect(stripSubagentResumeManifest(internalPayload)).toBe('confirm child');
171
197
  });
@@ -496,6 +496,60 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
496
496
  expect(captured!.messages.length).toBeGreaterThan(0);
497
497
  });
498
498
 
499
+ it('propagates a caller-only Run signal into lazy subagent resolution', async () => {
500
+ const callerAbort = new AbortController();
501
+ let resolverSignal: AbortSignal | undefined;
502
+ let markResolverStarted = (): void => undefined;
503
+ const resolverStarted = new Promise<void>((resolve) => {
504
+ markResolverStarted = resolve;
505
+ });
506
+ let releaseResolver = (): void => undefined;
507
+ const resolverRelease = new Promise<void>((resolve) => {
508
+ releaseResolver = resolve;
509
+ });
510
+ const parent = createParentAgent();
511
+ const eagerChild = parent.subagentConfigs?.[0]?.agentInputs;
512
+ if (eagerChild == null) {
513
+ throw new Error('Expected eager child inputs.');
514
+ }
515
+ parent.subagentConfigs = [
516
+ {
517
+ type: 'researcher',
518
+ name: 'Researcher',
519
+ description: 'Researches topics',
520
+ configId: 'caller-signal-researcher@v1',
521
+ resolveAgentInputs: async (context) => {
522
+ resolverSignal = context.signal;
523
+ markResolverStarted();
524
+ await resolverRelease;
525
+ return eagerChild;
526
+ },
527
+ },
528
+ ];
529
+ const run = await Run.create<t.IState>({
530
+ runId: `subagent-caller-signal-${Date.now()}`,
531
+ graphConfig: { type: 'standard', agents: [parent] },
532
+ returnContent: true,
533
+ skipCleanup: true,
534
+ });
535
+ run.Graph!.overrideTestModel(['Delegating...', 'Caller cancelled.'], 5, [
536
+ makeSubagentToolCall('call_caller_signal'),
537
+ ]);
538
+
539
+ const processing = run.processStream(
540
+ { messages: [new HumanMessage('cancel this delegation')] },
541
+ { ...callerConfig, signal: callerAbort.signal }
542
+ );
543
+ await resolverStarted;
544
+ callerAbort.abort(new Error('caller cancelled'));
545
+ await Promise.resolve();
546
+ const resolverWasAborted = resolverSignal?.aborted === true;
547
+ releaseResolver();
548
+ await processing.catch(() => undefined);
549
+
550
+ expect(resolverWasAborted).toBe(true);
551
+ });
552
+
499
553
  it('SubagentStart deny blocks subagent execution and returns blocked message', async () => {
500
554
  const registry = new HookRegistry();
501
555
  const denyHook: HookCallback<
@@ -1084,14 +1138,37 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
1084
1138
  schema: z.object({}),
1085
1139
  }
1086
1140
  );
1141
+ const resolutionContexts: t.SubagentResolveContext[] = [];
1142
+ const createLazyParent = (): t.AgentInputs => {
1143
+ const parent = createParentAgentWithPrimitiveInterruptTool(
1144
+ primitiveInterruptTool
1145
+ );
1146
+ const child = parent.subagentConfigs?.[0];
1147
+ const childInputs = child?.agentInputs;
1148
+ if (child == null || childInputs == null) {
1149
+ throw new Error('Expected a child agent configuration.');
1150
+ }
1151
+ return {
1152
+ ...parent,
1153
+ subagentConfigs: [
1154
+ {
1155
+ ...child,
1156
+ agentInputs: undefined,
1157
+ configId: 'primitive-interrupt-child@v1',
1158
+ resolveAgentInputs: async (context) => {
1159
+ resolutionContexts.push(context);
1160
+ return childInputs;
1161
+ },
1162
+ },
1163
+ ],
1164
+ };
1165
+ };
1087
1166
  const createRun = (currentRunId: string): Promise<Run<t.IState>> =>
1088
1167
  Run.create<t.IState>({
1089
1168
  runId: currentRunId,
1090
1169
  graphConfig: {
1091
1170
  type: 'standard',
1092
- agents: [
1093
- createParentAgentWithPrimitiveInterruptTool(primitiveInterruptTool),
1094
- ],
1171
+ agents: [createLazyParent()],
1095
1172
  compileOptions: { checkpointer },
1096
1173
  },
1097
1174
  returnContent: true,
@@ -1140,6 +1217,16 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
1140
1217
  );
1141
1218
 
1142
1219
  expect(rebuiltRun.getInterrupt()).toBeUndefined();
1220
+ expect(resolutionContexts).toHaveLength(2);
1221
+ expect(resolutionContexts[0].executionId).toBe(
1222
+ resolutionContexts[1].executionId
1223
+ );
1224
+ expect(resolutionContexts[0].descriptor.configId).toBe(
1225
+ 'primitive-interrupt-child@v1'
1226
+ );
1227
+ expect(resolutionContexts[1].descriptor.configId).toBe(
1228
+ 'primitive-interrupt-child@v1'
1229
+ );
1143
1230
  expect(resumedValues).toEqual(['approved after restart']);
1144
1231
  expect(JSON.stringify(rebuiltContent)).toContain('Final answer.');
1145
1232
  expect(JSON.stringify(rebuiltContent)).not.toContain('host edit on resume');