@threadplane/langgraph 0.0.57 → 0.0.58

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.
@@ -750,6 +750,8 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
750
750
  if (resetState) {
751
751
  invalidateQueueDrain();
752
752
  abortController?.abort();
753
+ lastPayload = null;
754
+ lastOptions = undefined;
753
755
  }
754
756
  currentThreadId = id;
755
757
  if (resetState) {
@@ -1011,7 +1013,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1011
1013
  subjects.toolProgress$.next([]);
1012
1014
  toolProgressMap.clear();
1013
1015
  canonicalMessageIds.clear();
1014
- lastPayload = payload;
1016
+ lastPayload = payload ?? null;
1015
1017
  lastOptions = opts;
1016
1018
  // Tracks whether at least one stream event has been processed this run.
1017
1019
  // Used to distinguish a mid-stream network interruption (kind:'interrupted')
@@ -1044,10 +1046,10 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1044
1046
  processEvent(event);
1045
1047
  }
1046
1048
  if (!isCurrentExecution(controller, attempt))
1047
- return;
1049
+ return finishOutcome(attempt);
1048
1050
  const outcome = await finalizeClosedAttempt(controller, attempt);
1049
1051
  if (outcome === null)
1050
- return;
1052
+ return finishOutcome(attempt);
1051
1053
  if (!controller.signal.aborted) {
1052
1054
  if (outcome !== 'error') {
1053
1055
  subjects.status$.next(ResourceStatus.Resolved);
@@ -1058,12 +1060,13 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1058
1060
  durationMs: Date.now() - startedAt,
1059
1061
  });
1060
1062
  }
1063
+ return outcome;
1061
1064
  }
1062
1065
  catch (err) {
1063
1066
  if (!isCurrentExecution(controller, attempt))
1064
- return;
1067
+ return finishOutcome(attempt);
1065
1068
  if (attempt.terminalOutcome)
1066
- return;
1069
+ return attempt.terminalOutcome;
1067
1070
  if (isAbortError(err) && userAbortedControllers.has(controller)) {
1068
1071
  finalizeAttempt(attempt, 'aborted');
1069
1072
  // User explicitly called stop() — treat as graceful idle, not an error.
@@ -1095,6 +1098,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1095
1098
  errorClass: agentRuntimeTelemetryErrorClass(err),
1096
1099
  });
1097
1100
  }
1101
+ return finishOutcome(attempt);
1098
1102
  }
1099
1103
  finally {
1100
1104
  if (abortController === controller)
@@ -1407,9 +1411,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1407
1411
  submit: async (payload, opts) => {
1408
1412
  if (opts?.multitaskStrategy === 'enqueue' && subjects.status$.value === ResourceStatus.Loading) {
1409
1413
  await enqueueRun(payload, opts);
1410
- return;
1414
+ return 'success';
1411
1415
  }
1412
- await runStream(payload, opts);
1416
+ return runStream(payload, opts);
1413
1417
  },
1414
1418
  stop: async () => {
1415
1419
  invalidateQueueDrain();
@@ -1499,9 +1503,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1499
1503
  }
1500
1504
  },
1501
1505
  resubmitLast: async () => {
1502
- if (lastPayload !== null) {
1503
- await runStream(lastPayload, lastOptions, 'resubmit');
1504
- }
1506
+ if (lastPayload === null)
1507
+ return 'not-started';
1508
+ return runStream(lastPayload, lastOptions, 'resubmit');
1505
1509
  },
