@librechat/agents 3.2.68 → 3.3.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 (159) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +117 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +4 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +243 -49
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +103 -28
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
  10. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/index.cjs +11 -2
  12. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  13. package/dist/cjs/llm/contextOverflowRecovery.cjs +132 -0
  14. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -0
  15. package/dist/cjs/llm/google/index.cjs +1 -1
  16. package/dist/cjs/llm/invoke.cjs +60 -3
  17. package/dist/cjs/llm/invoke.cjs.map +1 -1
  18. package/dist/cjs/llm/openai/index.cjs +1 -1
  19. package/dist/cjs/main.cjs +21 -7
  20. package/dist/cjs/messages/format.cjs +136 -4
  21. package/dist/cjs/messages/format.cjs.map +1 -1
  22. package/dist/cjs/messages/prune.cjs +16 -5
  23. package/dist/cjs/messages/prune.cjs.map +1 -1
  24. package/dist/cjs/messages/recency.cjs +2 -0
  25. package/dist/cjs/messages/recency.cjs.map +1 -1
  26. package/dist/cjs/prompts/activityLabel.cjs +101 -0
  27. package/dist/cjs/prompts/activityLabel.cjs.map +1 -0
  28. package/dist/cjs/run.cjs +177 -3
  29. package/dist/cjs/run.cjs.map +1 -1
  30. package/dist/cjs/stream.cjs +205 -49
  31. package/dist/cjs/stream.cjs.map +1 -1
  32. package/dist/cjs/summarization/node.cjs +55 -7
  33. package/dist/cjs/summarization/node.cjs.map +1 -1
  34. package/dist/cjs/tools/BashExecutor.cjs +2 -2
  35. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  36. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -2
  37. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  38. package/dist/cjs/tools/CodeExecutor.cjs +69 -8
  39. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  40. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +19 -15
  41. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  42. package/dist/cjs/tools/ToolNode.cjs +32 -3
  43. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  44. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  45. package/dist/cjs/utils/errors.cjs +317 -52
  46. package/dist/cjs/utils/errors.cjs.map +1 -1
  47. package/dist/esm/agents/AgentContext.mjs +118 -4
  48. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  49. package/dist/esm/common/enum.mjs +4 -0
  50. package/dist/esm/common/enum.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +242 -48
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +103 -28
  54. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  55. package/dist/esm/langfuseToolOutputTracing.mjs +4 -1
  56. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  57. package/dist/esm/llm/bedrock/index.mjs +11 -2
  58. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  59. package/dist/esm/llm/contextOverflowRecovery.mjs +130 -0
  60. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -0
  61. package/dist/esm/llm/google/index.mjs +1 -1
  62. package/dist/esm/llm/invoke.mjs +59 -4
  63. package/dist/esm/llm/invoke.mjs.map +1 -1
  64. package/dist/esm/llm/openai/index.mjs +1 -1
  65. package/dist/esm/main.mjs +10 -10
  66. package/dist/esm/messages/format.mjs +136 -5
  67. package/dist/esm/messages/format.mjs.map +1 -1
  68. package/dist/esm/messages/prune.mjs +14 -6
  69. package/dist/esm/messages/prune.mjs.map +1 -1
  70. package/dist/esm/messages/recency.mjs +2 -1
  71. package/dist/esm/messages/recency.mjs.map +1 -1
  72. package/dist/esm/prompts/activityLabel.mjs +100 -0
  73. package/dist/esm/prompts/activityLabel.mjs.map +1 -0
  74. package/dist/esm/run.mjs +178 -4
  75. package/dist/esm/run.mjs.map +1 -1
  76. package/dist/esm/stream.mjs +205 -49
  77. package/dist/esm/stream.mjs.map +1 -1
  78. package/dist/esm/summarization/node.mjs +55 -7
  79. package/dist/esm/summarization/node.mjs.map +1 -1
  80. package/dist/esm/tools/BashExecutor.mjs +3 -3
  81. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  82. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -3
  83. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  84. package/dist/esm/tools/CodeExecutor.mjs +62 -9
  85. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  86. package/dist/esm/tools/ProgrammaticToolCalling.mjs +20 -16
  87. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  88. package/dist/esm/tools/ToolNode.mjs +32 -3
  89. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  90. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  91. package/dist/esm/utils/errors.mjs +317 -53
  92. package/dist/esm/utils/errors.mjs.map +1 -1
  93. package/dist/types/agents/AgentContext.d.ts +62 -3
  94. package/dist/types/common/enum.d.ts +5 -1
  95. package/dist/types/graphs/Graph.d.ts +16 -2
  96. package/dist/types/graphs/MultiAgentGraph.d.ts +1 -0
  97. package/dist/types/langfuseToolOutputTracing.d.ts +4 -0
  98. package/dist/types/llm/contextOverflowRecovery.d.ts +85 -0
  99. package/dist/types/llm/invoke.d.ts +35 -6
  100. package/dist/types/messages/format.d.ts +22 -0
  101. package/dist/types/messages/prune.d.ts +10 -2
  102. package/dist/types/messages/recency.d.ts +1 -0
  103. package/dist/types/prompts/activityLabel.d.ts +31 -0
  104. package/dist/types/run.d.ts +16 -0
  105. package/dist/types/tools/CodeExecutor.d.ts +14 -1
  106. package/dist/types/types/activityLabel.d.ts +53 -0
  107. package/dist/types/types/index.d.ts +1 -0
  108. package/dist/types/types/llm.d.ts +7 -4
  109. package/dist/types/types/stream.d.ts +7 -4
  110. package/dist/types/types/summarize.d.ts +22 -0
  111. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +40 -0
  112. package/dist/types/utils/errors.d.ts +65 -16
  113. package/dist/types/utils/redactSecrets.d.ts +3 -0
  114. package/package.json +7 -8
  115. package/src/agents/AgentContext.ts +188 -7
  116. package/src/agents/__tests__/AgentContext.overflow.test.ts +205 -0
  117. package/src/common/enum.ts +4 -0
  118. package/src/graphs/Graph.ts +409 -58
  119. package/src/graphs/MultiAgentGraph.ts +184 -46
  120. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +631 -0
  121. package/src/langfuseToolOutputTracing.ts +4 -1
  122. package/src/llm/__tests__/contextOverflowRecovery.test.ts +401 -0
  123. package/src/llm/__tests__/fallbackOverflow.test.ts +287 -0
  124. package/src/llm/bedrock/index.ts +25 -12
  125. package/src/llm/contextOverflowRecovery.ts +292 -0
  126. package/src/llm/invoke.ts +119 -4
  127. package/src/messages/foldToollessToolBlocks.test.ts +438 -0
  128. package/src/messages/format.ts +233 -5
  129. package/src/messages/prune.ts +24 -11
  130. package/src/messages/recency.ts +3 -1
  131. package/src/prompts/activityLabel.ts +177 -0
  132. package/src/run.ts +322 -3
  133. package/src/scripts/context-overflow-probe.ts +997 -0
  134. package/src/specs/activity-label-prompt.test.ts +128 -0
  135. package/src/specs/activity-label-trace-seed.test.ts +47 -0
  136. package/src/specs/agent-handoffs.test.ts +903 -1
  137. package/src/specs/bedrock-toolless.live.test.ts +123 -0
  138. package/src/specs/context-overflow-recovery.live.test.ts +213 -0
  139. package/src/splitStream.test.ts +882 -0
  140. package/src/stream.ts +315 -51
  141. package/src/summarization/__tests__/aggregator.test.ts +83 -0
  142. package/src/summarization/__tests__/node.test.ts +139 -0
  143. package/src/summarization/node.ts +99 -14
  144. package/src/tools/BashExecutor.ts +4 -2
  145. package/src/tools/BashProgrammaticToolCalling.ts +4 -7
  146. package/src/tools/CodeExecutor.ts +119 -8
  147. package/src/tools/ProgrammaticToolCalling.ts +29 -27
  148. package/src/tools/ToolNode.ts +50 -8
  149. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +297 -3
  150. package/src/types/activityLabel.ts +55 -0
  151. package/src/types/index.ts +1 -0
  152. package/src/types/llm.ts +8 -1
  153. package/src/types/stream.ts +7 -4
  154. package/src/types/summarize.ts +22 -0
  155. package/src/utils/__tests__/errors.test.ts +270 -0
  156. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +336 -0
  157. package/src/utils/__tests__/redactSecrets.test.ts +56 -0
  158. package/src/utils/errors.ts +484 -66
  159. package/src/utils/redactSecrets.ts +61 -0
