@librechat/agents 3.9.1 → 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 (65) 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 +3 -0
  9. package/dist/cjs/messages/contextPruning.cjs +2 -1
  10. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  11. package/dist/cjs/messages/prune.cjs +3 -3
  12. package/dist/cjs/messages/prune.cjs.map +1 -1
  13. package/dist/cjs/run.cjs +33 -3
  14. package/dist/cjs/run.cjs.map +1 -1
  15. package/dist/cjs/tools/ToolNode.cjs +4 -1
  16. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  17. package/dist/cjs/utils/toolContent.cjs +6 -6
  18. package/dist/cjs/utils/toolContent.cjs.map +1 -1
  19. package/dist/cjs/utils/truncation.cjs +14 -5
  20. package/dist/cjs/utils/truncation.cjs.map +1 -1
  21. package/dist/esm/graphs/Graph.mjs +13 -4
  22. package/dist/esm/graphs/Graph.mjs.map +1 -1
  23. package/dist/esm/graphs/MultiAgentGraph.mjs +97 -5
  24. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  25. package/dist/esm/graphs/handoff.mjs +193 -0
  26. package/dist/esm/graphs/handoff.mjs.map +1 -0
  27. package/dist/esm/graphs/index.mjs +1 -0
  28. package/dist/esm/main.mjs +3 -2
  29. package/dist/esm/messages/contextPruning.mjs +2 -1
  30. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  31. package/dist/esm/messages/prune.mjs +4 -4
  32. package/dist/esm/messages/prune.mjs.map +1 -1
  33. package/dist/esm/run.mjs +33 -3
  34. package/dist/esm/run.mjs.map +1 -1
  35. package/dist/esm/tools/ToolNode.mjs +4 -1
  36. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  37. package/dist/esm/utils/toolContent.mjs +7 -7
  38. package/dist/esm/utils/toolContent.mjs.map +1 -1
  39. package/dist/esm/utils/truncation.mjs +14 -6
  40. package/dist/esm/utils/truncation.mjs.map +1 -1
  41. package/dist/types/graphs/Graph.d.ts +2 -0
  42. package/dist/types/graphs/MultiAgentGraph.d.ts +3 -0
  43. package/dist/types/graphs/handoff.d.ts +28 -0
  44. package/dist/types/graphs/index.d.ts +1 -0
  45. package/dist/types/run.d.ts +4 -0
  46. package/dist/types/tools/ToolNode.d.ts +2 -1
  47. package/dist/types/types/graph.d.ts +46 -1
  48. package/dist/types/types/run.d.ts +3 -1
  49. package/dist/types/types/tools.d.ts +3 -0
  50. package/dist/types/utils/toolContent.d.ts +1 -1
  51. package/dist/types/utils/truncation.d.ts +2 -0
  52. package/package.json +1 -1
  53. package/src/graphs/Graph.ts +8 -0
  54. package/src/graphs/MultiAgentGraph.ts +142 -4
  55. package/src/graphs/handoff.ts +263 -0
  56. package/src/graphs/index.ts +2 -0
  57. package/src/messages/contextPruning.ts +8 -2
  58. package/src/messages/prune.ts +15 -10
  59. package/src/run.ts +54 -2
  60. package/src/tools/ToolNode.ts +14 -6
  61. package/src/types/graph.ts +44 -1
  62. package/src/types/run.ts +3 -1
  63. package/src/types/tools.ts +3 -0
  64. package/src/utils/toolContent.ts +23 -7
  65. package/src/utils/truncation.ts +31 -6
@@ -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';
@@ -26,6 +26,7 @@ import {
26
26
  serializeToolContentBounded,
27
27
  } from '@/utils/toolContent';
28
28
  import { resolveContextPruningSettings } from './contextPruningSettings';
29
+ import { sliceWithoutSplittingSurrogates } from '@/utils/truncation';
29
30
 