1506
1510
  getReasoningDurationMs: (id) => {
1507
1511
  const entry = reasoningTimingMap.get(id);
@@ -2364,6 +2368,24 @@ function mergeClientTools(payload, catalog) {
2364
2368
  return payload;
2365
2369
  return { ...payload, client_tools: catalog };
2366
2370
  }
2371
+ /**
2372
+ * Merge A2UI client capabilities into a run payload under the
2373
+ * `a2ui_client_capabilities` state key. Same payload semantics as
2374
+ * {@link mergeClientTools}: null/undefined payloads (command resumes,
2375
+ * regenerates) and non-record payloads pass through untouched, and the
2376
+ * original object is never mutated. Because LangGraph thread state
2377
+ * persists across runs, the capabilities stamped by any run remain
2378
+ * readable by later runs on the same thread.
2379
+ */
2380
+ function mergeA2uiClientCapabilities(payload, capabilities) {
2381
+ if (!capabilities)
2382
+ return payload;
2383
+ if (payload === null || payload === undefined)
2384
+ return payload;
2385
+ if (typeof payload !== 'object' || Array.isArray(payload))
2386
+ return payload;
2387
+ return { ...payload, a2ui_client_capabilities: capabilities };
2388
+ }
2367
2389
  /**
2368
2390
  * Prepend staged tool messages to a run payload's message list.
2369
2391
  *
@@ -2396,22 +2418,25 @@ function mergeStagedToolMessages(payload, staged) {
2396
2418
  * The backend ends the run without emitting a ToolMessage result for
2397
2419
  * client tools, so `result` stays undefined on those entries.
2398
2420
  * - 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
2421
+ * and stages a ToolMessage with a deterministic ID without issuing a run.
2422
+ * - flush(): snapshots the whole staged group into ONE persistFn call without
2401
2423
  * 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.
2424
+ * Successful persistence acknowledges the captured entries; failed writes
2425
+ * retain them. When persistFn is absent, a non-empty flush rejects without
2426
+ * changing the staged results. Flushes are chained so entries settled after
2427
+ * one snapshot receive their own write. Concurrent persistence,
2428
+ * continuation, and ordinary submits may safely carry the same stable IDs.
2429
+ * - clearStagedToolMessages(): discards staged results on a thread switch and
2430
+ * advances the generation so late acknowledgments cannot affect new state.
2407
2431
  * - 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:
2432
+ * thread by calling submitFn with a non-destructive ToolMessage snapshot:
2409
2433
  * input: {
2410
2434
  * messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
2411
2435
  * client_tools: catalog(),
2412
2436
  * }
2413
- * The `add_messages` reducer on the Python side appends the ToolMessage
2414
- * to thread state. Including `client_tools` ensures the model sees the
2437
+ * The snapshot remains staged unless that continuation reports success.
2438
+ * LangGraph's `add_messages` reducer reuses each stable message ID on safe
2439
+ * overlap or replay. Including `client_tools` ensures the model sees the
2415
2440
  * full tool catalog on the continuation run.
2416
2441
  *
2417
2442
  * Catalog shipping: the catalog is NOT injected by this factory's
@@ -2426,8 +2451,8 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
2426
2451
  const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
2427
2452
  const toolMessageBuffer = [];
2428
2453
  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.
2454
+ // Bumped whenever a thread reset discards staged state. Every snapshot keeps
2455
+ // its generation, making acknowledgments from the prior thread no-ops.
2431
2456
  let bufferGeneration = 0;
2432
2457
  // Tool calls belonging to threads we have left. A handler still running when
2433
2458
  // the user switches threads settles AFTER the switch, by which point both the
@@ -2482,62 +2507,76 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
2482
2507
  // shape used in buildSubmitUpdate (agent.fn.ts line 732).
2483
2508
  toolMessageBuffer.push({
2484
2509
  threadId: currentThreadIdFn?.() ?? null,
2485
- message: { type: 'tool', role: 'tool', tool_call_id: id, content },
2510
+ message: {
2511
+ id: `client-tool-result-${id}`,
2512
+ type: 'tool',
2513
+ role: 'tool',
2514
+ tool_call_id: id,
2515
+ content,
2516
+ },
2486
2517
  });
2487
2518
  }
2488
2519
  /**
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.
2520
+ * Returns a non-destructive snapshot of entries still valid for the thread a
2521
+ * write would land on right now. Captured entries remain staged, so
2522
+ * overlapping operations may carry the same deterministic message IDs.
2523
+ * Acknowledgment removes only the exact captured entries while the snapshot
2524
+ * generation is current. An entry stamped for another thread is dropped
2525
+ * rather than misdelivered.
2492
2526
  *
2493
2527
  * A null on either side means "thread not tracked yet" (no threadId option
2494
2528
  * and no run has reported one); those are kept, since there is no evidence of
2495
2529
  * a switch and dropping them would lose results on untracked transports.
2496
2530
  */
2497
- function takeStagedForCurrentThread() {
2531
+ function snapshotToolMessages() {
2498
2532
  const current = currentThreadIdFn?.() ?? null;
2499
- const taken = toolMessageBuffer.splice(0, toolMessageBuffer.length);
2500
- return taken.filter((entry) => {
2533
+ for (let index = toolMessageBuffer.length - 1; index >= 0; index -= 1) {
2534
+ const entry = toolMessageBuffer[index];
2501
2535
  const stale = entry.threadId !== null && current !== null && entry.threadId !== current;
2502
2536
  if (stale) {
2503
2537
  console.warn(`Discarding a client tool result staged for thread ${entry.threadId}; ` +
2504
2538
  `the active thread is now ${current}.`);
2539
+ toolMessageBuffer.splice(index, 1);
2505
2540
  }
2506
- return !stale;
2507
- });
2541
+ }
2542
+ const generation = bufferGeneration;
2543
+ const entries = [...toolMessageBuffer];
2544
+ const messages = entries.map(({ message }) => ({ ...message }));
2545
+ let acknowledged = false;
2546
+ return {
2547
+ generation,
2548
+ messages,
2549
+ acknowledge() {
2550
+ if (acknowledged || generation !== bufferGeneration)
2551
+ return;
2552
+ acknowledged = true;
2553
+ for (const entry of entries) {
2554
+ const index = toolMessageBuffer.indexOf(entry);
2555
+ if (index !== -1)
2556
+ toolMessageBuffer.splice(index, 1);
2557
+ }
2558
+ },
2559
+ };
2508
2560
  }
2509
2561
  /**
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).
2562
+ * Persist one snapshot. Only that snapshot is acknowledged after persistence
2563
+ * succeeds; failure leaves its exact entries staged for retry or submit.
2515
2564
  */
2516
2565
  function runFlush() {
2517
- if (!persistFn)
2566
+ const batch = snapshotToolMessages();
2567
+ if (batch.messages.length === 0)
2518
2568
  return Promise.resolve();
2519
- const staged = takeStagedForCurrentThread();
2520
- if (staged.length === 0)
2521
- 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);
2569
+ if (!persistFn) {
2570
+ return Promise.reject(new Error('Cannot flush staged client tool results. ' +
2571
+ 'Custom LangGraph transports using terminal client tools must implement updateState().'));
2572
+ }
2573
+ return persistFn(batch.messages)
2574
+ .then(() => {
2575
+ batch.acknowledge();
2533
2576
  })
2534
- .finally(() => {
2535
- // Only clear if no later flush has already claimed the slot.
2536
- if (flushInFlight === inFlight)
2537
- flushInFlight = undefined;
2577
+ .catch((err) => {
2578
+ console.warn(`Client tool flush failed; ${batch.messages.length} result(s) remain staged for the next run.`, err);
2538
2579
  });
2539
- flushInFlight = inFlight;
2540
- return inFlight;
2541
2580
  }
2542
2581
  const capability = {
2543
2582
  catalog,
@@ -2548,9 +2587,8 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
2548
2587
  settle(id, result) {
2549
2588
  settleResult(id, result);
2550
2589
  },
2551
- /** Remove and return every buffered tool message valid for this thread. */
2552
- drainToolMessages() {
2553
- return takeStagedForCurrentThread().map((entry) => entry.message);
2590
+ snapshotToolMessages() {
2591
+ return snapshotToolMessages();
2554
2592
  },
2555
2593
  /**
2556
2594
  * Discard everything staged. Called when the active thread changes: a
@@ -2564,36 +2602,43 @@ function createClientToolsCapability(submitFn, store, persistFn, currentThreadId
2564
2602
  for (const toolCall of store.toolCalls())
2565
2603
  retiredToolCallIds.add(toolCall.id);
2566
2604
  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.
2605
+ // Invalidate every outstanding snapshot acknowledgment from the old thread.
2569
2606
  bufferGeneration += 1;
2570
2607
  },
2571
2608
  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();
2609
+ // Only this method owns the queue tail. Results settled after an active
2610
+ // snapshot need their own write, and every caller must remain behind all
2611
+ // callers already queued. The chain terminates when a fresh snapshot is
2612
+ // empty.
2613
+ const queued = flushInFlight
2614
+ ? flushInFlight.then(() => runFlush(), () => runFlush())
2615
+ : runFlush();
2616
+ flushInFlight = queued;
2617
+ const clearTail = () => {
2618
+ if (flushInFlight === queued)
2619
+ flushInFlight = undefined;
2620
+ };
2621
+ // Both handlers return normally, so this bookkeeping branch cannot
2622
+ // create an unhandled rejection when the caller observes `queued`.
2623
+ void queued.then(clearTail, clearTail);
2624
+ return queued;
2586
2625
  },
2587
2626
  resolve(id, result) {
2588
2627
  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.
2628
+ // Issue a new run with a non-destructive snapshot. The exact batch is
2629
+ // acknowledged only when that continuation succeeds; failures retain it
2630
+ // for retry. Stable IDs make overlap with flush or submit safe under
2631
+ // LangGraph's add_messages reducer. `client_tools` keeps the full catalog
2632
+ // visible to the continuation.
2633
+ const batch = snapshotToolMessages();
2592
2634
  const toolPayload = {
2593
- messages: takeStagedForCurrentThread().map((entry) => entry.message),
2635
+ messages: batch.messages,
2594
2636
  client_tools: catalog(),
2595
2637
  };
2596
- void submitFn(toolPayload);
2638
+ void submitFn(toolPayload, undefined, batch).then((outcome) => {
2639
+ if (outcome === 'success')
2640
+ batch.acknowledge();
2641
+ });
2597
2642
  },
2598
2643
  };
2599
2644
  return capability;
@@ -2709,6 +2754,7 @@ function agent(options) {
2709
2754
  // seam lives here. A holder keeps the binding itself a `const` while its
2710
2755
  // member is filled in later.
2711
2756
  const clientToolStaging = {};
2757
+ let retryableToolMessageBatch;
2712
2758
  function resetDerivedThreadState() {
2713
2759
  status$.next(ResourceStatus.Idle);
2714
2760
  error$.next(undefined);
@@ -2920,10 +2966,14 @@ function agent(options) {
2920
2966
  // updateState() silently no-ops when the transport has no updateState, so
2921
2967
  // only supply a persist function when the effective transport supports it —
2922
2968
  // 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.
2969
+ // does. When persistFn is undefined, a non-empty flush() rejects while the
2970
+ // results stay staged; an ordinary non-null submit remains their in-memory
2971
+ // fallback path.
2925
2972
  const canPersistToolMessages = !transport || typeof transport.updateState === 'function';
2926
- const clientToolsCap = createClientToolsCapability((payload, opts) => manager.submit(payload, opts), {
2973
+ const clientToolsCap = createClientToolsCapability((payload, opts, batch) => {
2974
+ retryableToolMessageBatch = batch;
2975
+ return manager.submit(payload, opts);
2976
+ }, {
2927
2977
  toolCalls: toolCallsNeutral,
2928
2978
  isLoading,
2929
2979
  applyClientResult: (id, patch) => clientResultOverrides.update((m) => new Map(m).set(id, patch)),
@@ -2942,7 +2992,16 @@ function agent(options) {
2942
2992
  // Stamps each staged result with the thread it was settled on, so a write
2943
2993
  // can never land on a thread the user has since moved to.
2944
2994
  () => manager.currentThreadId);
2945
- clientToolStaging.clear = () => clientToolsCap.clearStagedToolMessages();
2995
+ clientToolStaging.clear = () => {
2996
+ retryableToolMessageBatch = undefined;
2997
+ clientToolsCap.clearStagedToolMessages();
2998
+ };
2999
+ async function resubmitWithToolRecovery() {
3000
+ const batch = retryableToolMessageBatch;
3001
+ const outcome = await manager.resubmitLast();
3002
+ if (outcome === 'success')
3003
+ batch?.acknowledge();
3004
+ }
2946
3005
  return {
2947
3006
  // ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
2948
3007
  messages: messagesNeutral,
@@ -2956,7 +3015,7 @@ function agent(options) {
2956
3015
  events$,
2957
3016
  history: historyNeutral,
2958
3017
  messageCheckpoints: messageCheckpointsSig,
2959
- submit: (input, opts) => {
3018
+ submit: async (input, opts) => {
2960
3019
  // Lifecycle: first submit with no existing threadId → thread create.
2961
3020
  if (lcThreadCreatedAt() === null && lastThreadId == null) {
2962
3021
  lcThreadCreatedAt.set(Date.now());
@@ -2970,25 +3029,33 @@ function agent(options) {
2970
3029
  // backend middleware can merge them into the model's tool list. Null
2971
3030
  // payloads (regenerate re-runs, command resumes) are left unchanged.
2972
3031
  //
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();
3032
+ // Snapshot results settled but not yet durable (flush unavailable or a
3033
+ // prior flush failed) so they ride along without being forgotten. The
3034
+ // exact snapshot is acknowledged only when this operation succeeds;
3035
+ // overlaps may safely carry the same deterministic message IDs. A null
3036
+ // payload cannot carry results, so it leaves staging unchanged.
3037
+ const batch = request.payload === null || request.payload === undefined
3038
+ ? undefined
3039
+ : clientToolsCap.snapshotToolMessages();
3040
+ const staged = batch?.messages ?? [];
2980
3041
  const withStaged = staged.length > 0
2981
3042
  ? mergeStagedToolMessages(request.payload, staged)
2982
3043
  : request.payload;
2983
- const payload = mergeClientTools(withStaged, clientToolsCap.catalog());
2984
- return manager.submit(payload, request.options);
3044
+ const payload = mergeA2uiClientCapabilities(mergeClientTools(withStaged, clientToolsCap.catalog()), options.a2uiClientCapabilities);
3045
+ const createsQueuedRun = request.options?.multitaskStrategy === 'enqueue' && isLoading();
3046
+ if (!createsQueuedRun) {
3047
+ retryableToolMessageBatch = batch;
3048
+ }
3049
+ const outcome = await manager.submit(payload, request.options);
3050
+ if (outcome === 'success')
3051
+ batch?.acknowledge();
2985
3052
  },
2986
3053
  stop: () => manager.stop(),
2987
3054
  retry: async () => {
2988
3055
  if (isLoading())
2989
3056
  return; // no-op while a run is in flight
2990
3057
  error$.next(undefined); // clear the error before re-running
2991
- await manager.resubmitLast();
3058
+ await resubmitWithToolRecovery();
2992
3059
  },
2993
3060
  clientTools: clientToolsCap,
2994
3061
  regenerate: async (assistantMessageIndex) => {
@@ -3045,6 +3112,7 @@ function agent(options) {
3045
3112
  // at `__start__`, this resumes at the entry node and produces a fresh
3046
3113
  // assistant message — the trailing user message becomes the active
3047
3114
  // prompt without being re-appended.
3115
+ retryableToolMessageBatch = undefined;
3048
3116
  await manager.submit(null, undefined);
3049
3117
  },
3050
3118
  // ── Raw LangGraph signals ─────────────────────────────────────────────
@@ -3056,7 +3124,9 @@ function agent(options) {
3056
3124
  // ── Other LangGraph-specific fields ──────────────────────────────────
3057
3125
  value: value,
3058
3126
  hasValue: hasValueSig,
3059
- reload: () => manager.resubmitLast(),
3127
+ reload: () => {
3128
+ void resubmitWithToolRecovery();
3129
+ },
3060
3130
  toolProgress: toolProgSig,
3061
3131
  queue: queueSig,
3062
3132
  activeSubagents,