@librechat/agents 3.9.2 → 3.9.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 (43) hide show
  1. package/dist/cjs/graphs/Graph.cjs +13 -4
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +97 -5
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/graphs/handoff.cjs +195 -0
  6. package/dist/cjs/graphs/handoff.cjs.map +1 -0
  7. package/dist/cjs/graphs/index.cjs +1 -0
  8. package/dist/cjs/main.cjs +2 -0
  9. package/dist/cjs/run.cjs +33 -3
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +4 -1
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/esm/graphs/Graph.mjs +13 -4
  14. package/dist/esm/graphs/Graph.mjs.map +1 -1
  15. package/dist/esm/graphs/MultiAgentGraph.mjs +97 -5
  16. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  17. package/dist/esm/graphs/handoff.mjs +193 -0
  18. package/dist/esm/graphs/handoff.mjs.map +1 -0
  19. package/dist/esm/graphs/index.mjs +1 -0
  20. package/dist/esm/main.mjs +2 -1
  21. package/dist/esm/run.mjs +33 -3
  22. package/dist/esm/run.mjs.map +1 -1
  23. package/dist/esm/tools/ToolNode.mjs +4 -1
  24. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  25. package/dist/types/graphs/Graph.d.ts +2 -0
  26. package/dist/types/graphs/MultiAgentGraph.d.ts +3 -0
  27. package/dist/types/graphs/handoff.d.ts +28 -0
  28. package/dist/types/graphs/index.d.ts +1 -0
  29. package/dist/types/run.d.ts +4 -0
  30. package/dist/types/tools/ToolNode.d.ts +2 -1
  31. package/dist/types/types/graph.d.ts +46 -1
  32. package/dist/types/types/run.d.ts +3 -1
  33. package/dist/types/types/tools.d.ts +3 -0
  34. package/package.json +1 -1
  35. package/src/graphs/Graph.ts +8 -0
  36. package/src/graphs/MultiAgentGraph.ts +142 -4
  37. package/src/graphs/handoff.ts +263 -0
  38. package/src/graphs/index.ts +2 -0
  39. package/src/run.ts +54 -2
  40. package/src/tools/ToolNode.ts +14 -6
  41. package/src/types/graph.ts +44 -1
  42. package/src/types/run.ts +3 -1
  43. package/src/types/tools.ts +3 -0
@@ -33,6 +33,7 @@ import {
33
33
  HARD_MAX_TOOL_RESULT_CHARS,
34
34
  } from '@/utils/truncation';
35
35
  import { withInstructionlessHandoffCue } from '@/messages/handoffCue';
36
+ import { HandoffRouting, handoffStateAnnotation } from './handoff';
36
37
  import { serializeToolContentBounded } from '@/utils/toolContent';
37
38
  import { Constants, MULTI_AGENT_GRAPH_RUN_NAME } from '@/common';
38
39
  import { StandardGraph } from './Graph';