30
31
  /**
31
32
  * Applies head+tail soft-trim to tool result content.
@@ -36,7 +37,11 @@ function softTrimContent(
36
37
  ): string {
37
38
  const { headChars, tailChars } = settings;
38
39
  const indicator = `\n\n… [soft-trimmed: ${content.length} chars → ${headChars + tailChars} chars, middle removed] …\n\n`;
39
- return content.slice(0, headChars) + indicator + content.slice(-tailChars);
40
+ return (
41
+ sliceWithoutSplittingSurrogates(content, 0, headChars) +
42
+ indicator +
43
+ sliceWithoutSplittingSurrogates(content, -tailChars)
44
+ );
40
45
  }
41
46
 
42
47
  export interface ContextPruningResult {
@@ -140,7 +145,8 @@ export function applyContextPruning(params: {
140
145
  }
141
146
  const content = message.content;
142
147
  const contentLength = getToolContentCharLength(content);
143
- const eligibilityContent = params.canonicalMessages?.[i]?.content ?? content;
148
+ const eligibilityContent =
149
+ params.canonicalMessages?.[i]?.content ?? content;
144
150
  if (
145
151
  getToolContentCharLength(eligibilityContent) <
146
152
  settings.minPrunableToolChars
@@ -19,6 +19,7 @@ import {
19
19
  HARD_MAX_TOOL_CALL_INPUT_CHARS,
20
20
  HARD_MAX_TOOL_RESULT_CHARS,
21
21
  MIN_JSON_VALUE_CHARS,
22
+ sliceWithoutSplittingSurrogates,
22
23
  calculateMaxToolCallInputChars,
23
24
  calculateMaxToolResultChars,
24
25
  } from '@/utils/truncation';
@@ -1591,7 +1592,8 @@ function createBoundedTruncationValue(
1591
1592
  const next = Math.ceil((low + high) / 2);
1592
1593
  const candidate = {
1593
1594
  _truncated:
1594
- TOOL_INPUT_TRUNCATION_MARKER + canonicalPrefix.slice(0, next),
1595
+ TOOL_INPUT_TRUNCATION_MARKER +
1596
+ sliceWithoutSplittingSurrogates(canonicalPrefix, 0, next),
1595
1597
  _originalChars: originalChars,
1596
1598
  };
1597
1599
  if (JSON.stringify(candidate).length <= normalizedMaxChars) {
@@ -1604,7 +1606,8 @@ function createBoundedTruncationValue(
1604
1606
  // Keep the marker separate from a pure canonical prefix so another,
1605
1607
  // slightly smaller cap can be derived without nesting the envelope.
1606
1608
  _truncated:
1607
- TOOL_INPUT_TRUNCATION_MARKER + canonicalPrefix.slice(0, low),
1609
+ TOOL_INPUT_TRUNCATION_MARKER +
1610
+ sliceWithoutSplittingSurrogates(canonicalPrefix, 0, low),
1608
1611
  _originalChars: originalChars,
1609
1612
  };
1610
1613
  }
@@ -1847,8 +1850,12 @@ function projectStringInputWithinLimit(
1847
1850
  return {
1848
1851
  value:
1849
1852
  marker.length >= normalizedMaxChars
1850
- ? prefix.slice(0, normalizedMaxChars)
1851
- : prefix.slice(0, normalizedMaxChars - marker.length) + marker,
1853
+ ? sliceWithoutSplittingSurrogates(prefix, 0, normalizedMaxChars)
1854
+ : sliceWithoutSplittingSurrogates(
1855
+ prefix,
1856
+ 0,
1857
+ normalizedMaxChars - marker.length
1858
+ ) + marker,
1852
1859
  changed: true,
1853
1860
  };
1854
1861
  }
@@ -2366,7 +2373,9 @@ function applyToolCallInputCaps(params: {
2366
2373
  additionalKwargsChanges
2367
2374
  );
2368
2375
  }
2369
- if (capped.response_metadata.output !== canonical.response_metadata.output) {
2376
+ if (
2377
+ capped.response_metadata.output !== canonical.response_metadata.output
2378
+ ) {
2370
2379
  changes.response_metadata = cloneWithProjectedProperties(
2371
2380
  current.response_metadata,
2372
2381
  { output: capped.response_metadata.output }
@@ -2523,11 +2532,7 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
2523
2532
  originalToolContent.clear();
2524
2533
  originalToolContentSize = 0;
2525
2534
  }
2526
- for (
2527
- let i = toolExchangeWidthThrough;
2528
- i < canonicalMessages.length;
2529
- i++
2530
- ) {
2535
+ for (let i = toolExchangeWidthThrough; i < canonicalMessages.length; i++) {
2531
2536
  maxToolExchangeWidth = Math.max(
2532
2537
  maxToolExchangeWidth,
2533
2538
  getToolCallIds(canonicalMessages[i]).size
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
 
@@ -78,9 +78,45 @@ export type SystemCallbacks = {
78
78
  : never;
79
79
  };
80
80
 
81
+ /** An executed, SDK-generated handoff, identified independently of stream events. */
82
+ export interface HandoffTransition {
83
+ id: string;
84
+ sourceAgentId: string;
85
+ targetAgentId: string;
86
+ toolCallId: string;
87
+ scope: 'turn' | 'conversation';
88
+ /** Causal order: number of handoffs visible at this batch's input. */
89
+ depth: number;
90
+ }
91
+
92
+ /** Versioned graph checkpoint payload, scoped to one logical user turn. */
93
+ export interface HandoffState {
94
+ version: 1;
95
+ executionId: string;
96
+ entryAgentId: string;
97
+ maxHandoffs?: number;
98
+ transitions: HandoffTransition[];
99
+ parallel: boolean;
100
+ /** False when resuming an older checkpoint without complete routing provenance. */
101
+ historyComplete?: boolean;
102
+ }
103
+
104
+ /** Only a candidate from a completed top-level run may be promoted by a host. */
105
+ export type HandoffOutcome = {
106
+ executionId: string;
107
+ entryAgentId: string;
108
+ transitions: readonly HandoffTransition[];
109
+ } & (
110
+ | { status: 'candidate'; agentId: string; transitionId: string }
111
+ | { status: 'unchanged' | 'ambiguous' }
112
+ | { status: 'incomplete'; reason: string }
113
+ );
114
+
81
115
  export type BaseGraphState = {
82
116
  messages: BaseMessage[];
83
117
  runStepState?: RunStepResumeState;
118
+ /** SDK-owned routing state; hosts must not synthesize it from messages. */
119
+ handoffState?: HandoffState;
84
120
  /**
85
121
  * The summary a summarize-only run produced. Kept in state because such a
86
122
  * run has no assistant reply: trace roots report it as the run's output
@@ -434,6 +470,8 @@ export type GraphEdge = {
434
470
  condition?: (state: BaseGraphState) => boolean | string | string[];
435
471
  /** 'handoff' creates tools for dynamic routing, 'direct' creates direct edges, which also allow parallel execution */
436
472
  edgeType?: 'handoff' | 'direct';
473
+ /** Host may promote the destination after successful top-level completion. */
474
+ handoffScope?: 'turn' | 'conversation';
437
475
  /**
438
476
  * For direct edges: Optional prompt to add when transitioning through this edge.
439
477
  * String prompts can include variables like {results} which will be replaced with
@@ -463,15 +501,20 @@ export type GraphEdge = {
463
501
 
464
502
  export type GraphSubagentEdge = Omit<
465
503
  GraphEdge,
466
- 'edgeType' | 'condition' | 'promptKey'
504
+ 'edgeType' | 'condition' | 'promptKey' | 'handoffScope'
467
505
  > & {
468
506
  edgeType: 'direct';
469
507
  condition?: never;
470
508
  promptKey?: never;
509
+ handoffScope?: never;
471
510
  };
472
511
 
473
512
  export type MultiAgentGraphInput = StandardGraphInput & {
474
513
  edges: GraphEdge[];
514
+ /** Explicit fresh-turn entry; absent preserves topology-inferred entry points. */
515
+ entryAgentId?: string;
516
+ /** Shared logical-turn handoff cap. Zero forbids handoffs; absent uses only recursion limits. */
517
+ maxHandoffs?: number;
475
518
  /** Captures the designated member's final AI turn in graph state. */
476
519
  resultAgentId?: string;
477
520
  /** Optional per-member Pregel budget when the outer graph has its own topology budget. */
package/src/types/run.ts CHANGED
@@ -115,11 +115,13 @@ export type MultiAgentGraphConfig = {
115
115
  compileOptions?: g.CompileOptions;
116
116
  agents: g.AgentInputs[];
117
117
  edges: g.GraphEdge[];
118
+ entryAgentId?: string;
119
+ maxHandoffs?: number;
118
120
  };
119
121
 
120
122
  export type StandardGraphConfig = Omit<
121
123
  MultiAgentGraphConfig,
122
- 'edges' | 'type'
124
+ 'edges' | 'type' | 'entryAgentId' | 'maxHandoffs'
123
125
  > & { type?: 'standard'; signal?: AbortSignal };
124
126
 
125
127
  /**
@@ -14,6 +14,7 @@ import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
14
14
  import type { LangfuseConfig, SubagentExecutionContext } from './graph';
15
15
  import type { PreparedSubagents } from '@/tools/preparedSubagents';
16
16
  import type { RunBreakerScope } from '@/llm/streamLimits';
17
+ import type { HandoffRouting } from '@/graphs/handoff';
17
18
  import type { HumanInTheLoopConfig } from './hitl';
18
19
  import type { HookRegistry } from '@/hooks';
19
20
 
@@ -117,6 +118,8 @@ export type EagerEventToolCallChunkState = {
117
118
  };
118
119
 
119
120
  export type ToolNodeOptions = {
121
+ /** @internal Admission shared by tool nodes belonging to one multi-agent graph. */
122
+ handoffRouting?: Pick<HandoffRouting, 'finalize'>;
120
123
  name?: string;
121
124
  tags?: string[];
122
125
  /** Enables LangChain/LangGraph tracing for this ToolNode. Defaults to false. */