@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
- function isSubagentNamespace(namespace) {
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.includes('tools:');
445
- return namespace.some(segment => segment.startsWith('tools:'));
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
- if (isSubagentNamespace(namespace)) {
1118
- const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1119
- if (namespaceId) {
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(namespaceId, msg);
1212
+ subagentManager.addMessageToSubagent(child.key, msg);
1122
1213
  }
1123
1214
  publishSubagents();
1124
1215
  }
1125
- if (options.filterSubagentMessages) {
1126
- return;
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
- if (!isSubagentNamespace(namespace)) {
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
- if (!isSubagentNamespace(namespace)) {
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 (isSubagentNamespace(namespace) && isRecord$1(vals)) {
1185
- updateSubagentValues(namespace, vals);
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 (isSubagentNamespace(namespace)) {
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 namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1315
- if (!namespaceId)
1409
+ const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
1410
+ if (!child)
1316
1411
  return;
1317
- const messages = values['messages'];
1318
- if (Array.isArray(messages) && messages.length > 0) {
1319
- const first = messages[0];
1320
- if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
1321
- subagentManager.matchSubgraphToSubagent(namespaceId, first['content']);
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
- subagentManager.updateSubagentValues(namespaceId, values);
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 namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1329
- if (!namespaceId)
1430
+ const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
1431
+ if (!child)
1330
1432
  return;
1331
- subagentManager.markRunningFromNamespace(namespaceId, namespace);
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
- await runStream(payload, opts);
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 !== null) {
1503
- await runStream(lastPayload, lastOptions, 'resubmit');
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
- : undefined,
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 buffers a ToolMessage without issuing a run.
2400
- * - flush(): makes the whole buffer durable in ONE persistFn call without
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
- * The batch leaves the buffer at snapshot time and is re-staged only if the
2403
- * write fails, so a failure (or an absent persistFn) still degrades to the
2404
- * next flush or to the drainToolMessages() fallback in the submit wrapper,
2405
- * while a concurrent resolve()/drain can never re-send an in-flight batch.
2406
- * - clearStagedToolMessages(): discards the buffer on a thread switch.
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 the full buffered ToolMessage group:
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 `add_messages` reducer on the Python side appends the ToolMessage
2414
- * to thread state. Including `client_tools` ensures the model sees the
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 the buffer is discarded, so an in-flight flush can tell
2430
- // whether its batch still belongs to the current thread.
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: { type: 'tool', role: 'tool', tool_call_id: id, content },
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
- * Remove every staged entry, returning only those still valid for the thread
2490
- * a write would land on right now. An entry is stale when it was stamped with
2491
- * a different thread dropped rather than misdelivered.
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 takeStagedForCurrentThread() {
2636
+ function snapshotToolMessages() {
2498
2637
  const current = currentThreadIdFn?.() ?? null;
2499
- const taken = toolMessageBuffer.splice(0, toolMessageBuffer.length);
2500
- return taken.filter((entry) => {
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
- return !stale;
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 batch. Takes ownership of the buffer at snapshot time:
2511
- * resolve() and drainToolMessages() clear the buffer unconditionally and know
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
- if (!persistFn)
2518
- return Promise.resolve();
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
- const generation = bufferGeneration;
2523
- const batch = staged.map((entry) => entry.message);
2524
- const inFlight = persistFn(batch)
2525
- .catch((err) => {
2526
- // Re-stage at the FRONT so ordering is preserved for the next drain —
2527
- // unless the buffer was cleared meanwhile (thread switch), in which
2528
- // case these results belong to a thread we have left.
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
- .finally(() => {
2535
- // Only clear if no later flush has already claimed the slot.
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
- /** Remove and return every buffered tool message valid for this thread. */
2552
- drainToolMessages() {
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 any in-flight flush so its failure path cannot re-stage the
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
- if (!persistFn)
2573
- return Promise.resolve();
2574
- if (flushInFlight) {
2575
- // Chain rather than short-circuit. The caller's batch may have been
2576
- // staged AFTER the in-flight write took its snapshot, so returning that
2577
- // promise would resolve without ever persisting it — which is exactly
2578
- // what happens when an abort fires one flush per settled call. The
2579
- // chain terminates because runFlush() returns immediately once the
2580
- // buffer is empty.
2581
- const chained = flushInFlight.then(() => runFlush());
2582
- flushInFlight = chained;
2583
- return chained;
2584
- }
2585
- return runFlush();
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 on the same thread. LangGraph's add_messages reducer
2590
- // appends the ToolMessages to the thread state. `client_tools` is
2591
- // included so the model sees the full tool catalog on the continuation.
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: takeStagedForCurrentThread().map((entry) => entry.message),
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() keeps the buffer and the submit
2924
- // wrapper below drains it into the next run instead.
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) => manager.submit(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 = () => clientToolsCap.clearStagedToolMessages();
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
- // Drain any results settled but not yet made durable (flush unavailable
2974
- // or a prior flush failed) so they ride along with this run. A null
2975
- // payload cannot carry them, so leave the buffer alone in that case
2976
- // rather than silently discarding the staged results.
2977
- const staged = request.payload === null || request.payload === undefined
2978
- ? []
2979
- : clientToolsCap.drainToolMessages();
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
- return manager.submit(payload, request.options);
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 manager.resubmitLast();
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: () => manager.resubmitLast(),
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
  });