@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.
- 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 +3 -0
- package/dist/cjs/messages/contextPruning.cjs +2 -1
- package/dist/cjs/messages/contextPruning.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +3 -3
- package/dist/cjs/messages/prune.cjs.map +1 -1
- 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/cjs/utils/toolContent.cjs +6 -6
- package/dist/cjs/utils/toolContent.cjs.map +1 -1
- package/dist/cjs/utils/truncation.cjs +14 -5
- package/dist/cjs/utils/truncation.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 +3 -2
- package/dist/esm/messages/contextPruning.mjs +2 -1
- package/dist/esm/messages/contextPruning.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +4 -4
- package/dist/esm/messages/prune.mjs.map +1 -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/esm/utils/toolContent.mjs +7 -7
- package/dist/esm/utils/toolContent.mjs.map +1 -1
- package/dist/esm/utils/truncation.mjs +14 -6
- package/dist/esm/utils/truncation.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/dist/types/utils/toolContent.d.ts +1 -1
- package/dist/types/utils/truncation.d.ts +2 -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/messages/contextPruning.ts +8 -2
- package/src/messages/prune.ts +15 -10
- 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
- package/src/utils/toolContent.ts +23 -7
- 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
|
+
}
|
package/src/graphs/index.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
148
|
+
const eligibilityContent =
|
|
149
|
+
params.canonicalMessages?.[i]?.content ?? content;
|
|
144
150
|
if (
|
|
145
151
|
getToolContentCharLength(eligibilityContent) <
|
|
146
152
|
settings.minPrunableToolChars
|
package/src/messages/prune.ts
CHANGED
|
@@ -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 +
|
|
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 +
|
|
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
|
|
1851
|
-
:
|
|
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 (
|
|
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 &&
|
|
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
|
|
package/src/types/graph.ts
CHANGED
|
@@ -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
|
/**
|
package/src/types/tools.ts
CHANGED
|
@@ -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. */
|