@@ -1,12 +1,22 @@
1
1
  // src/specs/agent-handoffs.test.ts
2
+ import { MemorySaver } from '@langchain/langgraph';
2
3
  import { DynamicStructuredTool } from '@langchain/core/tools';
3
- import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
4
+ import {
5
+ AIMessage,
6
+ HumanMessage,
7
+ ToolMessage,
8
+ getBufferString,
9
+ } from '@langchain/core/messages';
10
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
11
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
4
12
  import type { RunnableConfig } from '@langchain/core/runnables';
5
13
  import type { ToolCall } from '@langchain/core/messages/tool';
6
14
  import type * as t from '@/types';
7
15
  import { Providers, GraphEvents, Constants } from '@/common';
16
+ import { createHandlers } from '@/utils/handlers';
8
17
  import { StandardGraph } from '@/graphs/Graph';
9
18
  import { ToolNode } from '@/tools/ToolNode';
19
+ import { FakeChatModel } from '@/llm/fake';
10
20
  import * as events from '@/utils/events';
11
21
  import { Run } from '@/run';
12
22
 
@@ -41,6 +51,100 @@ const findToolByName = (
41
51
  return tools?.find((tool) => getToolName(tool) === name);
42
52
  };
43
53
 
54
+ type HandoffModelScript = {
55
+ promptMarker: string;
56
+ response: string;
57
+ toolCalls?: ToolCall[];
58
+ };
59
+
60
+ function validateToolHistory(messages: t.BaseGraphState['messages']): void {
61
+ const toolCallIds = new Set<string>();
62
+ const toolResultIds = new Set<string>();
63
+ for (const message of messages) {
64
+ if (message.getType() === 'ai') {
65
+ for (const toolCall of (message as AIMessage).tool_calls ?? []) {
66
+ if (toolCall.id != null) {
67
+ toolCallIds.add(toolCall.id);
68
+ }
69
+ }
70
+ } else if (message.getType() === 'tool') {
71
+ toolResultIds.add((message as ToolMessage).tool_call_id);
72
+ }
73
+ }
74
+ for (const toolCallId of toolCallIds) {
75
+ if (!toolResultIds.has(toolCallId)) {
76
+ throw new Error(`Tool call ${toolCallId} has no matching result`);
77
+ }
78
+ }
79
+ for (const toolResultId of toolResultIds) {
80
+ if (!toolCallIds.has(toolResultId)) {
81
+ throw new Error(`Tool result ${toolResultId} has no matching call`);
82
+ }
83
+ }
84
+ }
85
+
86
+ class ScriptedHandoffModel extends FakeChatModel {
87
+ private scripts: HandoffModelScript[];
88
+
89
+ constructor(scripts: HandoffModelScript[]) {
90
+ super({ responses: [''] });
91
+ this.scripts = scripts;
92
+ }
93
+
94
+ override async *_streamResponseChunks(
95
+ messages: t.BaseGraphState['messages'],
96
+ options: this['ParsedCallOptions'],
97
+ runManager?: CallbackManagerForLLMRun
98
+ ): AsyncGenerator<ChatGenerationChunk> {
99
+ validateToolHistory(messages);
100
+ const prompt = getBufferString(messages);
101
+ let script: HandoffModelScript | undefined;
102
+ let latestMarkerIndex = -1;
103
+ for (const candidate of this.scripts) {
104
+ const markerIndex = prompt.lastIndexOf(candidate.promptMarker);
105
+ if (markerIndex > latestMarkerIndex) {
106
+ script = candidate;
107
+ latestMarkerIndex = markerIndex;
108
+ }
109
+ }
110
+ if (script == null) {
111
+ throw new Error(`No handoff model script matched prompt: ${prompt}`);
112
+ }
113
+ const model = new FakeChatModel({
114
+ responses: [script.response],
115
+ toolCalls: script.toolCalls,
116
+ });
117
+ yield* model._streamResponseChunks(messages, options, runManager);
118
+ }
119
+ }
120
+
121
+ type HandoffReceptionProbe = {
122
+ processHandoffReception(
123
+ messages: t.BaseGraphState['messages'],
124
+ agentId: string
125
+ ): {
126
+ instructions: string | null;
127
+ parallelGroupId?: number;
128
+ } | null;
129
+ };
130
+
131
+ type PendingSend = {
132
+ node: string;
133
+ args: { messages?: t.BaseGraphState['messages'] };
134
+ };
135
+
136
+ const isPendingSend = (value: unknown): value is PendingSend => {
137
+ if (value == null || typeof value !== 'object') {
138
+ return false;
139
+ }
140
+ const candidate = value as { node?: unknown; args?: unknown };
141
+ return (
142
+ typeof candidate.node === 'string' &&
143
+ candidate.args != null &&
144
+ typeof candidate.args === 'object'
145
+ );
146
+ };
147
+
44
148
  /**
45
149
  * Test suite for Agent Handoffs feature
46
150
  *
@@ -510,6 +614,632 @@ describe('Agent Handoffs Tests', () => {
510
614
  });
511
615
  });
512
616
 
617
+ describe('Parallel Handoffs', () => {
618
+ it('should expose one runtime group on simultaneous destination content', async () => {
619
+ const agents: t.AgentInputs[] = [
620
+ createBasicAgent('router', 'You are a router'),
621
+ createBasicAgent('left', 'You are the left specialist'),
622
+ createBasicAgent('right', 'You are the right specialist'),
623
+ ];
624
+ const edges: t.GraphEdge[] = [
625
+ { from: 'router', to: 'left', edgeType: 'handoff' },
626
+ { from: 'router', to: 'right', edgeType: 'handoff' },
627
+ ];
628
+ const { contentParts, handlers } = createHandlers();
629
+ const runConfig = createTestConfig(agents, edges);
630
+ runConfig.customHandlers = handlers;
631
+ const run = await Run.create(runConfig);
632
+
633
+ run.Graph?.overrideTestModel(
634
+ ['Routing', 'Left complete', 'Right complete'],
635
+ 10,
636
+ [
637
+ {
638
+ id: 'tool_call_left',
639
+ name: `${Constants.LC_TRANSFER_TO_}left`,
640
+ args: {},
641
+ } as ToolCall,
642
+ {
643
+ id: 'tool_call_right',
644
+ name: `${Constants.LC_TRANSFER_TO_}right`,
645
+ args: {},
646
+ } as ToolCall,
647
+ ]
648
+ );
649
+
650
+ const config: Partial<RunnableConfig> & {
651
+ version: 'v1' | 'v2';
652
+ streamMode: string;
653
+ } = {
654
+ configurable: { thread_id: 'test-parallel-handoff-group-thread' },
655
+ streamMode: 'values',
656
+ version: 'v2',
657
+ };
658
+ await run.processStream(
659
+ { messages: [new HumanMessage('Run both specialists')] },
660
+ config
661
+ );
662
+
663
+ const definedParts = contentParts.filter(
664
+ (part): part is t.MessageContentComplex => part != null
665
+ );
666
+ const routerParts = definedParts.filter(
667
+ (part) => part.agentId === 'router'
668
+ );
669
+ const leftParts = definedParts.filter((part) => part.agentId === 'left');
670
+ const rightParts = definedParts.filter(
671
+ (part) => part.agentId === 'right'
672
+ );
673
+ const leftGroupId = leftParts[0]?.groupId;
674
+
675
+ expect(routerParts.length).toBeGreaterThan(0);
676
+ expect(routerParts.every((part) => part.groupId == null)).toBe(true);
677
+ expect(leftParts.length).toBeGreaterThan(0);
678
+ expect(rightParts.length).toBeGreaterThan(0);
679
+ expect(leftGroupId).toEqual(expect.any(Number));
680
+ expect(leftParts.every((part) => part.groupId === leftGroupId)).toBe(
681
+ true
682
+ );
683
+ expect(rightParts.every((part) => part.groupId === leftGroupId)).toBe(
684
+ true
685
+ );
686
+ });
687
+
688
+ it('should checkpoint one durable group ID before parallel recipients run', async () => {
689
+ const agents: t.AgentInputs[] = [
690
+ createBasicAgent('router', 'You are a router'),
691
+ createBasicAgent('left', 'You are the left specialist'),
692
+ createBasicAgent('right', 'You are the right specialist'),
693
+ ];
694
+ const edges: t.GraphEdge[] = [
695
+ { from: 'router', to: 'left', edgeType: 'handoff' },
696
+ { from: 'router', to: 'right', edgeType: 'handoff' },
697
+ ];
698
+ const checkpointer = new MemorySaver();
699
+ const runConfig = createTestConfig(agents, edges);
700
+ runConfig.graphConfig.compileOptions = { checkpointer };
701
+ const run = await Run.create(runConfig);
702
+
703
+ run.Graph?.overrideTestModel(
704
+ ['Routing', 'Left complete', 'Right complete'],
705
+ 10,
706
+ [
707
+ {
708
+ id: 'tool_call_checkpoint_left',
709
+ name: `${Constants.LC_TRANSFER_TO_}left`,
710
+ args: {},
711
+ } as ToolCall,
712
+ {
713
+ id: 'tool_call_checkpoint_right',
714
+ name: `${Constants.LC_TRANSFER_TO_}right`,
715
+ args: {},
716
+ } as ToolCall,
717
+ ]
718
+ );
719
+
720
+ const threadId = 'test-parallel-handoff-checkpoint-thread';
721
+ const config: Partial<RunnableConfig> & {
722
+ version: 'v1' | 'v2';
723
+ streamMode: string;
724
+ durability: 'sync';
725
+ } = {
726
+ configurable: { thread_id: threadId },
727
+ streamMode: 'values',
728
+ version: 'v2',
729
+ durability: 'sync',
730
+ };
731
+ const historicalToolCall = {
732
+ id: 'tool_call_before_parallel_handoff',
733
+ name: 'historical_lookup',
734
+ args: {},
735
+ };
736
+ await run.processStream(
737
+ {
738
+ messages: [
739
+ new HumanMessage('Look this up first'),
740
+ new AIMessage({ content: '', tool_calls: [historicalToolCall] }),
741
+ new ToolMessage({
742
+ content: 'Historical result',
743
+ name: historicalToolCall.name,
744
+ tool_call_id: historicalToolCall.id,
745
+ }),
746
+ new HumanMessage('Run both specialists'),
747
+ ],
748
+ },
749
+ config
750
+ );
751
+
752
+ let persistedHandoffs: ToolMessage[] = [];
753
+ let persistedSends: PendingSend[] = [];
754
+ for await (const tuple of checkpointer.list({
755
+ configurable: { thread_id: threadId },
756
+ })) {
757
+ const sends = (tuple.pendingWrites ?? [])
758
+ .filter(([, channel]) => channel === '__pregel_tasks')
759
+ .map(([, , value]) => value)
760
+ .filter(isPendingSend)
761
+ .filter(({ node }) => node === 'left' || node === 'right');
762
+ const handoffs = sends.flatMap((send) =>
763
+ (send.args.messages ?? []).filter(
764
+ (message): message is ToolMessage =>
765
+ message.getType() === 'tool' &&
766
+ message.name === `${Constants.LC_TRANSFER_TO_}${send.node}`
767
+ )
768
+ );
769
+ if (handoffs.length === 2) {
770
+ persistedHandoffs = handoffs;
771
+ persistedSends = sends;
772
+ break;
773
+ }
774
+ }
775
+
776
+ expect(persistedHandoffs).toHaveLength(2);
777
+ const persistedGroupIds = persistedHandoffs.map(
778
+ (message) => message.additional_kwargs[Constants.HANDOFF_GROUP_ID]
779
+ );
780
+ const persistedBatchIds = persistedHandoffs.map(
781
+ (message) => message.additional_kwargs[Constants.HANDOFF_PARALLEL_BATCH]
782
+ );
783
+ const persistedGroupId = persistedGroupIds[0];
784
+ if (typeof persistedGroupId !== 'number') {
785
+ throw new Error('Expected a persisted numeric handoff group ID');
786
+ }
787
+
788
+ expect(Number.isSafeInteger(persistedGroupId)).toBe(true);
789
+ expect(persistedGroupId).toBeGreaterThanOrEqual(2 ** 48);
790
+ expect(persistedGroupIds[1]).toBe(persistedGroupId);
791
+ expect(persistedBatchIds[0]).toEqual(expect.any(String));
792
+ expect(persistedBatchIds[1]).toBe(persistedBatchIds[0]);
793
+
794
+ const historicalResults = persistedSends.flatMap((send) =>
795
+ (send.args.messages ?? []).filter(
796
+ (message): message is ToolMessage =>
797
+ message.getType() === 'tool' &&
798
+ (message as ToolMessage).tool_call_id === historicalToolCall.id
799
+ )
800
+ );
801
+ expect(historicalResults).toHaveLength(2);
802
+ for (const result of historicalResults) {
803
+ expect(
804
+ result.additional_kwargs[Constants.HANDOFF_PARALLEL_BATCH]
805
+ ).toBeUndefined();
806
+ expect(
807
+ result.additional_kwargs[Constants.HANDOFF_GROUP_ID]
808
+ ).toBeUndefined();
809
+ expect(
810
+ result.additional_kwargs.handoff_parallel_siblings
811
+ ).toBeUndefined();
812
+ }
813
+ });
814
+
815
+ it('should suppress a static group when a hybrid router selects one handoff', async () => {
816
+ const agents: t.AgentInputs[] = [
817
+ createBasicAgent('router', 'You are a router'),
818
+ createBasicAgent('left', 'You are the left specialist'),
819
+ createBasicAgent('right', 'You are the right specialist'),
820
+ ];
821
+ const edges: t.GraphEdge[] = [
822
+ {
823
+ from: 'router',
824
+ to: ['left', 'right'],
825
+ edgeType: 'direct',
826
+ },
827
+ { from: 'router', to: 'left', edgeType: 'handoff' },
828
+ { from: 'router', to: 'right', edgeType: 'handoff' },
829
+ ];
830
+ const { contentParts, handlers } = createHandlers();
831
+ const runConfig = createTestConfig(agents, edges);
832
+ runConfig.customHandlers = handlers;
833
+ const run = await Run.create(runConfig);
834
+
835
+ run.Graph?.overrideTestModel(['Routing', 'Left complete'], 10, [
836
+ {
837
+ id: 'tool_call_left_only',
838
+ name: `${Constants.LC_TRANSFER_TO_}left`,
839
+ args: {},
840
+ } as ToolCall,
841
+ ]);
842
+
843
+ const config: Partial<RunnableConfig> & {
844
+ version: 'v1' | 'v2';
845
+ streamMode: string;
846
+ } = {
847
+ configurable: { thread_id: 'test-hybrid-handoff-group-thread' },
848
+ streamMode: 'values',
849
+ version: 'v2',
850
+ };
851
+ await run.processStream(
852
+ { messages: [new HumanMessage('Run only the left specialist')] },
853
+ config
854
+ );
855
+
856
+ const definedParts = contentParts.filter(
857
+ (part): part is t.MessageContentComplex => part != null
858
+ );
859
+ const leftParts = definedParts.filter((part) => part.agentId === 'left');
860
+ const rightParts = definedParts.filter(
861
+ (part) => part.agentId === 'right'
862
+ );
863
+
864
+ expect(leftParts.length).toBeGreaterThan(0);
865
+ expect(leftParts.every((part) => part.groupId == null)).toBe(true);
866
+ expect(rightParts).toHaveLength(0);
867
+ });
868
+
869
+ it('should not reuse a historical handoff when a target is later reached directly', async () => {
870
+ const agents: t.AgentInputs[] = [
871
+ createBasicAgent('router', 'historical-router-marker'),
872
+ createBasicAgent('recipient', 'historical-recipient-agent-marker'),
873
+ createBasicAgent('sibling', 'historical-sibling-agent-marker'),
874
+ ];
875
+ const edges: t.GraphEdge[] = [
876
+ { from: 'router', to: 'recipient', edgeType: 'direct' },
877
+ {
878
+ from: 'router',
879
+ to: 'recipient',
880
+ edgeType: 'handoff',
881
+ prompt: 'Instructions for the recipient',
882
+ },
883
+ {
884
+ from: 'router',
885
+ to: 'sibling',
886
+ edgeType: 'handoff',
887
+ prompt: 'Instructions for the sibling',
888
+ },
889
+ ];
890
+ const run = await Run.create(createTestConfig(agents, edges));
891
+ if (run.Graph == null) {
892
+ throw new Error('Expected a multi-agent graph');
893
+ }
894
+ run.Graph.overrideModel = new ScriptedHandoffModel([
895
+ {
896
+ promptMarker: 'stale-recipient-instructions-marker',
897
+ response: 'Historical recipient complete',
898
+ },
899
+ {
900
+ promptMarker: 'second-direct-request-marker',
901
+ response: 'Direct pass complete',
902
+ },
903
+ ]);
904
+
905
+ const config: Partial<RunnableConfig> & {
906
+ version: 'v1' | 'v2';
907
+ streamMode: string;
908
+ } = {
909
+ configurable: {
910
+ thread_id: 'test-historical-handoff-direct-thread',
911
+ },
912
+ streamMode: 'values',
913
+ version: 'v2',
914
+ };
915
+ const historicalToolCall = {
916
+ id: 'tool_call_historical_recipient',
917
+ name: `${Constants.LC_TRANSFER_TO_}recipient`,
918
+ args: { instructions: 'stale-recipient-instructions-marker' },
919
+ };
920
+ await run.processStream(
921
+ {
922
+ messages: [
923
+ new HumanMessage('first-historical-request-marker'),
924
+ new AIMessage({ content: '', tool_calls: [historicalToolCall] }),
925
+ new ToolMessage({
926
+ content:
927
+ 'Successfully transferred to recipient\n\nInstructions: stale-recipient-instructions-marker',
928
+ name: historicalToolCall.name,
929
+ tool_call_id: historicalToolCall.id,
930
+ additional_kwargs: {
931
+ handoff_source_name: 'Router',
932
+ handoff_instructions: 'stale-recipient-instructions-marker',
933
+ handoff_parallel_siblings: ['sibling'],
934
+ [Constants.HANDOFF_GROUP_ID]: 2 ** 48 + 1,
935
+ },
936
+ }),
937
+ new HumanMessage('second-direct-request-marker'),
938
+ ],
939
+ },
940
+ config
941
+ );
942
+
943
+ const recipientSteps = run.Graph.getRunSteps('recipient');
944
+ expect(recipientSteps.length).toBeGreaterThan(0);
945
+ expect(recipientSteps.every((step) => step.groupId == null)).toBe(true);
946
+ const directPassMessages = run
947
+ .getRunMessages()
948
+ ?.filter(
949
+ (message) =>
950
+ message.getType() === 'ai' &&
951
+ message.content === 'Direct pass complete'
952
+ );
953
+ expect(directPassMessages).toHaveLength(2);
954
+ });
955
+
956
+ it('should clear the handoff identity before a direct re-entry', async () => {
957
+ const agents: t.AgentInputs[] = [
958
+ {
959
+ ...createBasicAgent('router', 'stale-context-router-marker'),
960
+ name: 'Router',
961
+ },
962
+ {
963
+ ...createBasicAgent('recipient', 'stale-context-recipient-marker'),
964
+ name: 'Recipient',
965
+ },
966
+ {
967
+ ...createBasicAgent('sibling', 'stale-context-sibling-marker'),
968
+ name: 'Sibling',
969
+ },
970
+ {
971
+ ...createBasicAgent('relay', 'stale-context-relay-marker'),
972
+ name: 'Relay',
973
+ },
974
+ ];
975
+ const edges: t.GraphEdge[] = [
976
+ {
977
+ from: 'router',
978
+ to: 'recipient',
979
+ edgeType: 'handoff',
980
+ prompt: 'Instructions for the recipient',
981
+ },
982
+ {
983
+ from: 'router',
984
+ to: 'sibling',
985
+ edgeType: 'handoff',
986
+ prompt: 'Instructions for the sibling',
987
+ },
988
+ {
989
+ from: 'recipient',
990
+ to: 'relay',
991
+ edgeType: 'handoff',
992
+ prompt: 'Instructions for the relay',
993
+ },
994
+ { from: 'relay', to: 'recipient', edgeType: 'direct' },
995
+ ];
996
+ const run = await Run.create(createTestConfig(agents, edges));
997
+ if (run.Graph == null) {
998
+ throw new Error('Expected a multi-agent graph');
999
+ }
1000
+ const model = new ScriptedHandoffModel([
1001
+ {
1002
+ promptMarker: 'stale-context-router-marker',
1003
+ response: 'Routing in parallel',
1004
+ toolCalls: [
1005
+ {
1006
+ id: 'tool_call_stale_context_recipient',
1007
+ name: `${Constants.LC_TRANSFER_TO_}recipient`,
1008
+ args: { instructions: 'recipient-first-handoff-marker' },
1009
+ },
1010
+ {
1011
+ id: 'tool_call_stale_context_sibling',
1012
+ name: `${Constants.LC_TRANSFER_TO_}sibling`,
1013
+ args: { instructions: 'sibling-first-handoff-marker' },
1014
+ },
1015
+ ],
1016
+ },
1017
+ {
1018
+ promptMarker: 'recipient-first-handoff-marker',
1019
+ response: 'Sending through relay',
1020
+ toolCalls: [
1021
+ {
1022
+ id: 'tool_call_stale_context_relay',
1023
+ name: `${Constants.LC_TRANSFER_TO_}relay`,
1024
+ args: { instructions: 'relay-handoff-marker' },
1025
+ },
1026
+ ],
1027
+ },
1028
+ {
1029
+ promptMarker: 'sibling-first-handoff-marker',
1030
+ response: 'Sibling complete',
1031
+ },
1032
+ {
1033
+ promptMarker: 'relay-handoff-marker',
1034
+ response: 'relay-direct-reentry-marker',
1035
+ },
1036
+ {
1037
+ promptMarker: 'relay-direct-reentry-marker',
1038
+ response: 'Recipient direct complete',
1039
+ },
1040
+ ]);
1041
+ run.Graph.overrideModel = model;
1042
+
1043
+ const config: Partial<RunnableConfig> & {
1044
+ version: 'v1' | 'v2';
1045
+ streamMode: string;
1046
+ } = {
1047
+ configurable: {
1048
+ thread_id: 'test-clear-handoff-context-direct-reentry-thread',
1049
+ },
1050
+ streamMode: 'values',
1051
+ version: 'v2',
1052
+ };
1053
+ await run.processStream(
1054
+ { messages: [new HumanMessage('stale-context-router-marker')] },
1055
+ config
1056
+ );
1057
+
1058
+ const recipientContext = run.Graph.agentContexts.get('recipient');
1059
+ const recipientSystemRunnable = recipientContext?.systemRunnable;
1060
+ if (recipientSystemRunnable == null) {
1061
+ throw new Error('Expected recipient system instructions');
1062
+ }
1063
+ const recipientSystemPrompt = getBufferString(
1064
+ await recipientSystemRunnable.invoke([])
1065
+ );
1066
+ expect(recipientSystemPrompt).toContain('stale-context-recipient-marker');
1067
+ expect(recipientSystemPrompt).not.toContain('## Multi-Agent Workflow');
1068
+ expect(recipientSystemPrompt).not.toContain('transferred from "Router"');
1069
+ expect(recipientSystemPrompt).not.toContain('Running in parallel with:');
1070
+ });
1071
+
1072
+ it('should clear the runtime group after a parallel target hands off sequentially', async () => {
1073
+ const agents: t.AgentInputs[] = [
1074
+ createBasicAgent('router', 'router-script-marker'),
1075
+ createBasicAgent('left', 'left-script-marker'),
1076
+ createBasicAgent('right', 'right-script-marker'),
1077
+ createBasicAgent('final', 'final-script-marker'),
1078
+ ];
1079
+ const edges: t.GraphEdge[] = [
1080
+ {
1081
+ from: 'router',
1082
+ to: 'left',
1083
+ edgeType: 'handoff',
1084
+ prompt: 'Instructions for the left specialist',
1085
+ },
1086
+ {
1087
+ from: 'router',
1088
+ to: 'right',
1089
+ edgeType: 'handoff',
1090
+ prompt: 'Instructions for the right specialist',
1091
+ },
1092
+ {
1093
+ from: 'left',
1094
+ to: 'final',
1095
+ edgeType: 'handoff',
1096
+ prompt: 'Instructions for the final specialist',
1097
+ },
1098
+ ];
1099
+ const run = await Run.create(createTestConfig(agents, edges));
1100
+ if (run.Graph == null) {
1101
+ throw new Error('Expected a multi-agent graph');
1102
+ }
1103
+ run.Graph.overrideModel = new ScriptedHandoffModel([
1104
+ {
1105
+ promptMarker: 'router-script-marker',
1106
+ response: 'Routing',
1107
+ toolCalls: [
1108
+ {
1109
+ id: 'tool_call_nested_left',
1110
+ name: `${Constants.LC_TRANSFER_TO_}left`,
1111
+ args: { instructions: 'left-script-marker' },
1112
+ },
1113
+ {
1114
+ id: 'tool_call_nested_right',
1115
+ name: `${Constants.LC_TRANSFER_TO_}right`,
1116
+ args: { instructions: 'right-script-marker' },
1117
+ },
1118
+ ],
1119
+ },
1120
+ {
1121
+ promptMarker: 'left-script-marker',
1122
+ response: 'Forwarding',
1123
+ toolCalls: [
1124
+ {
1125
+ id: 'tool_call_nested_final',
1126
+ name: `${Constants.LC_TRANSFER_TO_}final`,
1127
+ args: { instructions: 'final-script-marker' },
1128
+ },
1129
+ ],
1130
+ },
1131
+ {
1132
+ promptMarker: 'right-script-marker',
1133
+ response: 'Right complete',
1134
+ },
1135
+ {
1136
+ promptMarker: 'final-script-marker',
1137
+ response: 'Final complete',
1138
+ },
1139
+ ]);
1140
+
1141
+ const config: Partial<RunnableConfig> & {
1142
+ version: 'v1' | 'v2';
1143
+ streamMode: string;
1144
+ } = {
1145
+ configurable: { thread_id: 'test-nested-handoff-group-thread' },
1146
+ streamMode: 'values',
1147
+ version: 'v2',
1148
+ };
1149
+ await run.processStream(
1150
+ { messages: [new HumanMessage('router-script-marker')] },
1151
+ config
1152
+ );
1153
+
1154
+ const leftSteps = run.Graph.getRunSteps('left');
1155
+ const rightSteps = run.Graph.getRunSteps('right');
1156
+ const finalSteps = run.Graph.getRunSteps('final');
1157
+ const parallelGroupId = leftSteps[0]?.groupId;
1158
+
1159
+ expect(parallelGroupId).toEqual(expect.any(Number));
1160
+ expect(rightSteps.length).toBeGreaterThan(0);
1161
+ expect(rightSteps.every((step) => step.groupId === parallelGroupId)).toBe(
1162
+ true
1163
+ );
1164
+ expect(finalSteps.length).toBeGreaterThan(0);
1165
+ expect(finalSteps.every((step) => step.groupId == null)).toBe(true);
1166
+ });
1167
+
1168
+ it('should remove every transfer call from mixed conditional recipient history', async () => {
1169
+ const agents: t.AgentInputs[] = [
1170
+ createBasicAgent('router', 'mixed-router-marker'),
1171
+ createBasicAgent('left', 'mixed-left-marker'),
1172
+ createBasicAgent('right', 'mixed-right-marker'),
1173
+ ];
1174
+ const edges: t.GraphEdge[] = [
1175
+ {
1176
+ from: 'router',
1177
+ to: 'left',
1178
+ edgeType: 'handoff',
1179
+ prompt: 'Instructions for the left specialist',
1180
+ },
1181
+ {
1182
+ from: 'router',
1183
+ to: 'right',
1184
+ condition: () => 'right',
1185
+ prompt: 'Instructions for the right specialist',
1186
+ },
1187
+ ];
1188
+ const run = await Run.create(createTestConfig(agents, edges));
1189
+ if (run.Graph == null) {
1190
+ throw new Error('Expected a multi-agent graph');
1191
+ }
1192
+ run.Graph.overrideModel = new ScriptedHandoffModel([
1193
+ {
1194
+ promptMarker: 'mixed-router-marker',
1195
+ response: 'Routing',
1196
+ toolCalls: [
1197
+ {
1198
+ id: 'tool_call_mixed_left',
1199
+ name: `${Constants.LC_TRANSFER_TO_}left`,
1200
+ args: { instructions: 'mixed-left-marker' },
1201
+ },
1202
+ {
1203
+ id: 'tool_call_mixed_right',
1204
+ name: 'conditional_transfer',
1205
+ args: { instructions: 'mixed-right-marker' },
1206
+ },
1207
+ ],
1208
+ },
1209
+ {
1210
+ promptMarker: 'mixed-left-marker',
1211
+ response: 'Left complete',
1212
+ },
1213
+ {
1214
+ promptMarker: 'mixed-right-marker',
1215
+ response: 'Right complete',
1216
+ },
1217
+ ]);
1218
+
1219
+ const config: Partial<RunnableConfig> & {
1220
+ version: 'v1' | 'v2';
1221
+ streamMode: string;
1222
+ } = {
1223
+ configurable: { thread_id: 'test-mixed-parallel-handoff-thread' },
1224
+ streamMode: 'values',
1225
+ version: 'v2',
1226
+ };
1227
+ await expect(
1228
+ run.processStream(
1229
+ { messages: [new HumanMessage('mixed-router-marker')] },
1230
+ config
1231
+ )
1232
+ ).resolves.toBeDefined();
1233
+
1234
+ const leftSteps = run.Graph.getRunSteps('left');
1235
+ const rightSteps = run.Graph.getRunSteps('right');
1236
+ const groupId = leftSteps[0]?.groupId;
1237
+ expect(groupId).toEqual(expect.any(Number));
1238
+ expect(rightSteps.length).toBeGreaterThan(0);
1239
+ expect(rightSteps.every((step) => step.groupId === groupId)).toBe(true);
1240
+ });
1241
+ });
1242
+
513
1243
  describe('Handoffs with Prompts', () => {
514
1244
  it('should create handoff tool with prompt parameter when prompt is specified', async () => {
515
1245
  const agents: t.AgentInputs[] = [
@@ -628,6 +1358,178 @@ describe('Agent Handoffs Tests', () => {
628
1358
  // Tool message should contain the prompt key and value
629
1359
  expect(handoffMessage?.content).toContain('Context:');
630
1360
  });
1361
+
1362
+ it('should deliver custom prompt key content to the receiving agent', async () => {
1363
+ const agents: t.AgentInputs[] = [
1364
+ createBasicAgent('agent_a', 'You are agent A'),
1365
+ createBasicAgent('agent_b', 'You are agent B'),
1366
+ ];
1367
+ const edges: t.GraphEdge[] = [
1368
+ {
1369
+ from: 'agent_a',
1370
+ to: 'agent_b',
1371
+ edgeType: 'handoff',
1372
+ prompt: 'Brief for agent B',
1373
+ promptKey: 'brief',
1374
+ },
1375
+ ];
1376
+ const run = await Run.create(createTestConfig(agents, edges));
1377
+ const brief = 'Investigate the cache invalidation path';
1378
+ const rawBrief = ` ${brief}\n`;
1379
+
1380
+ run.Graph?.overrideTestModel(
1381
+ ['Transferring', 'Investigation complete'],
1382
+ 10,
1383
+ [
1384
+ {
1385
+ id: 'tool_call_custom_prompt',
1386
+ name: `${Constants.LC_TRANSFER_TO_}agent_b`,
1387
+ args: { brief: rawBrief },
1388
+ } as ToolCall,
1389
+ ]
1390
+ );
1391
+
1392
+ const config: Partial<RunnableConfig> & {
1393
+ version: 'v1' | 'v2';
1394
+ streamMode: string;
1395
+ } = {
1396
+ configurable: { thread_id: 'test-custom-prompt-key-thread' },
1397
+ streamMode: 'values',
1398
+ version: 'v2',
1399
+ };
1400
+ await run.processStream(
1401
+ { messages: [new HumanMessage('Delegate this investigation')] },
1402
+ config
1403
+ );
1404
+
1405
+ const receivedBrief = run
1406
+ .getRunMessages()
1407
+ ?.some(
1408
+ (message) =>
1409
+ message.getType() === 'human' && message.content === brief
1410
+ );
1411
+ expect(receivedBrief).toBe(true);
1412
+ expect(
1413
+ run
1414
+ .getRunMessages()
1415
+ ?.some(
1416
+ (message) =>
1417
+ message.getType() === 'human' && message.content === rawBrief
1418
+ )
1419
+ ).toBe(false);
1420
+ });
1421
+
1422
+ it('should deliver custom prompt key content through a conditional handoff', async () => {
1423
+ const agents: t.AgentInputs[] = [
1424
+ createBasicAgent('agent_a', 'You are agent A'),
1425
+ createBasicAgent('agent_b', 'You are agent B'),
1426
+ ];
1427
+ const edges: t.GraphEdge[] = [
1428
+ {
1429
+ from: 'agent_a',
1430
+ to: 'agent_b',
1431
+ condition: () => 'agent_b',
1432
+ prompt: 'Brief for agent B',
1433
+ promptKey: 'brief',
1434
+ },
1435
+ ];
1436
+ const run = await Run.create(createTestConfig(agents, edges));
1437
+ const brief = 'Review the conditional routing result';
1438
+
1439
+ run.Graph?.overrideTestModel(['Transferring', 'Review complete'], 10, [
1440
+ {
1441
+ id: 'tool_call_conditional_prompt',
1442
+ name: 'conditional_transfer',
1443
+ args: { brief },
1444
+ } as ToolCall,
1445
+ ]);
1446
+
1447
+ const config: Partial<RunnableConfig> & {
1448
+ version: 'v1' | 'v2';
1449
+ streamMode: string;
1450
+ } = {
1451
+ configurable: {
1452
+ thread_id: 'test-conditional-custom-prompt-key-thread',
1453
+ },
1454
+ streamMode: 'values',
1455
+ version: 'v2',
1456
+ };
1457
+ await run.processStream(
1458
+ { messages: [new HumanMessage('Delegate conditionally')] },
1459
+ config
1460
+ );
1461
+
1462
+ const receivedBrief = run
1463
+ .getRunMessages()
1464
+ ?.some(
1465
+ (message) =>
1466
+ message.getType() === 'human' && message.content === brief
1467
+ );
1468
+ expect(receivedBrief).toBe(true);
1469
+ });
1470
+
1471
+ it('should recover a complete custom-key payload from a legacy checkpoint', async () => {
1472
+ const agents: t.AgentInputs[] = [
1473
+ createBasicAgent('agent_a', 'You are agent A'),
1474
+ createBasicAgent('agent_b', 'You are agent B'),
1475
+ ];
1476
+ const edges: t.GraphEdge[] = [
1477
+ {
1478
+ from: 'agent_a',
1479
+ to: 'agent_b',
1480
+ edgeType: 'handoff',
1481
+ prompt: 'Brief for agent B',
1482
+ promptKey: 'brief',
1483
+ },
1484
+ ];
1485
+ const run = await Run.create(createTestConfig(agents, edges));
1486
+ const graph = run.Graph as unknown as HandoffReceptionProbe;
1487
+ const toolCall = {
1488
+ id: 'tool_call_legacy_prompt',
1489
+ name: `${Constants.LC_TRANSFER_TO_}agent_b`,
1490
+ args: {},
1491
+ };
1492
+ const payload =
1493
+ 'Inspect the routing decision.\nContext: retain this entire line.';
1494
+ const context = graph.processHandoffReception(
1495
+ [
1496
+ new AIMessage({ content: '', tool_calls: [toolCall] }),
1497
+ new ToolMessage({
1498
+ content: `Successfully transferred to agent_b\n\nBrief: ${payload}`,
1499
+ name: toolCall.name,
1500
+ tool_call_id: toolCall.id,
1501
+ }),
1502
+ ],
1503
+ 'agent_b'
1504
+ );
1505
+
1506
+ expect(context?.instructions).toBe(payload);
1507
+
1508
+ const whitespaceContext = graph.processHandoffReception(
1509
+ [
1510
+ new AIMessage({
1511
+ content: '',
1512
+ tool_calls: [
1513
+ {
1514
+ id: 'tool_call_whitespace_prompt',
1515
+ name: `${Constants.LC_TRANSFER_TO_}agent_b`,
1516
+ args: {},
1517
+ },
1518
+ ],
1519
+ }),
1520
+ new ToolMessage({
1521
+ content: 'Successfully transferred to agent_b\n\nBrief: ',
1522
+ name: `${Constants.LC_TRANSFER_TO_}agent_b`,
1523
+ tool_call_id: 'tool_call_whitespace_prompt',
1524
+ additional_kwargs: {
1525
+ handoff_instructions: ' \n ',
1526
+ },
1527
+ }),
1528
+ ],
1529
+ 'agent_b'
1530
+ );
1531
+ expect(whitespaceContext?.instructions).toBe('');
1532
+ });
631
1533
  });
632
1534
 
633
1535
  describe('Edge Cases and Error Handling', () => {