@threadplane/langgraph 0.0.57 → 0.0.59
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.
|
@@ -278,6 +278,7 @@ class SubagentTracker {
|
|
|
278
278
|
this.subagents.set(id, {
|
|
279
279
|
id,
|
|
280
280
|
generation: existing?.generation ?? createSubagentGeneration(),
|
|
281
|
+
kind: 'tool',
|
|
281
282
|
status: existing?.status ?? 'pending',
|
|
282
283
|
toolCall: {
|
|
283
284
|
id,
|
|
@@ -328,14 +329,14 @@ class SubagentTracker {
|
|
|
328
329
|
return toolCallId;
|
|
329
330
|
};
|
|
330
331
|
for (const [toolCallId, subagent] of this.subagents) {
|
|
331
|
-
if (mapped.has(toolCallId))
|
|
332
|
+
if (subagent.kind !== 'tool' || mapped.has(toolCallId))
|
|
332
333
|
continue;
|
|
333
334
|
if (subagent.toolCall.args['description'] === description) {
|
|
334
335
|
return establish(toolCallId);
|
|
335
336
|
}
|
|
336
337
|
}
|
|
337
338
|
for (const [toolCallId, subagent] of this.subagents) {
|
|
338
|
-
if (mapped.has(toolCallId))
|
|
339
|
+
if (subagent.kind !== 'tool' || mapped.has(toolCallId))
|
|
339
340
|
continue;
|
|
340
341
|
const subagentDescription = subagent.toolCall.args['description'];
|
|
341
342
|
if (typeof subagentDescription !== 'string' || !subagentDescription)
|
|
@@ -344,7 +345,11 @@ class SubagentTracker {
|
|
|
344
345
|
return establish(toolCallId);
|
|
345
346
|
}
|
|
346
347
|
}
|
|
348
|
+
// Last-resort fallback — tool children only. A subgraph child is keyed by
|
|
349
|
+
// its own namespace and must never absorb an unrelated child's events.
|
|
347
350
|
for (const [toolCallId, subagent] of this.subagents) {
|
|
351
|
+
if (subagent.kind !== 'tool')
|
|
352
|
+
continue;
|
|
348
353
|
if (!mapped.has(toolCallId) && (subagent.status === 'pending' || subagent.status === 'running')) {
|
|
349
354
|
return establish(toolCallId);
|
|
350
355
|
}
|
|
@@ -372,6 +377,46 @@ class SubagentTracker {
|
|
|
372
377
|
});
|
|
373
378
|
this.onSubagentChange?.();
|
|
374
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Register a plain-subgraph child stream on its first namespaced event.
|
|
382
|
+
*
|
|
383
|
+
* Unlike tool children — announced ahead of time by the parent's tool call —
|
|
384
|
+
* a compiled child added as a plain node has no announcement: its existence
|
|
385
|
+
* is learned from the first event carrying its namespace. It starts
|
|
386
|
+
* 'running' because by the time we see an event, it is.
|
|
387
|
+
*/
|
|
388
|
+
ensureSubgraphStream(key, name) {
|
|
389
|
+
if (this.subagents.has(key))
|
|
390
|
+
return;
|
|
391
|
+
this.subagents.set(key, {
|
|
392
|
+
id: key,
|
|
393
|
+
generation: createSubagentGeneration(),
|
|
394
|
+
kind: 'subgraph',
|
|
395
|
+
status: 'running',
|
|
396
|
+
toolCall: { id: key, name, args: {} },
|
|
397
|
+
values: {},
|
|
398
|
+
messages: [],
|
|
399
|
+
});
|
|
400
|
+
this.onSubagentChange?.();
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Settle still-running subgraph children when the run reaches a terminal
|
|
404
|
+
* outcome. Tool children settle through their tool result
|
|
405
|
+
* (`processToolMessage`); subgraph children have no result message, so the
|
|
406
|
+
* run's own settle is their completion signal. Paused/interrupted runs must
|
|
407
|
+
* NOT call this — a child can resume with the thread.
|
|
408
|
+
*/
|
|
409
|
+
settleRunningSubgraphs(outcome) {
|
|
410
|
+
let changed = false;
|
|
411
|
+
for (const [key, subagent] of this.subagents) {
|
|
412
|
+
if (subagent.kind !== 'subgraph' || subagent.status !== 'running')
|
|
413
|
+
continue;
|
|
414
|
+
this.subagents.set(key, { ...subagent, status: outcome });
|
|
415
|
+
changed = true;
|
|
416
|
+
}
|
|
417
|
+
if (changed)
|
|
418
|
+
this.onSubagentChange?.();
|
|
419
|
+
}
|
|
375
420
|
updateSubagentValues(namespaceId, values) {
|
|
376
421
|
const toolCallId = this.resolveToolCallId(namespaceId);
|
|
377
422
|
const subagent = this.subagents.get(toolCallId);
|
|
@@ -437,12 +482,40 @@ class SubagentTracker {
|
|
|
437
482
|
return this.namespaceToToolCallId.get(namespaceId) ?? namespaceId;
|
|
438
483
|
}
|
|
439
484
|
}
|
|
440
|
-
|
|
485
|
+
/**
|
|
486
|
+
* True when a stream event belongs to a child graph rather than the parent —
|
|
487
|
+
* i.e. it carries any namespace at all. This is the single classification
|
|
488
|
+
* question; which child owns the event is a separate (attribution) question.
|
|
489
|
+
*
|
|
490
|
+
* Kept consistent with the terminal-evidence guard, which has always refused
|
|
491
|
+
* ANY namespaced event as proof the parent run finished.
|
|
492
|
+
*/
|
|
493
|
+
function isChildNamespace(namespace) {
|
|
441
494
|
if (!namespace)
|
|
442
495
|
return false;
|
|
443
496
|
if (typeof namespace === 'string')
|
|
444
|
-
return namespace.
|
|
445
|
-
return namespace.
|
|
497
|
+
return namespace.length > 0;
|
|
498
|
+
return namespace.length > 0;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Derive a child stream's identity from an event namespace.
|
|
502
|
+
*
|
|
503
|
+
* `tools:<id>` segments identify a tool-dispatched child by its tool-call id.
|
|
504
|
+
* Any other segment (e.g. `research:<uuid>` from a compiled graph added with
|
|
505
|
+
* `add_node`) identifies a plain subgraph child: the full segment is the key
|
|
506
|
+
* (unique per invocation) and the part before the first ':' is the node name.
|
|
507
|
+
*/
|
|
508
|
+
function childStreamRefFromNamespace(namespace) {
|
|
509
|
+
for (const segment of namespace) {
|
|
510
|
+
if (segment.startsWith('tools:')) {
|
|
511
|
+
return { key: segment.slice(6), name: '', kind: 'tool' };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const first = namespace[0];
|
|
515
|
+
if (!first)
|
|
516
|
+
return undefined;
|
|
517
|
+
const colon = first.indexOf(':');
|
|
518
|
+
return { key: first, name: colon > 0 ? first.slice(0, colon) : first, kind: 'subgraph' };
|
|
446
519
|
}
|
|
447
520
|
function extractToolCallIdFromNamespace(namespace) {
|
|
448
521
|
if (!namespace)
|
|
@@ -649,6 +722,13 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
649
722
|
continue;
|
|
650
723
|
finalizeMessage(attempt, id, outcome);
|
|
651
724
|
}
|
|
725
|
+
// Subgraph children have no tool result to settle them; the run's own
|
|
726
|
+
// terminal outcome is their completion signal. Paused/interrupted runs
|
|
727
|
+
// are excluded — a child can resume with the thread.
|
|
728
|
+
if (outcome === 'success' || outcome === 'error' || outcome === 'aborted') {
|
|
729
|
+
subagentManager.settleRunningSubgraphs(outcome === 'success' ? 'complete' : 'error');
|
|
730
|
+
publishSubagents();
|
|
731
|
+
}
|
|
652
732
|
}
|
|
653
733
|
function finishOutcome(attempt) {
|
|
654
734
|
return attempt.terminalOutcome
|
|
@@ -750,6 +830,8 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
750
830
|
if (resetState) {
|
|
751
831
|
invalidateQueueDrain();
|
|
752
832
|
abortController?.abort();
|
|
833
|
+
lastPayload = null;
|
|
834
|
+
lastOptions = undefined;
|
|
753
835
|
}
|
|
754
836
|
currentThreadId = id;
|
|
755
837
|
if (resetState) {
|
|
@@ -1011,7 +1093,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1011
1093
|
subjects.toolProgress$.next([]);
|
|
1012
1094
|
toolProgressMap.clear();
|
|
1013
1095
|
canonicalMessageIds.clear();
|
|
1014
|
-
lastPayload = payload;
|
|
1096
|
+
lastPayload = payload ?? null;
|
|
1015
1097
|
lastOptions = opts;
|
|
1016
1098
|
// Tracks whether at least one stream event has been processed this run.
|
|
1017
1099
|
// Used to distinguish a mid-stream network interruption (kind:'interrupted')
|
|
@@ -1044,10 +1126,10 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1044
1126
|
processEvent(event);
|
|
1045
1127
|
}
|
|
1046
1128
|
if (!isCurrentExecution(controller, attempt))
|
|
1047
|
-
return;
|
|
1129
|
+
return finishOutcome(attempt);
|
|
1048
1130
|
const outcome = await finalizeClosedAttempt(controller, attempt);
|
|
1049
1131
|
if (outcome === null)
|
|
1050
|
-
return;
|
|
1132
|
+
return finishOutcome(attempt);
|
|
1051
1133
|
if (!controller.signal.aborted) {
|
|
1052
1134
|
if (outcome !== 'error') {
|
|
1053
1135
|
subjects.status$.next(ResourceStatus.Resolved);
|
|
@@ -1058,12 +1140,13 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1058
1140
|
durationMs: Date.now() - startedAt,
|
|
1059
1141
|
});
|
|
1060
1142
|
}
|
|
1143
|
+
return outcome;
|
|
1061
1144
|
}
|
|
1062
1145
|
catch (err) {
|
|
1063
1146
|
if (!isCurrentExecution(controller, attempt))
|
|
1064
|
-
return;
|
|
1147
|
+
return finishOutcome(attempt);
|
|
1065
1148
|
if (attempt.terminalOutcome)
|
|
1066
|
-
return;
|
|
1149
|
+
return attempt.terminalOutcome;
|
|
1067
1150
|
if (isAbortError(err) && userAbortedControllers.has(controller)) {
|
|
1068
1151
|
finalizeAttempt(attempt, 'aborted');
|
|
1069
1152
|
// User explicitly called stop() — treat as graceful idle, not an error.
|
|
@@ -1095,6 +1178,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1095
1178
|
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
1096
1179
|
});
|
|
1097
1180
|
}
|
|
1181
|
+
return finishOutcome(attempt);
|
|
1098
1182
|
}
|
|
1099
1183
|
finally {
|
|
1100
1184
|
if (abortController === controller)
|
|
@@ -1114,17 +1198,23 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1114
1198
|
const normalized = options.toMessage
|
|
1115
1199
|
? msgs.map(options.toMessage)
|
|
1116
1200
|
: msgs;
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1201
|
+
// Any namespaced message event is child content. It feeds the child's
|
|
1202
|
+
// stream and never merges into the parent transcript — the parent
|
|
1203
|
+
// transcript is what the parent graph says. Shared-state children still
|
|
1204
|
+
// surface at settle through the authoritative top-level `values` sync.
|
|
1205
|
+
if (isChildNamespace(namespace)) {
|
|
1206
|
+
const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
|
|
1207
|
+
if (child) {
|
|
1208
|
+
if (child.kind === 'subgraph') {
|
|
1209
|
+
subagentManager.ensureSubgraphStream(child.key, child.name);
|
|
1210
|
+
}
|
|
1120
1211
|
for (const msg of normalized) {
|
|
1121
|
-
subagentManager.addMessageToSubagent(
|
|
1212
|
+
subagentManager.addMessageToSubagent(child.key, msg);
|
|
1122
1213
|
}
|
|
1123
1214
|
publishSubagents();
|
|
1124
1215
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
}
|
|
1216
|
+
storeMessageMetadata(normalized, event);
|
|
1217
|
+
return;
|
|
1128
1218
|
}
|
|
1129
1219
|
// Partial and message-tuple events are incremental. Merge them by id
|
|
1130
1220
|
// so optimistic human messages and earlier tool messages are preserved.
|
|
@@ -1139,7 +1229,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1139
1229
|
const merged = mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds, affectedMessageIds, activeAttempt?.currentAssistantMessageId !== undefined
|
|
1140
1230
|
&& activeAttempt.currentStepHasTerminalEvidence !== true);
|
|
1141
1231
|
subjects.messages$.next(merged);
|
|
1142
|
-
|
|
1232
|
+
{
|
|
1143
1233
|
trackAssistantMessages(merged.filter(message => {
|
|
1144
1234
|
const id = message['id'];
|
|
1145
1235
|
return typeof id === 'string' && affectedMessageIds.has(id);
|
|
@@ -1162,7 +1252,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1162
1252
|
const affectedMessageIds = new Set();
|
|
1163
1253
|
const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds);
|
|
1164
1254
|
subjects.messages$.next(preserved);
|
|
1165
|
-
|
|
1255
|
+
{
|
|
1166
1256
|
trackAssistantMessages(preserved.filter(message => {
|
|
1167
1257
|
const id = message['id'];
|
|
1168
1258
|
return typeof id === 'string' && affectedMessageIds.has(id);
|
|
@@ -1181,8 +1271,11 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1181
1271
|
switch (baseType) {
|
|
1182
1272
|
case 'values': {
|
|
1183
1273
|
const vals = extractEventData(event);
|
|
1184
|
-
if (
|
|
1185
|
-
|
|
1274
|
+
if (isChildNamespace(namespace)) {
|
|
1275
|
+
// A child's state must not clobber the parent's `values$` — route it
|
|
1276
|
+
// to the child stream and stop.
|
|
1277
|
+
if (isRecord$1(vals))
|
|
1278
|
+
updateSubagentValues(namespace, vals);
|
|
1186
1279
|
break;
|
|
1187
1280
|
}
|
|
1188
1281
|
if ((namespace?.length ?? 0) === 0) {
|
|
@@ -1248,7 +1341,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1248
1341
|
}
|
|
1249
1342
|
case 'updates': {
|
|
1250
1343
|
const upd = extractEventData(event);
|
|
1251
|
-
if (
|
|
1344
|
+
if (isChildNamespace(namespace)) {
|
|
1345
|
+
// A child's updates must not spread-merge into the parent's
|
|
1346
|
+
// `values$` — they only mark the child stream running.
|
|
1252
1347
|
markSubagentRunning(namespace);
|
|
1253
1348
|
break;
|
|
1254
1349
|
}
|
|
@@ -1311,24 +1406,34 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1311
1406
|
publishSubagents();
|
|
1312
1407
|
}
|
|
1313
1408
|
function updateSubagentValues(namespace, values) {
|
|
1314
|
-
const
|
|
1315
|
-
if (!
|
|
1409
|
+
const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
|
|
1410
|
+
if (!child)
|
|
1316
1411
|
return;
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1412
|
+
if (child.kind === 'tool') {
|
|
1413
|
+
// Attribution ladder applies to tool children only: their namespace id
|
|
1414
|
+
// may need mapping onto a registered tool call.
|
|
1415
|
+
const messages = values['messages'];
|
|
1416
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
1417
|
+
const first = messages[0];
|
|
1418
|
+
if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
|
|
1419
|
+
subagentManager.matchSubgraphToSubagent(child.key, first['content']);
|
|
1420
|
+
}
|
|
1322
1421
|
}
|
|
1323
1422
|
}
|
|
1324
|
-
|
|
1423
|
+
else {
|
|
1424
|
+
subagentManager.ensureSubgraphStream(child.key, child.name);
|
|
1425
|
+
}
|
|
1426
|
+
subagentManager.updateSubagentValues(child.key, values);
|
|
1325
1427
|
publishSubagents();
|
|
1326
1428
|
}
|
|
1327
1429
|
function markSubagentRunning(namespace) {
|
|
1328
|
-
const
|
|
1329
|
-
if (!
|
|
1430
|
+
const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
|
|
1431
|
+
if (!child)
|
|
1330
1432
|
return;
|
|
1331
|
-
|
|
1433
|
+
if (child.kind === 'subgraph') {
|
|
1434
|
+
subagentManager.ensureSubgraphStream(child.key, child.name);
|
|
1435
|
+
}
|
|
1436
|
+
subagentManager.markRunningFromNamespace(child.key, namespace);
|
|
1332
1437
|
publishSubagents();
|
|
1333
1438
|
}
|
|
1334
1439
|
function publishSubagents() {
|
|
@@ -1407,9 +1512,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1407
1512
|
submit: async (payload, opts) => {
|
|
1408
1513
|
if (opts?.multitaskStrategy === 'enqueue' && subjects.status$.value === ResourceStatus.Loading) {
|
|
1409
1514
|
await enqueueRun(payload, opts);
|
|
1410
|
-
return;
|
|
1515
|
+
return 'success';
|
|
1411
1516
|
}
|
|
1412
|
-
|
|
1517
|
+
return runStream(payload, opts);
|
|
1413
1518
|
},
|
|
1414
1519
|
stop: async () => {
|
|
1415
1520
|
invalidateQueueDrain();
|
|
@@ -1499,9 +1604,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1499
1604
|
}
|
|
1500
1605
|
},
|
|
1501
1606
|
resubmitLast: async () => {
|
|
1502
|
-
if (lastPayload
|
|
1503
|
-
|
|
1504
|
-
|
|
1607
|
+
if (lastPayload === null)
|
|
1608
|
+
return 'not-started';
|
|
1609
|
+
return runStream(lastPayload, lastOptions, 'resubmit');
|
|
1505
1610
|
},
|
|
1506
1611
|
getReasoningDurationMs: (id) => {
|
|
1507
1612
|
const entry = reasoningTimingMap.get(id);
|
|
@@ -2126,9 +2231,13 @@ function toSubagentRefs(subagents) {
|
|
|
2126
2231
|
subagents.forEach((subagent, key) => {
|
|
2127
2232
|
refs.set(key, {
|
|
2128
2233
|
toolCallId: subagent.id,
|
|
2234
|
+
// Tool children are named by their `subagent_type` arg; subgraph
|
|
2235
|
+
// children by their node name (stored as the synthetic toolCall name).
|
|
2129
2236
|
name: typeof subagent.toolCall.args['subagent_type'] === 'string'
|
|
2130
2237
|
? subagent.toolCall.args['subagent_type']
|
|
2131
|
-
:
|
|
2238
|
+
: subagent.kind === 'subgraph'
|
|
2239
|
+
? subagent.toolCall.name
|
|
2240
|
+
: undefined,
|
|
2132
2241
|
status: signal(subagent.status),
|
|
2133
2242
|
values: signal(subagent.values),
|
|
2134
2243
|
messages: signal(subagent.messages),
|
|
@@ -2364,6 +2473,24 @@ function mergeClientTools(payload, catalog) {
|
|
|
2364
2473
|
return payload;
|
|
2365
2474
|
return { ...payload, client_tools: catalog };
|
|
2366
2475
|
}
|
|
2476
|
+
/**
|
|
2477
|
+
* Merge A2UI client capabilities into a run payload under the
|
|
2478
|
+
* `a2ui_client_capabilities` state key. Same payload semantics as
|
|
2479
|
+
* {@link mergeClientTools}: null/undefined payloads (command resumes,
|
|
2480
|
+
* regenerates) and non-record payloads pass through untouched, and the
|
|
2481
|
+
* original object is never mutated. Because LangGraph thread state
|
|
2482
|
+
* persists across runs, the capabilities stamped by any run remain
|
|
2483
|
+
* readable by later runs on the same thread.
|
|
2484
|
+
*/
|
|
2485
|
+
function mergeA2uiClientCapabilities(payload, capabilities) {
|
|
2486
|
+
if (!capabilities)
|
|
2487
|
+
return payload;
|
|
2488
|
+
if (payload === null || payload === undefined)
|
|
2489
|
+
return payload;
|
|
2490
|
+
if (typeof payload !== 'object' || Array.isArray(payload))
|
|
2491
|
+
return payload;
|
|
2492
|
+
return { ...payload, a2ui_client_capabilities: capabilities };
|
|
2493
|
+
}
|
|
2367
2494
|
/**
|
|
2368
2495
|
* Prepend staged tool messages to a run payload's message list.
|
|
2369
2496
|
*
|
|
@@ -2396,22 +2523,25 @@ function mergeStagedToolMessages(payload, staged) {
|
|
|
2396
2523
|
* The backend ends the run without emitting a ToolMessage result for
|
|
2397
2524
|
* client tools, so `result` stays undefined on those entries.
|
|
2398
2525
|
* - settle(id, result): marks the call as resolved, writes the local result,
|
|
2399
|
-
* and
|
|
2400
|
-
* - flush():
|
|
2526
|
+
* and stages a ToolMessage with a deterministic ID without issuing a run.
|
|
2527
|
+
* - flush(): snapshots the whole staged group into ONE persistFn call without
|
|
2401
2528
|
* starting a run — the settlement path for tool groups that never continue.
|
|
2402
|
-
*
|
|
2403
|
-
*
|
|
2404
|
-
*
|
|
2405
|
-
*
|
|
2406
|
-
*
|
|
2529
|
+
* Successful persistence acknowledges the captured entries; failed writes
|
|
2530
|
+
* retain them. When persistFn is absent, a non-empty flush rejects without
|
|
2531
|
+
* changing the staged results. Flushes are chained so entries settled after
|
|
2532
|
+
* one snapshot receive their own write. Concurrent persistence,
|
|
2533
|
+
* continuation, and ordinary submits may safely carry the same stable IDs.
|
|
2534
|
+
* - clearStagedToolMessages(): discards staged results on a thread switch and
|
|
2535
|
+
* advances the generation so late acknowledgments cannot affect new state.
|
|
2407
2536
|
* - resolve(id, result): settles the result, then issues a NEW run on the SAME
|
|
2408
|
-
* thread by calling submitFn with
|
|
2537
|
+
* thread by calling submitFn with a non-destructive ToolMessage snapshot:
|
|
2409
2538
|
* input: {
|
|
2410
2539
|
* messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
|
|
2411
2540
|
* client_tools: catalog(),
|
|
2412
2541
|
* }
|
|
2413
|
-
* The
|
|
2414
|
-
*
|
|
2542
|
+
* The snapshot remains staged unless that continuation reports success.
|
|
2543
|
+
* LangGraph's `add_messages` reducer reuses each stable message ID on safe
|
|
2544
|
+
* overlap or replay. Including `client_tools` ensures the model sees the
|
|
2415
2545
|
* full tool catalog on the continuation run.
|
|
2416
2546
|
*
|
|
2417
2547
|
* Catalog shipping: the catalog is NOT injected by this factory's
|
|
@@ -2426,8 +2556,8 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
|
|
|
2426
2556
|
const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
|
|
2427
2557
|
const toolMessageBuffer = [];
|
|
2428
2558
|
let flushInFlight;
|
|
2429
|
-
// Bumped whenever
|
|
2430
|
-
//
|
|
2559
|
+
// Bumped whenever a thread reset discards staged state. Every snapshot keeps
|
|
2560
|
+
// its generation, making acknowledgments from the prior thread no-ops.
|
|
2431
2561
|
let bufferGeneration = 0;
|
|
2432
2562
|
// Tool calls belonging to threads we have left. A handler still running when
|
|
2433
2563
|
// the user switches threads settles AFTER the switch, by which point both the
|
|
@@ -2482,62 +2612,76 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
|
|
|
2482
2612
|
// shape used in buildSubmitUpdate (agent.fn.ts line 732).
|
|
2483
2613
|
toolMessageBuffer.push({
|
|
2484
2614
|
threadId: currentThreadIdFn?.() ?? null,
|
|
2485
|
-
message: {
|
|
2615
|
+
message: {
|
|
2616
|
+
id: `client-tool-result-${id}`,
|
|
2617
|
+
type: 'tool',
|
|
2618
|
+
role: 'tool',
|
|
2619
|
+
tool_call_id: id,
|
|
2620
|
+
content,
|
|
2621
|
+
},
|
|
2486
2622
|
});
|
|
2487
2623
|
}
|
|
2488
2624
|
/**
|
|
2489
|
-
*
|
|
2490
|
-
*
|
|
2491
|
-
*
|
|
2625
|
+
* Returns a non-destructive snapshot of entries still valid for the thread a
|
|
2626
|
+
* write would land on right now. Captured entries remain staged, so
|
|
2627
|
+
* overlapping operations may carry the same deterministic message IDs.
|
|
2628
|
+
* Acknowledgment removes only the exact captured entries while the snapshot
|
|
2629
|
+
* generation is current. An entry stamped for another thread is dropped
|
|
2630
|
+
* rather than misdelivered.
|
|
2492
2631
|
*
|
|
2493
2632
|
* A null on either side means "thread not tracked yet" (no threadId option
|
|
2494
2633
|
* and no run has reported one); those are kept, since there is no evidence of
|
|
2495
2634
|
* a switch and dropping them would lose results on untracked transports.
|
|
2496
2635
|
*/
|
|
2497
|
-
function
|
|
2636
|
+
function snapshotToolMessages() {
|
|
2498
2637
|
const current = currentThreadIdFn?.() ?? null;
|
|
2499
|
-
|
|
2500
|
-
|
|
2638
|
+
for (let index = toolMessageBuffer.length - 1; index >= 0; index -= 1) {
|
|
2639
|
+
const entry = toolMessageBuffer[index];
|
|
2501
2640
|
const stale = entry.threadId !== null && current !== null && entry.threadId !== current;
|
|
2502
2641
|
if (stale) {
|
|
2503
2642
|
console.warn(`Discarding a client tool result staged for thread ${entry.threadId}; ` +
|
|
2504
2643
|
`the active thread is now ${current}.`);
|
|
2644
|
+
toolMessageBuffer.splice(index, 1);
|
|
2505
2645
|
}
|
|
2506
|
-
|
|
2507
|
-
|
|
2646
|
+
}
|
|
2647
|
+
const generation = bufferGeneration;
|
|
2648
|
+
const entries = [...toolMessageBuffer];
|
|
2649
|
+
const messages = entries.map(({ message }) => ({ ...message }));
|
|
2650
|
+
let acknowledged = false;
|
|
2651
|
+
return {
|
|
2652
|
+
generation,
|
|
2653
|
+
messages,
|
|
2654
|
+
acknowledge() {
|
|
2655
|
+
if (acknowledged || generation !== bufferGeneration)
|
|
2656
|
+
return;
|
|
2657
|
+
acknowledged = true;
|
|
2658
|
+
for (const entry of entries) {
|
|
2659
|
+
const index = toolMessageBuffer.indexOf(entry);
|
|
2660
|
+
if (index !== -1)
|
|
2661
|
+
toolMessageBuffer.splice(index, 1);
|
|
2662
|
+
}
|
|
2663
|
+
},
|
|
2664
|
+
};
|
|
2508
2665
|
}
|
|
2509
2666
|
/**
|
|
2510
|
-
* Persist one
|
|
2511
|
-
*
|
|
2512
|
-
* nothing about an in-flight write, so anything left staged across the await
|
|
2513
|
-
* could be re-sent (a duplicate ToolMessage for one tool_call_id) or removed
|
|
2514
|
-
* by the wrong index (dropping a result that was never persisted).
|
|
2667
|
+
* Persist one snapshot. Only that snapshot is acknowledged after persistence
|
|
2668
|
+
* succeeds; failure leaves its exact entries staged for retry or submit.
|
|
2515
2669
|
*/
|
|
2516
2670
|
function runFlush() {
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
const staged = takeStagedForCurrentThread();
|
|
2520
|
-
if (staged.length === 0)
|
|
2671
|
+
const batch = snapshotToolMessages();
|
|
2672
|
+
if (batch.messages.length === 0)
|
|
2521
2673
|
return Promise.resolve();
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
if (generation === bufferGeneration) {
|
|
2530
|
-
toolMessageBuffer.unshift(...staged);
|
|
2531
|
-
}
|
|
2532
|
-
console.warn(`Client tool flush failed; ${batch.length} result(s) remain staged for the next run.`, err);
|
|
2674
|
+
if (!persistFn) {
|
|
2675
|
+
return Promise.reject(new Error('Cannot flush staged client tool results. ' +
|
|
2676
|
+
'Custom LangGraph transports using terminal client tools must implement updateState().'));
|
|
2677
|
+
}
|
|
2678
|
+
return persistFn(batch.messages)
|
|
2679
|
+
.then(() => {
|
|
2680
|
+
batch.acknowledge();
|
|
2533
2681
|
})
|
|
2534
|
-
.
|
|
2535
|
-
|
|
2536
|
-
if (flushInFlight === inFlight)
|
|
2537
|
-
flushInFlight = undefined;
|
|
2682
|
+
.catch((err) => {
|
|
2683
|
+
console.warn(`Client tool flush failed; ${batch.messages.length} result(s) remain staged for the next run.`, err);
|
|
2538
2684
|
});
|
|
2539
|
-
flushInFlight = inFlight;
|
|
2540
|
-
return inFlight;
|
|
2541
2685
|
}
|
|
2542
2686
|
const capability = {
|
|
2543
2687
|
catalog,
|
|
@@ -2548,9 +2692,8 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
|
|
|
2548
2692
|
settle(id, result) {
|
|
2549
2693
|
settleResult(id, result);
|
|
2550
2694
|
},
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
return takeStagedForCurrentThread().map((entry) => entry.message);
|
|
2695
|
+
snapshotToolMessages() {
|
|
2696
|
+
return snapshotToolMessages();
|
|
2554
2697
|
},
|
|
2555
2698
|
/**
|
|
2556
2699
|
* Discard everything staged. Called when the active thread changes: a
|
|
@@ -2564,36 +2707,43 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
|
|
|
2564
2707
|
for (const toolCall of store.toolCalls())
|
|
2565
2708
|
retiredToolCallIds.add(toolCall.id);
|
|
2566
2709
|
toolMessageBuffer.length = 0;
|
|
2567
|
-
// Invalidate
|
|
2568
|
-
// old thread's messages into the new thread's buffer.
|
|
2710
|
+
// Invalidate every outstanding snapshot acknowledgment from the old thread.
|
|
2569
2711
|
bufferGeneration += 1;
|
|
2570
2712
|
},
|
|
2571
2713
|
flush() {
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2714
|
+
// Only this method owns the queue tail. Results settled after an active
|
|
2715
|
+
// snapshot need their own write, and every caller must remain behind all
|
|
2716
|
+
// callers already queued. The chain terminates when a fresh snapshot is
|
|
2717
|
+
// empty.
|
|
2718
|
+
const queued = flushInFlight
|
|
2719
|
+
? flushInFlight.then(() => runFlush(), () => runFlush())
|
|
2720
|
+
: runFlush();
|
|
2721
|
+
flushInFlight = queued;
|
|
2722
|
+
const clearTail = () => {
|
|
2723
|
+
if (flushInFlight === queued)
|
|
2724
|
+
flushInFlight = undefined;
|
|
2725
|
+
};
|
|
2726
|
+
// Both handlers return normally, so this bookkeeping branch cannot
|
|
2727
|
+
// create an unhandled rejection when the caller observes `queued`.
|
|
2728
|
+
void queued.then(clearTail, clearTail);
|
|
2729
|
+
return queued;
|
|
2586
2730
|
},
|
|
2587
2731
|
resolve(id, result) {
|
|
2588
2732
|
settleResult(id, result);
|
|
2589
|
-
// Issue a new run
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2733
|
+
// Issue a new run with a non-destructive snapshot. The exact batch is
|
|
2734
|
+
// acknowledged only when that continuation succeeds; failures retain it
|
|
2735
|
+
// for retry. Stable IDs make overlap with flush or submit safe under
|
|
2736
|
+
// LangGraph's add_messages reducer. `client_tools` keeps the full catalog
|
|
2737
|
+
// visible to the continuation.
|
|
2738
|
+
const batch = snapshotToolMessages();
|
|
2592
2739
|
const toolPayload = {
|
|
2593
|
-
messages:
|
|
2740
|
+
messages: batch.messages,
|
|
2594
2741
|
client_tools: catalog(),
|
|
2595
2742
|
};
|
|
2596
|
-
void submitFn(toolPayload)
|
|
2743
|
+
void submitFn(toolPayload, undefined, batch).then((outcome) => {
|
|
2744
|
+
if (outcome === 'success')
|
|
2745
|
+
batch.acknowledge();
|
|
2746
|
+
});
|
|
2597
2747
|
},
|
|
2598
2748
|
};
|
|
2599
2749
|
return capability;
|
|
@@ -2709,6 +2859,7 @@ function agent(options) {
|
|
|
2709
2859
|
// seam lives here. A holder keeps the binding itself a `const` while its
|
|
2710
2860
|
// member is filled in later.
|
|
2711
2861
|
const clientToolStaging = {};
|
|
2862
|
+
let retryableToolMessageBatch;
|
|
2712
2863
|
function resetDerivedThreadState() {
|
|
2713
2864
|
status$.next(ResourceStatus.Idle);
|
|
2714
2865
|
error$.next(undefined);
|
|
@@ -2920,10 +3071,14 @@ function agent(options) {
|
|
|
2920
3071
|
// updateState() silently no-ops when the transport has no updateState, so
|
|
2921
3072
|
// only supply a persist function when the effective transport supports it —
|
|
2922
3073
|
// an omitted transport means the bridge builds a FetchStreamTransport, which
|
|
2923
|
-
// does. When persistFn is undefined, flush()
|
|
2924
|
-
//
|
|
3074
|
+
// does. When persistFn is undefined, a non-empty flush() rejects while the
|
|
3075
|
+
// results stay staged; an ordinary non-null submit remains their in-memory
|
|
3076
|
+
// fallback path.
|
|
2925
3077
|
const canPersistToolMessages = !transport || typeof transport.updateState === 'function';
|
|
2926
|
-
const clientToolsCap = createClientToolsCapability((payload, opts) =>
|
|
3078
|
+
const clientToolsCap = createClientToolsCapability((payload, opts, batch) => {
|
|
3079
|
+
retryableToolMessageBatch = batch;
|
|
3080
|
+
return manager.submit(payload, opts);
|
|
3081
|
+
}, {
|
|
2927
3082
|
toolCalls: toolCallsNeutral,
|
|
2928
3083
|
isLoading,
|
|
2929
3084
|
applyClientResult: (id, patch) => clientResultOverrides.update((m) => new Map(m).set(id, patch)),
|
|
@@ -2942,7 +3097,16 @@ function agent(options) {
|
|
|
2942
3097
|
// Stamps each staged result with the thread it was settled on, so a write
|
|
2943
3098
|
// can never land on a thread the user has since moved to.
|
|
2944
3099
|
() => manager.currentThreadId);
|
|
2945
|
-
clientToolStaging.clear = () =>
|
|
3100
|
+
clientToolStaging.clear = () => {
|
|
3101
|
+
retryableToolMessageBatch = undefined;
|
|
3102
|
+
clientToolsCap.clearStagedToolMessages();
|
|
3103
|
+
};
|
|
3104
|
+
async function resubmitWithToolRecovery() {
|
|
3105
|
+
const batch = retryableToolMessageBatch;
|
|
3106
|
+
const outcome = await manager.resubmitLast();
|
|
3107
|
+
if (outcome === 'success')
|
|
3108
|
+
batch?.acknowledge();
|
|
3109
|
+
}
|
|
2946
3110
|
return {
|
|
2947
3111
|
// ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
|
|
2948
3112
|
messages: messagesNeutral,
|
|
@@ -2956,7 +3120,7 @@ function agent(options) {
|
|
|
2956
3120
|
events$,
|
|
2957
3121
|
history: historyNeutral,
|
|
2958
3122
|
messageCheckpoints: messageCheckpointsSig,
|
|
2959
|
-
submit: (input, opts) => {
|
|
3123
|
+
submit: async (input, opts) => {
|
|
2960
3124
|
// Lifecycle: first submit with no existing threadId → thread create.
|
|
2961
3125
|
if (lcThreadCreatedAt() === null && lastThreadId == null) {
|
|
2962
3126
|
lcThreadCreatedAt.set(Date.now());
|
|
@@ -2970,25 +3134,33 @@ function agent(options) {
|
|
|
2970
3134
|
// backend middleware can merge them into the model's tool list. Null
|
|
2971
3135
|
// payloads (regenerate re-runs, command resumes) are left unchanged.
|
|
2972
3136
|
//
|
|
2973
|
-
//
|
|
2974
|
-
//
|
|
2975
|
-
//
|
|
2976
|
-
//
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
3137
|
+
// Snapshot results settled but not yet durable (flush unavailable or a
|
|
3138
|
+
// prior flush failed) so they ride along without being forgotten. The
|
|
3139
|
+
// exact snapshot is acknowledged only when this operation succeeds;
|
|
3140
|
+
// overlaps may safely carry the same deterministic message IDs. A null
|
|
3141
|
+
// payload cannot carry results, so it leaves staging unchanged.
|
|
3142
|
+
const batch = request.payload === null || request.payload === undefined
|
|
3143
|
+
? undefined
|
|
3144
|
+
: clientToolsCap.snapshotToolMessages();
|
|
3145
|
+
const staged = batch?.messages ?? [];
|
|
2980
3146
|
const withStaged = staged.length > 0
|
|
2981
3147
|
? mergeStagedToolMessages(request.payload, staged)
|
|
2982
3148
|
: request.payload;
|
|
2983
|
-
const payload = mergeClientTools(withStaged, clientToolsCap.catalog());
|
|
2984
|
-
|
|
3149
|
+
const payload = mergeA2uiClientCapabilities(mergeClientTools(withStaged, clientToolsCap.catalog()), options.a2uiClientCapabilities);
|
|
3150
|
+
const createsQueuedRun = request.options?.multitaskStrategy === 'enqueue' && isLoading();
|
|
3151
|
+
if (!createsQueuedRun) {
|
|
3152
|
+
retryableToolMessageBatch = batch;
|
|
3153
|
+
}
|
|
3154
|
+
const outcome = await manager.submit(payload, request.options);
|
|
3155
|
+
if (outcome === 'success')
|
|
3156
|
+
batch?.acknowledge();
|
|
2985
3157
|
},
|
|
2986
3158
|
stop: () => manager.stop(),
|
|
2987
3159
|
retry: async () => {
|
|
2988
3160
|
if (isLoading())
|
|
2989
3161
|
return; // no-op while a run is in flight
|
|
2990
3162
|
error$.next(undefined); // clear the error before re-running
|
|
2991
|
-
await
|
|
3163
|
+
await resubmitWithToolRecovery();
|
|
2992
3164
|
},
|
|
2993
3165
|
clientTools: clientToolsCap,
|
|
2994
3166
|
regenerate: async (assistantMessageIndex) => {
|
|
@@ -3045,6 +3217,7 @@ function agent(options) {
|
|
|
3045
3217
|
// at `__start__`, this resumes at the entry node and produces a fresh
|
|
3046
3218
|
// assistant message — the trailing user message becomes the active
|
|
3047
3219
|
// prompt without being re-appended.
|
|
3220
|
+
retryableToolMessageBatch = undefined;
|
|
3048
3221
|
await manager.submit(null, undefined);
|
|
3049
3222
|
},
|
|
3050
3223
|
// ── Raw LangGraph signals ─────────────────────────────────────────────
|
|
@@ -3056,7 +3229,9 @@ function agent(options) {
|
|
|
3056
3229
|
// ── Other LangGraph-specific fields ──────────────────────────────────
|
|
3057
3230
|
value: value,
|
|
3058
3231
|
hasValue: hasValueSig,
|
|
3059
|
-
reload: () =>
|
|
3232
|
+
reload: () => {
|
|
3233
|
+
void resubmitWithToolRecovery();
|
|
3234
|
+
},
|
|
3060
3235
|
toolProgress: toolProgSig,
|
|
3061
3236
|
queue: queueSig,
|
|
3062
3237
|
activeSubagents,
|
|
@@ -3354,7 +3529,6 @@ function agentFactory() {
|
|
|
3354
3529
|
...(config.transport !== undefined ? { transport: config.transport } : {}),
|
|
3355
3530
|
...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}),
|
|
3356
3531
|
...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}),
|
|
3357
|
-
...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}),
|
|
3358
3532
|
...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}),
|
|
3359
3533
|
...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}),
|
|
3360
3534
|
});
|