@@ -337,6 +338,7 @@ export class MultiAgentGraph extends StandardGraph {
337
338
  private handoffSourceIds = new Set<string>();
338
339
  private readonly resultAgentId?: string;
339
340
  private readonly memberRecursionLimit?: number;
341
+ private reachableAgentIds?: Set<string>;
340
342
  private handoffPromptLabels: Map<string, Set<string>> = new Map();
341
343
  /**
342
344
  * Map of agentId to parallel group info.
@@ -379,9 +381,115 @@ export class MultiAgentGraph extends StandardGraph {
379
381
  this.categorizeEdges();
380
382
  this.validateCommandRoutedDirectEdges();
381
383
  this.analyzeGraph();
384
+ if (input.entryAgentId != null) {
385
+ if (!this.agentContexts.has(input.entryAgentId)) {
386
+ throw new Error(
387
+ `MultiAgentGraph: unknown entryAgentId "${input.entryAgentId}"`
388
+ );
389
+ }
390
+ this.startingNodes = new Set([input.entryAgentId]);
391
+ this.defaultAgentId = input.entryAgentId;
392
+ this.computeParallelCapability();
393
+ this.reachableAgentIds = this.resolveEntryReachability(
394
+ input.entryAgentId
395
+ );
396
+ }
397
+ if (
398
+ input.maxHandoffs != null &&
399
+ (!Number.isSafeInteger(input.maxHandoffs) || input.maxHandoffs < 0)
400
+ ) {
401
+ throw new Error(
402
+ 'MultiAgentGraph: maxHandoffs must be a non-negative safe integer'
403
+ );
404
+ }
405
+ const directDestinations = new Map<string, Set<string>>();
406
+ for (const edge of this.directEdges) {
407
+ if (edge.handoffScope != null)
408
+ throw new Error('handoffScope is only valid on handoff edges');
409
+ for (const source of Array.isArray(edge.from) ? edge.from : [edge.from]) {
410
+ const targets = directDestinations.get(source) ?? new Set<string>();
411
+ for (const target of Array.isArray(edge.to) ? edge.to : [edge.to])
412
+ targets.add(target);
413
+ directDestinations.set(source, targets);
414
+ }
415
+ }
416
+ this.handoffRouting = new HandoffRouting(
417
+ this.startingNodes.values().next().value ?? this.defaultAgentId,
418
+ input.maxHandoffs,
419
+ this.startingNodes.size > 1 ||
420
+ [...directDestinations.values()].some((targets) => targets.size > 1)
421
+ );
422
+ this.validateHandoffScopes();
382
423
  this.createHandoffTools();
383
424
  }
384
425
 
426
+ private validateHandoffScopes(): void {
427
+ const scopes = new Map<string, string>();
428
+ for (const edge of this.handoffEdges) {
429
+ const scope = edge.handoffScope ?? 'turn';
430
+ if (!['turn', 'conversation'].includes(scope))
431
+ throw new Error('Invalid handoffScope');
432
+ for (const source of Array.isArray(edge.from) ? edge.from : [edge.from]) {
433
+ const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
434
+ const targets =
435
+ edge.condition != null ? ['conditional_transfer'] : destinations;
436
+ for (const target of targets) {
437
+ const key = JSON.stringify([source, target]);
438
+ const previous = scopes.get(key);
439
+ if (previous != null && previous !== scope)
440
+ throw new Error(
441
+ 'Conflicting handoffScope for the same transfer tool'
442
+ );
443
+ scopes.set(key, scope);
444
+ }
445
+ }
446
+ }
447
+ }
448
+
449
+ private resolveEntryReachability(entryAgentId: string): Set<string> {
450
+ const reachable = new Set([entryAgentId]);
451
+ const direct = new Set(this.directEdges);
452
+ const outgoing = new Map<string, t.GraphEdge[]>();
453
+ const remaining = new Map<t.GraphEdge, number>();
454
+ for (const edge of this.edges) {
455
+ const sources = new Set(
456
+ Array.isArray(edge.from) ? edge.from : [edge.from]
457
+ );
458
+ remaining.set(edge, direct.has(edge) ? sources.size : 1);
459
+ for (const source of sources) {
460
+ const edges = outgoing.get(source) ?? [];
461
+ edges.push(edge);
462
+ outgoing.set(source, edges);
463
+ }
464
+ }
465
+ const queue = [entryAgentId];
466
+ for (let i = 0; i < queue.length; i++) {
467
+ for (const edge of outgoing.get(queue[i]) ?? []) {
468
+ const pending = remaining.get(edge) ?? 0;
469
+ if (pending === 0) continue;
470
+ remaining.set(edge, pending - 1);
471
+ if (pending > 1) continue;
472
+ for (const target of Array.isArray(edge.to) ? edge.to : [edge.to]) {
473
+ if (reachable.has(target)) continue;
474
+ reachable.add(target);
475
+ queue.push(target);
476
+ }
477
+ }
478
+ }
479
+ for (const edge of this.directEdges) {
480
+ const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
481
+ if (
482
+ sources.some((source) => reachable.has(source)) &&
483
+ !sources.every((source) => reachable.has(source))
484
+ ) {
485
+ throw new Error(
486
+ 'entryAgentId cannot satisfy a grouped direct edge prerequisite'
487
+ );
488
+ }
489
+ }
490
+ return reachable;
491
+ }
492
+
385
493
  /**
386
494
  * Fails fast when an edge references an agent that is not in
387
495
  * `agentContexts`. Without this check, the underlying LangGraph
@@ -762,6 +870,11 @@ export class MultiAgentGraph extends StandardGraph {
762
870
  destination = Array.isArray(result) ? result[0] : destinations[0];
763
871
  }
764
872
 
873
+ if (!destinations.includes(destination)) {
874
+ throw new Error(
875
+ `Conditional handoff selected undeclared destination "${destination}"`
876
+ );
877
+ }
765
878
  const handoffInstructions = getHandoffInstructions(
766
879
  input,
767
880
  promptKey,
@@ -789,7 +902,15 @@ export class MultiAgentGraph extends StandardGraph {
789
902
 
790
903
  return new Command({
791
904
  goto: destination,
792
- update: { messages: state.messages.concat(toolMessage) },
905
+ update: {
906
+ messages: state.messages.concat(toolMessage),
907
+ handoffRequest: {
908
+ sourceAgentId,
909
+ targetAgentId: destination,
910
+ toolCallId,
911
+ scope: edge.handoffScope ?? 'turn',
912
+ },
913
+ },
793
914
  graph: Command.PARENT,
794
915
  });
795
916
  },
@@ -926,7 +1047,15 @@ export class MultiAgentGraph extends StandardGraph {
926
1047
 
927
1048
  return new Command({
928
1049
  goto: destination,
929
- update: { messages: filteredMessages },
1050
+ update: {
1051
+ messages: filteredMessages,
1052
+ handoffRequest: {
1053
+ sourceAgentId,
1054
+ targetAgentId: destination,
1055
+ toolCallId,
1056
+ scope: edge.handoffScope ?? 'turn',
1057
+ },
1058
+ },
930
1059
  graph: Command.PARENT,
931
1060
  });
932
1061
  },
@@ -1274,6 +1403,7 @@ export class MultiAgentGraph extends StandardGraph {
1274
1403
  default: () => undefined,
1275
1404
  }),
1276
1405
  runStepState: this.createRunStepStateAnnotation(),
1406
+ handoffState: handoffStateAnnotation(),
1277
1407
  });
1278
1408
 
1279
1409
  const builder = new StateGraph(StateAnnotation);
@@ -1298,9 +1428,16 @@ export class MultiAgentGraph extends StandardGraph {
1298
1428
  const agentIds =
1299
1429
  summarizeOnlyAgentId != null
1300
1430
  ? [summarizeOnlyAgentId]
1301
- : [...this.agentContexts.keys()];
1431
+ : [...(this.reachableAgentIds ?? this.agentContexts.keys())];
1302
1432
  const handoffEdges = summarizeOnlyAgentId != null ? [] : this.handoffEdges;
1303
- const directEdges = summarizeOnlyAgentId != null ? [] : this.directEdges;
1433
+ const directEdges =
1434
+ summarizeOnlyAgentId != null
1435
+ ? []
1436
+ : this.directEdges.filter((edge) => {
1437
+ if (this.reachableAgentIds == null) return true;
1438
+ const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
1439
+ return sources.every((id) => this.reachableAgentIds!.has(id));
1440
+ });
1304
1441
 
1305
1442
  // Add all agents as complete subgraphs
1306
1443
  for (const agentId of agentIds) {
@@ -1348,6 +1485,7 @@ export class MultiAgentGraph extends StandardGraph {
1348
1485
  state: t.MultiAgentGraphState,
1349
1486
  config?: LangGraphRunnableConfig
1350
1487
  ): Promise<t.MultiAgentGraphState | Command> => {
1488
+ this.handoffRouting?.restore(state.handoffState);
1351
1489
  let result: t.MultiAgentGraphState;
1352
1490
  let inputMessages = state.messages;
1353
1491
  const agentContext = this.agentContexts.get(agentId);
@@ -0,0 +1,263 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { Annotation, Command, Send } from '@langchain/langgraph';
3
+ import type { BaseChannel, OverwriteValue } from '@langchain/langgraph';
4
+ import type { RunnableConfig } from '@langchain/core/runnables';
5
+ import type {
6
+ BaseGraphState,
7
+ HandoffOutcome,
8
+ HandoffState,
9
+ HandoffTransition,
10
+ } from '@/types/graph';
11
+
12
+ function isSupportedVersion(version: number): boolean {
13
+ return version === 1;
14
+ }
15
+
16
+ function isSend(value: string | Send): value is Send {
17
+ return typeof value !== 'string' && value.lg_name === 'Send';
18
+ }
19
+
20
+ export type HandoffRequest = Pick<
21
+ HandoffTransition,
22
+ 'sourceAgentId' | 'targetAgentId' | 'toolCallId' | 'scope'
23
+ >;
24
+
25
+ type HandoffUpdate = Partial<BaseGraphState> & {
26
+ handoffRequest?: HandoffRequest;
27
+ };
28
+
29
+ function sameTransition(a: HandoffTransition, b: HandoffTransition): boolean {
30
+ return (
31
+ a.id === b.id &&
32
+ a.sourceAgentId === b.sourceAgentId &&
33
+ a.targetAgentId === b.targetAgentId &&
34
+ a.toolCallId === b.toolCallId &&
35
+ a.scope === b.scope &&
36
+ a.depth === b.depth
37
+ );
38
+ }
39
+
40
+ /** Union by identity makes checkpoint replay idempotent and parallel merges commutative. */
41
+ export function mergeHandoffState(
42
+ current: HandoffState | undefined,
43
+ update: HandoffState | undefined
44
+ ): HandoffState | undefined {
45
+ if (update == null) return current;
46
+ if (current == null) return update;
47
+ if (current.executionId !== update.executionId) {
48
+ throw new Error('Cannot merge handoffs from different logical turns');
49
+ }
50
+ if (
51
+ current.entryAgentId !== update.entryAgentId ||
52
+ current.maxHandoffs !== update.maxHandoffs
53
+ ) {
54
+ throw new Error('Conflicting handoff checkpoint configuration');
55
+ }
56
+ const transitions = new Map(
57
+ current.transitions.map((item) => [item.id, item])
58
+ );
59
+ for (const transition of update.transitions) {
60
+ const existing = transitions.get(transition.id);
61
+ if (existing != null && !sameTransition(existing, transition)) {
62
+ throw new Error('Conflicting replayed handoff transition');
63
+ }
64
+ transitions.set(transition.id, transition);
65
+ }
66
+ return {
67
+ ...current,
68
+ parallel: current.parallel || update.parallel,
69
+ historyComplete:
70
+ current.historyComplete !== false && update.historyComplete !== false,
71
+ transitions: [...transitions.values()].sort(
72
+ (a, b) => a.depth - b.depth || a.id.localeCompare(b.id)
73
+ ),
74
+ };
75
+ }
76
+
77
+ export function handoffStateAnnotation(): BaseChannel<
78
+ HandoffState | undefined,
79
+ HandoffState | OverwriteValue<HandoffState | undefined> | undefined
80
+ > {
81
+ return Annotation<HandoffState | undefined>({
82
+ reducer: mergeHandoffState,
83
+ default: () => undefined,
84
+ });
85
+ }
86
+
87
+ export class HandoffLimitError extends Error {
88
+ constructor(readonly limit: number) {
89
+ super(`Agent handoff limit (${limit}) reached`);
90
+ this.name = 'HandoffLimitError';
91
+ }
92
+ }
93
+
94
+ /** One owner per graph, never shared with isolated child executions. */
95
+ export class HandoffRouting {
96
+ private state: HandoffState;
97
+
98
+ constructor(
99
+ private readonly entryAgentId: string,
100
+ private readonly maxHandoffs: number | undefined,
101
+ private readonly parallel: boolean
102
+ ) {
103
+ this.state = this.freshState();
104
+ }
105
+
106
+ private freshState(): HandoffState {
107
+ return {
108
+ version: 1,
109
+ executionId: nanoid(),
110
+ entryAgentId: this.entryAgentId,
111
+ ...(this.maxHandoffs == null ? {} : { maxHandoffs: this.maxHandoffs }),
112
+ transitions: [],
113
+ parallel: this.parallel,
114
+ };
115
+ }
116
+
117
+ start(): HandoffState {
118
+ this.state = this.freshState();
119
+ return this.snapshot();
120
+ }
121
+
122
+ resume(state: HandoffState | undefined): void {
123
+ if (state != null) {
124
+ this.restore(state);
125
+ return;
126
+ }
127
+ if (this.maxHandoffs != null) {
128
+ throw new Error(
129
+ 'Cannot enforce a handoff budget on a legacy checkpoint without routing state'
130
+ );
131
+ }
132
+ this.state = { ...this.state, historyComplete: false };
133
+ }
134
+
135
+ restore(state: HandoffState | undefined): void {
136
+ if (state == null) return;
137
+ if (!isSupportedVersion(state.version))
138
+ throw new Error('Unsupported handoff checkpoint version');
139
+ if (
140
+ state.entryAgentId !== this.entryAgentId ||
141
+ state.maxHandoffs !== this.maxHandoffs
142
+ ) {
143
+ throw new Error('Cannot resume with a different handoff entry or budget');
144
+ }
145
+ if (this.state.executionId !== state.executionId) {
146
+ if (this.state.transitions.length > 0) {
147
+ throw new Error('Cannot resume a different handoff execution');
148
+ }
149
+ this.state = {
150
+ ...state,
151
+ transitions: state.transitions.map((item) => ({ ...item })),
152
+ };
153
+ return;
154
+ }
155
+ this.state = mergeHandoffState(this.state, state)!;
156
+ }
157
+
158
+ snapshot(): HandoffState {
159
+ return {
160
+ ...this.state,
161
+ transitions: this.state.transitions.map((item) => ({ ...item })),
162
+ };
163
+ }
164
+
165
+ /** Called after tools settle but before Commands can schedule recipients. */
166
+ finalize(
167
+ commands: Command[],
168
+ input: BaseGraphState,
169
+ config: RunnableConfig
170
+ ): void {
171
+ this.restore(input.handoffState);
172
+ const updates: HandoffUpdate[] = [];
173
+ let parallel = this.state.parallel;
174
+ for (const command of commands) {
175
+ if (command.graph !== Command.PARENT) continue;
176
+ const sends = Array.isArray(command.goto)
177
+ ? command.goto.filter(isSend)
178
+ : [];
179
+ parallel ||= sends.length > 1;
180
+ if (sends.length > 0) {
181
+ for (const send of sends) updates.push(send.args as HandoffUpdate);
182
+ } else if (command.update != null) {
183
+ updates.push(command.update as HandoffUpdate);
184
+ }
185
+ }
186
+ const accepted = new Map(
187
+ this.state.transitions.map((item) => [item.id, item])
188
+ );
189
+ const requests = updates.filter((update) => update.handoffRequest != null);
190
+ if (requests.length === 0) return;
191
+ const depth =
192
+ input.handoffState?.transitions.length ?? this.state.transitions.length;
193
+ const lastMessage = input.messages.at(-1);
194
+ for (const update of requests) {
195
+ const request = update.handoffRequest!;
196
+ const id = JSON.stringify([
197
+ this.state.executionId,
198
+ config.configurable?.checkpoint_ns ?? '',
199
+ request.sourceAgentId,
200
+ lastMessage?.id ?? input.messages.length,
201
+ request.toolCallId,
202
+ ]);
203
+ const transition = { ...request, id, depth };
204
+ const existing = accepted.get(id);
205
+ if (
206
+ existing != null &&
207
+ (existing.targetAgentId !== transition.targetAgentId ||
208
+ existing.scope !== transition.scope)
209
+ )
210
+ throw new Error('Handoff replay changed its destination or scope');
211
+ accepted.set(id, existing ?? transition);
212
+ }
213
+ if (this.maxHandoffs != null && accepted.size > this.maxHandoffs) {
214
+ throw new HandoffLimitError(this.maxHandoffs);
215
+ }
216
+ this.state = {
217
+ ...this.state,
218
+ transitions: [...accepted.values()],
219
+ parallel,
220
+ };
221
+ for (const update of requests) delete update.handoffRequest;
222
+ for (const command of commands) {
223
+ if (command.graph !== Command.PARENT) continue;
224
+ command.update = {
225
+ ...(command.update as HandoffUpdate),
226
+ handoffState: this.snapshot(),
227
+ };
228
+ if (!Array.isArray(command.goto)) continue;
229
+ command.goto = command.goto.map((destination) =>
230
+ isSend(destination)
231
+ ? new Send(destination.node, {
232
+ ...destination.args,
233
+ handoffState: this.snapshot(),
234
+ })
235
+ : destination
236
+ );
237
+ }
238
+ }
239
+
240
+ outcome(reason?: string): HandoffOutcome {
241
+ const state = this.snapshot();
242
+ const base = {
243
+ executionId: state.executionId,
244
+ entryAgentId: state.entryAgentId,
245
+ transitions: state.transitions,
246
+ };
247
+ if (reason != null) return { ...base, status: 'incomplete', reason };
248
+ if (state.historyComplete === false)
249
+ return { ...base, status: 'incomplete', reason: 'legacy_checkpoint' };
250
+ const persistent = state.transitions.filter(
251
+ (item) => item.scope === 'conversation'
252
+ );
253
+ if (persistent.length === 0) return { ...base, status: 'unchanged' };
254
+ if (state.parallel) return { ...base, status: 'ambiguous' };
255
+ const last = persistent.reduce((a, b) => (a.depth > b.depth ? a : b));
256
+ return {
257
+ ...base,
258
+ status: 'candidate',
259
+ agentId: last.targetAgentId,
260
+ transitionId: last.id,
261
+ };
262
+ }
263
+ }
@@ -2,3 +2,5 @@ export * from './Graph';
2
2
  export * from './MultiAgentGraph';
3
3
  export * from './createGraph';
4
4
  export type * from './graphFactory';
5
+
6
+ export { HandoffLimitError } from './handoff';
package/src/run.ts CHANGED
@@ -111,6 +111,7 @@ import { initializeLangfuseTracing } from './instrumentation';
111
111
  import { seedRunInitialSessions } from '@/utils/toolSessions';
112
112
  import { getTraceIdSeed } from '@/langfuseRuntimeContext';
113
113
  import { resolveClientOptionsModel } from '@/llm/request';
114
+ import { HandoffLimitError } from '@/graphs/handoff';
114
115
  import { createGraph } from '@/graphs/createGraph';
115
116
  import { isFadingTier } from '@/messages/fading';
116
117
  import { resolveMaxSeals } from '@/llm/preempt';
@@ -291,6 +292,7 @@ function getInterruptHookSessionId(payload: unknown): string | undefined {
291
292
  type InterruptStateSnapshot = {
292
293
  config?: RunnableConfig;
293
294
  values?: {
295
+ handoffState?: t.HandoffState;
294
296
  messages?: BaseMessage[];
295
297
  runStepState?: t.RunStepResumeState;
296
298
  };
@@ -512,6 +514,7 @@ export class Run<_T extends t.BaseGraphState> {
512
514
  /** Distinguishes sibling forks started from the same explicit checkpoint. */
513
515
  private checkpointForkSeq = 0;
514
516
  private _haltedReason: string | undefined;
517
+ private _handoffOutcome?: t.HandoffOutcome;
515
518
 
516
519
  private constructor(config: Partial<t.RunConfig>) {
517
520
  const runId = config.runId ?? '';
@@ -683,7 +686,7 @@ export class Run<_T extends t.BaseGraphState> {
683
686
  private createMultiAgentGraph(
684
687
  config: t.MultiAgentGraphConfig
685
688
  ): t.CompiledStateWorkflow {
686
- const { agents, edges, compileOptions } = config;
689
+ const { agents, edges, compileOptions, entryAgentId, maxHandoffs } = config;
687
690
 
688
691
  const multiAgentGraph = createGraph({
689
692
  kind: 'multi-agent',
@@ -691,6 +694,8 @@ export class Run<_T extends t.BaseGraphState> {
691
694
  runId: this.id,
692
695
  agents,
693
696
  edges,
697
+ entryAgentId,
698
+ maxHandoffs,
694
699
  langfuse: this.langfuse,
695
700
  tokenCounter: this.tokenCounter,
696
701
  indexTokenCountMap: this.indexTokenCountMap,
@@ -1237,6 +1242,7 @@ export class Run<_T extends t.BaseGraphState> {
1237
1242
  }
1238
1243
  const graphRunnable = this.graphRunnable;
1239
1244
  const graph = this.Graph;
1245
+ this._handoffOutcome = undefined;
1240
1246
 
1241
1247
  /**
1242
1248
  * `Command` inputs (`Command({ resume, update?, goto? })`) are
@@ -1347,6 +1353,7 @@ export class Run<_T extends t.BaseGraphState> {
1347
1353
  checkpointId === '' ? 0 : ++this.checkpointForkSeq,
1348
1354
  ]);
1349
1355
  graph.resetValues(streamOptions?.keepContent, checkpointScope);
1356
+ graph.handoffRouting?.start();
1350
1357
  graph.startStopContinuationExecution(nanoid());
1351
1358
  }
1352
1359
  this._interrupt = undefined;
@@ -1482,9 +1489,18 @@ export class Run<_T extends t.BaseGraphState> {
1482
1489
 
1483
1490
  const consumeStream = async (): Promise<void> => {
1484
1491
  let streamInputs: t.IState | Command = inputs;
1485
- if (!isResume && this.hasCheckpointer) {
1492
+ if (!isResume && graph.handoffRouting != null) {
1486
1493
  streamInputs = {
1487
1494
  ...(inputs as t.IState),
1495
+ handoffState: graph.handoffRouting.snapshot(),
1496
+ };
1497
+ }
1498
+ if (!isResume && this.hasCheckpointer) {
1499
+ streamInputs = {
1500
+ ...(streamInputs as t.IState),
1501
+ ...(graph.handoffRouting == null
1502
+ ? {}
1503
+ : { handoffState: new Overwrite(graph.handoffRouting.snapshot()) }),
1488
1504
  runStepState: new Overwrite(graph.createRunStepResumeState()),
1489
1505
  } as unknown as t.IState;
1490
1506
  } else if (overwriteLegacyResumeState) {
@@ -1679,6 +1695,7 @@ export class Run<_T extends t.BaseGraphState> {
1679
1695
  ? injected
1680
1696
  : [...graph.messages, ...injected],
1681
1697
  runStepState: graph.createRunStepResumeState(),
1698
+ handoffState: graph.handoffRouting?.snapshot(),
1682
1699
  };
1683
1700
  streamConfig = completedSegmentConfig;
1684
1701
  continue;
@@ -1716,6 +1733,8 @@ export class Run<_T extends t.BaseGraphState> {
1716
1733
  } catch (err) {
1717
1734
  terminalAt = Date.now();
1718
1735
  streamThrew = true;
1736
+ if (err instanceof HandoffLimitError)
1737
+ this._haltedReason = 'handoff_limit';
1719
1738
  await langfuseHandler?.handleChainError(
1720
1739
  err instanceof Error ? err : new Error(String(err)),
1721
1740
  this.id
@@ -1860,6 +1879,13 @@ export class Run<_T extends t.BaseGraphState> {
1860
1879
  * `HumanInTheLoopConfig` JSDoc.
1861
1880
  */
1862
1881
  const awaitingResume = this.isAwaitingResume(streamThrew);
1882
+ this._handoffOutcome = graph.handoffRouting?.outcome(
1883
+ this.getHandoffIncompleteReason(
1884
+ streamThrew,
1885
+ awaitingResume,
1886
+ config.signal
1887
+ )
1888
+ );
1863
1889
  if (!this.skipCleanup && !awaitingResume) {
1864
1890
  this.Graph.clearHeavyState();
1865
1891
  }
@@ -1921,6 +1947,31 @@ export class Run<_T extends t.BaseGraphState> {
1921
1947
  return this._haltedReason;
1922
1948
  }
1923
1949
 
1950
+ private getHandoffIncompleteReason(
1951
+ streamThrew: boolean,
1952
+ awaitingResume: boolean,
1953
+ signal?: AbortSignal
1954
+ ): string | undefined {
1955
+ if (this._haltedReason != null) return this._haltedReason;
1956
+ if (signal?.aborted === true || this.Graph?.signal?.aborted === true) {
1957
+ return 'aborted';
1958
+ }
1959
+ if (streamThrew) return 'error';
1960
+ if (awaitingResume) return 'interrupted';
1961
+ if (this.Graph?.subagentScope === true) return 'subagent';
1962
+ return undefined;
1963
+ }
1964
+
1965
+ /** Execution evidence only. The host must authorize and durably commit a candidate. */
1966
+ getHandoffOutcome(): t.HandoffOutcome | undefined {
1967
+ const outcome = this._handoffOutcome;
1968
+ if (outcome == null) return undefined;
1969
+ return {
1970
+ ...outcome,
1971
+ transitions: outcome.transitions.map((item) => ({ ...item })),
1972
+ };
1973
+ }
1974
+
1924
1975
  /**
1925
1976
  * Resume a paused HITL run with the value the user (or whatever
1926
1977
  * decided the interrupt) supplied. The default `TResume` covers the
@@ -2134,6 +2185,7 @@ export class Run<_T extends t.BaseGraphState> {
2134
2185
  const snapshot = await workflow.getState(callerConfig as RunnableConfig, {
2135
2186
  subgraphs: true,
2136
2187
  });
2188
+ this.Graph?.handoffRouting?.resume(snapshot.values?.handoffState);
2137
2189
  const persistedInterrupt = getFirstPersistedInterrupt(snapshot);
2138
2190
  if (persistedInterrupt == null) {
2139
2191
  return;
@@ -974,8 +974,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
974
974
  * other's in-flight state.
975
975
  */
976
976
  private anonBatchCounter: number = 0;
977
+ private handoffRouting?: t.ToolNodeOptions['handoffRouting'];
977
978
 
978
979
  constructor({
980
+ handoffRouting,
979
981
  tools,
980
982
  toolMap,
981
983
  name,
@@ -1219,6 +1221,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1219
1221
  this.runLangfuse = runLangfuse;
1220
1222
  this.agentLangfuse = agentLangfuse;
1221
1223
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
1224
+ this.handoffRouting = handoffRouting;
1222
1225
  this.toolCallStepIds = toolCallStepIds;
1223
1226
  this.handleToolErrors = handleToolErrors ?? this.handleToolErrors;
1224
1227
  this.loadRuntimeTools = loadRuntimeTools;
@@ -3221,10 +3224,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3221
3224
 
3222
3225
  private retainCodeSessionInputsFromRequests(
3223
3226
  requests: Iterable<t.ToolCallRequest>,
3224
- baselineByRequestId: ReadonlyMap<
3225
- string,
3226
- ReadonlyMap<string, string>
3227
- >
3227
+ baselineByRequestId: ReadonlyMap<string, ReadonlyMap<string, string>>
3228
3228
  ): void {
3229
3229
  if (!this.sessions) {
3230
3230
  return;
@@ -5140,8 +5140,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
5140
5140
  call.id == null
5141
5141
  ? undefined
5142
5142
  : baseContext.resolvedArgsByCallId?.get(call.id);
5143
- const codeSessionBaseline =
5144
- baseContext.codeSessionBaselineByCallId?.get(call.id ?? '');
5143
+ const codeSessionBaseline = baseContext.codeSessionBaselineByCallId?.get(
5144
+ call.id ?? ''
5145
+ );
5145
5146
  const result: SettledDirectToolResult = {
5146
5147
  proposal: structuredClone({
5147
5148
  name: call.name,
@@ -6073,6 +6074,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
6073
6074
  if (replayBatchKey != null) {
6074
6075
  this.settledDirectResultsByBatch.delete(replayBatchKey);
6075
6076
  }
6077
+ if (!Array.isArray(input) && !this.isSendInput(input)) {
6078
+ this.handoffRouting?.finalize(
6079
+ combinedOutputs.filter(isCommand),
6080
+ input as t.BaseGraphState,
6081
+ config
6082
+ );
6083
+ }
6076
6084
  return combinedOutputs as T;
6077
6085
  }
6078
6086