@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.
- package/dist/cjs/graphs/Graph.cjs +13 -4
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +97 -5
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/graphs/handoff.cjs +195 -0
- package/dist/cjs/graphs/handoff.cjs.map +1 -0
- package/dist/cjs/graphs/index.cjs +1 -0
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/run.cjs +33 -3
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +4 -1
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +13 -4
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +97 -5
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/graphs/handoff.mjs +193 -0
- package/dist/esm/graphs/handoff.mjs.map +1 -0
- package/dist/esm/graphs/index.mjs +1 -0
- package/dist/esm/main.mjs +2 -1
- package/dist/esm/run.mjs +33 -3
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +4 -1
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +2 -0
- package/dist/types/graphs/MultiAgentGraph.d.ts +3 -0
- package/dist/types/graphs/handoff.d.ts +28 -0
- package/dist/types/graphs/index.d.ts +1 -0
- package/dist/types/run.d.ts +4 -0
- package/dist/types/tools/ToolNode.d.ts +2 -1
- package/dist/types/types/graph.d.ts +46 -1
- package/dist/types/types/run.d.ts +3 -1
- package/dist/types/types/tools.d.ts +3 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +8 -0
- package/src/graphs/MultiAgentGraph.ts +142 -4
- package/src/graphs/handoff.ts +263 -0
- package/src/graphs/index.ts +2 -0
- package/src/run.ts +54 -2
- package/src/tools/ToolNode.ts +14 -6
- package/src/types/graph.ts +44 -1
- package/src/types/run.ts +3 -1
- 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: {
|
|
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: {
|
|
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 =
|
|
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
|
+
}
|
package/src/graphs/index.ts
CHANGED
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 &&
|
|
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;
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|