@threadplane/langgraph 0.0.56 → 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.
|
@@ -3,9 +3,9 @@ import { signal, Injectable, InjectionToken, computed, inject, DestroyRef, isSig
|
|
|
3
3
|
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
4
4
|
import { takeUntil, Subject, BehaviorSubject, of, throttleTime, asyncScheduler } from 'rxjs';
|
|
5
5
|
import { takeUntil as takeUntil$1 } from 'rxjs/operators';
|
|
6
|
+
import { staticDelivery, completeDelivery, streamingDelivery, toAgentError, isAbortError, AgentError, AGENT_ERROR_MESSAGES, selectPendingClientToolCalls, mockAgent } from '@threadplane/chat';
|
|
6
7
|
import { Client } from '@langchain/langgraph-sdk';
|
|
7
8
|
import { getToolCallsWithResults } from '@langchain/langgraph-sdk/utils';
|
|
8
|
-
import { toAgentError, isAbortError, AgentError, AGENT_ERROR_MESSAGES, mockAgent } from '@threadplane/chat';
|
|
9
9
|
|
|
10
10
|
// SPDX-License-Identifier: MIT
|
|
11
11
|
/**
|
|
@@ -226,6 +226,11 @@ function isRecord$2(value) {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
const DEFAULT_SUBAGENT_TOOL_NAMES = ['task'];
|
|
229
|
+
let subagentGenerationSequence = 0;
|
|
230
|
+
function createSubagentGeneration() {
|
|
231
|
+
subagentGenerationSequence += 1;
|
|
232
|
+
return `subagent-${subagentGenerationSequence}-${Math.random().toString(36).slice(2, 10)}`;
|
|
233
|
+
}
|
|
229
234
|
/**
|
|
230
235
|
* Lightweight Angular adapter for LangGraph subagent stream state.
|
|
231
236
|
*
|
|
@@ -272,6 +277,7 @@ class SubagentTracker {
|
|
|
272
277
|
const existing = this.subagents.get(id);
|
|
273
278
|
this.subagents.set(id, {
|
|
274
279
|
id,
|
|
280
|
+
generation: existing?.generation ?? createSubagentGeneration(),
|
|
275
281
|
status: existing?.status ?? 'pending',
|
|
276
282
|
toolCall: {
|
|
277
283
|
id,
|
|
@@ -404,6 +410,22 @@ class SubagentTracker {
|
|
|
404
410
|
});
|
|
405
411
|
this.onSubagentChange?.();
|
|
406
412
|
}
|
|
413
|
+
getMessageDelivery(toolCallId, message) {
|
|
414
|
+
const id = getMessageId(message) ?? toolCallId;
|
|
415
|
+
const raw = message;
|
|
416
|
+
const type = typeof message._getType === 'function' ? message._getType() : raw['type'];
|
|
417
|
+
if (type !== 'ai' && type !== 'assistant' && type !== 'AIMessage' && type !== 'AIMessageChunk') {
|
|
418
|
+
return staticDelivery(id);
|
|
419
|
+
}
|
|
420
|
+
const subagent = this.subagents.get(toolCallId);
|
|
421
|
+
if (!subagent)
|
|
422
|
+
return staticDelivery(id);
|
|
423
|
+
if (subagent.status === 'error')
|
|
424
|
+
return completeDelivery(subagent.generation, 'error');
|
|
425
|
+
if (subagent.status === 'complete')
|
|
426
|
+
return completeDelivery(subagent.generation, 'success');
|
|
427
|
+
return streamingDelivery(subagent.generation);
|
|
428
|
+
}
|
|
407
429
|
retryPendingMatches() {
|
|
408
430
|
for (const [namespaceId, description] of this.pendingMatches) {
|
|
409
431
|
if (this.matchSubgraphToSubagent(namespaceId, description)) {
|
|
@@ -537,15 +559,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
537
559
|
let abortController = null;
|
|
538
560
|
let historyAbortController = null;
|
|
539
561
|
let hasSeenThreadId = false;
|
|
540
|
-
|
|
541
|
-
let userAbortRequested = false;
|
|
562
|
+
const userAbortedControllers = new WeakSet();
|
|
542
563
|
const toolProgressMap = new Map();
|
|
543
564
|
// Message ids whose content is known-final (installed by a canonical
|
|
544
565
|
// replacement). Late streamed deltas for these ids are stale stragglers and
|
|
545
566
|
// are ignored — decided by identity, never by comparing text to text.
|
|
546
567
|
const canonicalMessageIds = new Set();
|
|
547
568
|
const queuedRuns = [];
|
|
548
|
-
let
|
|
569
|
+
let queueDrainEpoch = 0;
|
|
570
|
+
let activeQueueDrainEpoch = null;
|
|
571
|
+
let attemptSequence = 0;
|
|
572
|
+
const messageDeliveries = new Map();
|
|
573
|
+
const deliveryRevision = signal(0, ...(ngDevMode ? [{ debugName: "deliveryRevision" }] : []));
|
|
574
|
+
let activeAttempt = null;
|
|
549
575
|
const subagentManager = new SubagentTracker({
|
|
550
576
|
subagentToolNames: options.subagentToolNames,
|
|
551
577
|
onSubagentChange: publishSubagents,
|
|
@@ -565,6 +591,135 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
565
591
|
* resetThreadState() and on bridge teardown.
|
|
566
592
|
*/
|
|
567
593
|
const reasoningTimingMap = new Map();
|
|
594
|
+
function notifyDeliveryChange() {
|
|
595
|
+
deliveryRevision.update(revision => revision + 1);
|
|
596
|
+
}
|
|
597
|
+
function beginAttempt(allowBaselineTail = false) {
|
|
598
|
+
if (activeAttempt?.awaitingFinalSync) {
|
|
599
|
+
historyAbortController?.abort();
|
|
600
|
+
}
|
|
601
|
+
if (activeAttempt && !activeAttempt.terminalOutcome) {
|
|
602
|
+
finalizeAttempt(activeAttempt, 'interrupted');
|
|
603
|
+
}
|
|
604
|
+
const baselineMessageIds = new Set();
|
|
605
|
+
for (const message of subjects.messages$.value) {
|
|
606
|
+
const id = message['id'];
|
|
607
|
+
if (typeof id === 'string')
|
|
608
|
+
baselineMessageIds.add(id);
|
|
609
|
+
}
|
|
610
|
+
attemptSequence += 1;
|
|
611
|
+
const attempt = {
|
|
612
|
+
generation: `attempt-${attemptSequence}-${Math.random().toString(36).slice(2, 10)}`,
|
|
613
|
+
messageIds: new Set(),
|
|
614
|
+
finalizedMessageIds: new Set(),
|
|
615
|
+
baselineMessageIds,
|
|
616
|
+
eligibleBaselineAssistantId: allowBaselineTail
|
|
617
|
+
? getTailAssistantMessageId(subjects.messages$.value)
|
|
618
|
+
: undefined,
|
|
619
|
+
sawAssistantChunk: false,
|
|
620
|
+
currentStepHasTerminalEvidence: false,
|
|
621
|
+
awaitingFinalSync: false,
|
|
622
|
+
};
|
|
623
|
+
activeAttempt = attempt;
|
|
624
|
+
return attempt;
|
|
625
|
+
}
|
|
626
|
+
function isCurrentExecution(controller, attempt) {
|
|
627
|
+
return abortController === controller && activeAttempt === attempt;
|
|
628
|
+
}
|
|
629
|
+
function setDelivery(id, delivery) {
|
|
630
|
+
const previous = messageDeliveries.get(id);
|
|
631
|
+
if (previous?.generation === delivery.generation
|
|
632
|
+
&& previous.phase === delivery.phase
|
|
633
|
+
&& (previous.phase !== 'complete' || delivery.phase !== 'complete' || previous.outcome === delivery.outcome)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
messageDeliveries.set(id, delivery);
|
|
637
|
+
notifyDeliveryChange();
|
|
638
|
+
}
|
|
639
|
+
function finalizeMessage(attempt, id, outcome) {
|
|
640
|
+
setDelivery(id, completeDelivery(attempt.generation, outcome));
|
|
641
|
+
attempt.finalizedMessageIds.add(id);
|
|
642
|
+
}
|
|
643
|
+
function finalizeAttempt(attempt, outcome) {
|
|
644
|
+
if (attempt.terminalOutcome)
|
|
645
|
+
return;
|
|
646
|
+
attempt.terminalOutcome = outcome;
|
|
647
|
+
for (const id of attempt.messageIds) {
|
|
648
|
+
if (attempt.finalizedMessageIds.has(id))
|
|
649
|
+
continue;
|
|
650
|
+
finalizeMessage(attempt, id, outcome);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function finishOutcome(attempt) {
|
|
654
|
+
return attempt.terminalOutcome
|
|
655
|
+
?? (attempt.currentStepHasTerminalEvidence || !attempt.sawAssistantChunk ? 'success' : 'interrupted');
|
|
656
|
+
}
|
|
657
|
+
async function finalizeClosedAttempt(controller, attempt) {
|
|
658
|
+
if (attempt.terminalOutcome)
|
|
659
|
+
return attempt.terminalOutcome;
|
|
660
|
+
const outcome = finishOutcome(attempt);
|
|
661
|
+
attempt.awaitingFinalSync = true;
|
|
662
|
+
try {
|
|
663
|
+
await refreshHistory(true, () => isCurrentExecution(controller, attempt) && !attempt.terminalOutcome);
|
|
664
|
+
}
|
|
665
|
+
finally {
|
|
666
|
+
attempt.awaitingFinalSync = false;
|
|
667
|
+
}
|
|
668
|
+
if (!isCurrentExecution(controller, attempt))
|
|
669
|
+
return null;
|
|
670
|
+
if (!attempt.terminalOutcome)
|
|
671
|
+
finalizeAttempt(attempt, outcome);
|
|
672
|
+
return attempt.terminalOutcome ?? outcome;
|
|
673
|
+
}
|
|
674
|
+
function trackAssistantMessages(messages) {
|
|
675
|
+
const attempt = activeAttempt;
|
|
676
|
+
if (!attempt || attempt.terminalOutcome)
|
|
677
|
+
return;
|
|
678
|
+
const assistantMessages = messages.filter(message => {
|
|
679
|
+
const raw = message;
|
|
680
|
+
const type = normalizeMessageType(typeof message._getType === 'function' ? message._getType() : raw['type']);
|
|
681
|
+
const id = typeof raw['id'] === 'string' ? raw['id'] : undefined;
|
|
682
|
+
return type === 'ai' && id && !attempt.finalizedMessageIds.has(id);
|
|
683
|
+
});
|
|
684
|
+
const newAssistantMessages = assistantMessages.filter(message => {
|
|
685
|
+
const id = message['id'];
|
|
686
|
+
return typeof id === 'string' && !attempt.baselineMessageIds.has(id);
|
|
687
|
+
});
|
|
688
|
+
const currentStepMessages = newAssistantMessages.length > 0
|
|
689
|
+
? newAssistantMessages
|
|
690
|
+
: assistantMessages.filter(message => message['id'] === attempt.eligibleBaselineAssistantId);
|
|
691
|
+
for (const message of currentStepMessages) {
|
|
692
|
+
const id = message['id'];
|
|
693
|
+
if (attempt.currentAssistantMessageId && attempt.currentAssistantMessageId !== id) {
|
|
694
|
+
finalizeMessage(attempt, attempt.currentAssistantMessageId, 'success');
|
|
695
|
+
attempt.currentStepHasTerminalEvidence = false;
|
|
696
|
+
}
|
|
697
|
+
attempt.currentAssistantMessageId = id;
|
|
698
|
+
attempt.messageIds.add(id);
|
|
699
|
+
attempt.sawAssistantChunk = true;
|
|
700
|
+
setDelivery(id, streamingDelivery(attempt.generation));
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function invalidateQueueDrain() {
|
|
704
|
+
queueDrainEpoch += 1;
|
|
705
|
+
activeQueueDrainEpoch = null;
|
|
706
|
+
}
|
|
707
|
+
function markNormalTerminal(event) {
|
|
708
|
+
const attempt = activeAttempt;
|
|
709
|
+
if (!attempt
|
|
710
|
+
|| attempt.terminalOutcome
|
|
711
|
+
|| !attempt.sawAssistantChunk
|
|
712
|
+
|| (getEventNamespace(event)?.length ?? 0) > 0)
|
|
713
|
+
return;
|
|
714
|
+
const baseType = getBaseEventType(event.type);
|
|
715
|
+
// These are the canonical state/snapshot signals available in the current
|
|
716
|
+
// transport contract. Iterator close alone is deliberately not terminal
|
|
717
|
+
// evidence: after assistant chunks, a close without one of these markers
|
|
718
|
+
// is classified as interrupted.
|
|
719
|
+
if (baseType === 'values' || baseType === 'messages/complete' || baseType === 'checkpoints') {
|
|
720
|
+
attempt.currentStepHasTerminalEvidence = true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
568
723
|
function resetThreadState() {
|
|
569
724
|
historyAbortController?.abort();
|
|
570
725
|
subjects.values$.next({});
|
|
@@ -584,10 +739,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
584
739
|
subagentManager.clear();
|
|
585
740
|
reasoningTimingMap.clear();
|
|
586
741
|
canonicalMessageIds.clear();
|
|
742
|
+
if (activeAttempt && !activeAttempt.terminalOutcome) {
|
|
743
|
+
finalizeAttempt(activeAttempt, 'interrupted');
|
|
744
|
+
}
|
|
745
|
+
messageDeliveries.clear();
|
|
746
|
+
activeAttempt = null;
|
|
747
|
+
notifyDeliveryChange();
|
|
587
748
|
}
|
|
588
749
|
function setThreadId(id, resetState) {
|
|
589
750
|
if (resetState) {
|
|
751
|
+
invalidateQueueDrain();
|
|
590
752
|
abortController?.abort();
|
|
753
|
+
lastPayload = null;
|
|
754
|
+
lastOptions = undefined;
|
|
591
755
|
}
|
|
592
756
|
currentThreadId = id;
|
|
593
757
|
if (resetState) {
|
|
@@ -602,11 +766,18 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
602
766
|
setThreadId(id, shouldReset);
|
|
603
767
|
});
|
|
604
768
|
destroy$.subscribe(() => {
|
|
769
|
+
invalidateQueueDrain();
|
|
605
770
|
abortController?.abort();
|
|
606
771
|
historyAbortController?.abort();
|
|
607
772
|
reasoningTimingMap.clear();
|
|
773
|
+
if (activeAttempt && !activeAttempt.terminalOutcome) {
|
|
774
|
+
finalizeAttempt(activeAttempt, 'interrupted');
|
|
775
|
+
}
|
|
776
|
+
messageDeliveries.clear();
|
|
777
|
+
activeAttempt = null;
|
|
778
|
+
notifyDeliveryChange();
|
|
608
779
|
});
|
|
609
|
-
async function refreshHistory(force = false) {
|
|
780
|
+
async function refreshHistory(force = false, isRelevant = () => true) {
|
|
610
781
|
const getHistory = transport.getHistory?.bind(transport);
|
|
611
782
|
if (!currentThreadId || !getHistory)
|
|
612
783
|
return;
|
|
@@ -616,8 +787,8 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
616
787
|
const threadId = currentThreadId;
|
|
617
788
|
subjects.isThreadLoading$.next(true);
|
|
618
789
|
try {
|
|
619
|
-
const history = await getHistory(threadId, controller.signal);
|
|
620
|
-
if (!controller.signal.aborted && currentThreadId === threadId) {
|
|
790
|
+
const history = await waitForHistory(getHistory(threadId, controller.signal), controller.signal);
|
|
791
|
+
if (!controller.signal.aborted && currentThreadId === threadId && isRelevant()) {
|
|
621
792
|
subjects.history$.next(history);
|
|
622
793
|
// Project the latest checkpoint into messages$ + values$:
|
|
623
794
|
// - On first connect (`force=false`): only when messages$ is
|
|
@@ -636,7 +807,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
636
807
|
// canonical surface for them; keeping a duplicate in values$
|
|
637
808
|
// would confuse downstream consumers reading both subjects.
|
|
638
809
|
delete restoredValues.messages;
|
|
639
|
-
subjects.messages$.next(restoredMessages);
|
|
810
|
+
subjects.messages$.next(preserveIds(subjects.messages$.value, restoredMessages));
|
|
640
811
|
subjects.values$.next(restoredValues);
|
|
641
812
|
// Rebuild derived subjects from the new authoritative messages$.
|
|
642
813
|
// Tool-call results displayed by chat-tool-calls come from
|
|
@@ -653,7 +824,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
653
824
|
}
|
|
654
825
|
}
|
|
655
826
|
catch (err) {
|
|
656
|
-
if (!controller.signal.aborted && err?.name !== 'AbortError') {
|
|
827
|
+
if (!controller.signal.aborted && isRelevant() && err?.name !== 'AbortError') {
|
|
657
828
|
subjects.error$.next(toAgentError(err));
|
|
658
829
|
}
|
|
659
830
|
}
|
|
@@ -664,6 +835,30 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
664
835
|
}
|
|
665
836
|
}
|
|
666
837
|
}
|
|
838
|
+
function waitForHistory(history, signal) {
|
|
839
|
+
if (signal.aborted)
|
|
840
|
+
return Promise.reject(createHistoryAbortError());
|
|
841
|
+
return new Promise((resolve, reject) => {
|
|
842
|
+
const onAbort = () => {
|
|
843
|
+
cleanup();
|
|
844
|
+
reject(createHistoryAbortError());
|
|
845
|
+
};
|
|
846
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
847
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
848
|
+
history.then(value => {
|
|
849
|
+
cleanup();
|
|
850
|
+
resolve(value);
|
|
851
|
+
}, error => {
|
|
852
|
+
cleanup();
|
|
853
|
+
reject(error);
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
function createHistoryAbortError() {
|
|
858
|
+
const error = new Error('History refresh aborted.');
|
|
859
|
+
error.name = 'AbortError';
|
|
860
|
+
return error;
|
|
861
|
+
}
|
|
667
862
|
function publishQueue() {
|
|
668
863
|
subjects.queue$.next(createQueueSnapshot());
|
|
669
864
|
}
|
|
@@ -719,24 +914,33 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
719
914
|
await Promise.all(entries.map(entry => cancelRun(entry.threadId, entry.id, new AbortController().signal)));
|
|
720
915
|
}
|
|
721
916
|
async function drainQueue() {
|
|
722
|
-
if (
|
|
917
|
+
if (activeQueueDrainEpoch !== null || queuedRuns.length === 0)
|
|
723
918
|
return;
|
|
724
|
-
|
|
919
|
+
queueDrainEpoch += 1;
|
|
920
|
+
const drainEpoch = queueDrainEpoch;
|
|
921
|
+
activeQueueDrainEpoch = drainEpoch;
|
|
725
922
|
try {
|
|
726
923
|
while (queuedRuns.length > 0) {
|
|
924
|
+
if (activeQueueDrainEpoch !== drainEpoch)
|
|
925
|
+
return;
|
|
727
926
|
const entry = queuedRuns.shift();
|
|
728
927
|
publishQueue();
|
|
729
928
|
if (!entry || !transport.joinStream)
|
|
730
929
|
continue;
|
|
731
|
-
await joinQueuedRun(entry);
|
|
930
|
+
await joinQueuedRun(entry, drainEpoch);
|
|
732
931
|
}
|
|
733
932
|
}
|
|
734
933
|
finally {
|
|
735
|
-
|
|
934
|
+
if (activeQueueDrainEpoch === drainEpoch)
|
|
935
|
+
activeQueueDrainEpoch = null;
|
|
736
936
|
}
|
|
737
937
|
}
|
|
738
|
-
async function joinQueuedRun(entry) {
|
|
739
|
-
|
|
938
|
+
async function joinQueuedRun(entry, drainEpoch) {
|
|
939
|
+
if (activeQueueDrainEpoch !== drainEpoch)
|
|
940
|
+
return;
|
|
941
|
+
const controller = new AbortController();
|
|
942
|
+
abortController = controller;
|
|
943
|
+
const attempt = beginAttempt(true);
|
|
740
944
|
const startedAt = Date.now();
|
|
741
945
|
captureRuntimeRequestTelemetry('join_queued');
|
|
742
946
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
|
|
@@ -746,19 +950,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
746
950
|
subjects.status$.next(ResourceStatus.Loading);
|
|
747
951
|
try {
|
|
748
952
|
const iter = transport.joinStream
|
|
749
|
-
? transport.joinStream(entry.threadId, entry.id, undefined,
|
|
953
|
+
? transport.joinStream(entry.threadId, entry.id, undefined, controller.signal)
|
|
750
954
|
: [];
|
|
751
955
|
for await (const event of iter) {
|
|
752
|
-
if (
|
|
956
|
+
if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
|
|
753
957
|
break;
|
|
754
958
|
processEvent(event);
|
|
755
959
|
}
|
|
756
|
-
if (!
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
960
|
+
if (!isCurrentExecution(controller, attempt))
|
|
961
|
+
return;
|
|
962
|
+
const outcome = await finalizeClosedAttempt(controller, attempt);
|
|
963
|
+
if (outcome === null)
|
|
964
|
+
return;
|
|
965
|
+
if (!controller.signal.aborted) {
|
|
966
|
+
if (outcome !== 'error') {
|
|
967
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
968
|
+
}
|
|
762
969
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
|
|
763
970
|
...telemetryProperties,
|
|
764
971
|
durationMs: Date.now() - startedAt,
|
|
@@ -766,19 +973,37 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
766
973
|
}
|
|
767
974
|
}
|
|
768
975
|
catch (err) {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
976
|
+
if (!isCurrentExecution(controller, attempt))
|
|
977
|
+
return;
|
|
978
|
+
if (attempt.terminalOutcome)
|
|
979
|
+
return;
|
|
980
|
+
if (isAbortError(err) && userAbortedControllers.has(controller)) {
|
|
981
|
+
finalizeAttempt(attempt, 'aborted');
|
|
982
|
+
subjects.error$.next(undefined);
|
|
983
|
+
subjects.status$.next(ResourceStatus.Idle);
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
987
|
+
subjects.error$.next(toAgentError(err));
|
|
988
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
989
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
|
|
990
|
+
...telemetryProperties,
|
|
991
|
+
durationMs: Date.now() - startedAt,
|
|
992
|
+
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
finally {
|
|
997
|
+
if (abortController === controller)
|
|
998
|
+
abortController = null;
|
|
776
999
|
}
|
|
777
1000
|
}
|
|
778
1001
|
async function runStream(payload, opts, requestType = 'submit') {
|
|
1002
|
+
invalidateQueueDrain();
|
|
779
1003
|
abortController?.abort();
|
|
780
|
-
|
|
781
|
-
|
|
1004
|
+
const controller = new AbortController();
|
|
1005
|
+
abortController = controller;
|
|
1006
|
+
const attempt = beginAttempt(requestType === 'resubmit' || (isRecord$1(opts?.command) && 'resume' in opts.command));
|
|
782
1007
|
const startedAt = Date.now();
|
|
783
1008
|
captureRuntimeRequestTelemetry(requestType);
|
|
784
1009
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
|
|
@@ -788,7 +1013,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
788
1013
|
subjects.toolProgress$.next([]);
|
|
789
1014
|
toolProgressMap.clear();
|
|
790
1015
|
canonicalMessageIds.clear();
|
|
791
|
-
lastPayload = payload;
|
|
1016
|
+
lastPayload = payload ?? null;
|
|
792
1017
|
lastOptions = opts;
|
|
793
1018
|
// Tracks whether at least one stream event has been processed this run.
|
|
794
1019
|
// Used to distinguish a mid-stream network interruption (kind:'interrupted')
|
|
@@ -813,31 +1038,43 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
813
1038
|
subjects.messages$.next([...existing, ...stamped]);
|
|
814
1039
|
}
|
|
815
1040
|
try {
|
|
816
|
-
const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ??
|
|
1041
|
+
const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? controller.signal, opts);
|
|
817
1042
|
for await (const event of iter) {
|
|
818
|
-
if (
|
|
1043
|
+
if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
|
|
819
1044
|
break;
|
|
820
1045
|
streamingStarted = true;
|
|
821
1046
|
processEvent(event);
|
|
822
1047
|
}
|
|
823
|
-
if (!
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1048
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1049
|
+
return finishOutcome(attempt);
|
|
1050
|
+
const outcome = await finalizeClosedAttempt(controller, attempt);
|
|
1051
|
+
if (outcome === null)
|
|
1052
|
+
return finishOutcome(attempt);
|
|
1053
|
+
if (!controller.signal.aborted) {
|
|
1054
|
+
if (outcome !== 'error') {
|
|
1055
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
1056
|
+
}
|
|
828
1057
|
await drainQueue();
|
|
829
1058
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
|
|
830
1059
|
...telemetryProperties,
|
|
831
1060
|
durationMs: Date.now() - startedAt,
|
|
832
1061
|
});
|
|
833
1062
|
}
|
|
1063
|
+
return outcome;
|
|
834
1064
|
}
|
|
835
1065
|
catch (err) {
|
|
836
|
-
if (
|
|
1066
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1067
|
+
return finishOutcome(attempt);
|
|
1068
|
+
if (attempt.terminalOutcome)
|
|
1069
|
+
return attempt.terminalOutcome;
|
|
1070
|
+
if (isAbortError(err) && userAbortedControllers.has(controller)) {
|
|
1071
|
+
finalizeAttempt(attempt, 'aborted');
|
|
837
1072
|
// User explicitly called stop() — treat as graceful idle, not an error.
|
|
1073
|
+
subjects.error$.next(undefined);
|
|
838
1074
|
subjects.status$.next(ResourceStatus.Idle);
|
|
839
1075
|
}
|
|
840
1076
|
else if (isAbortError(err)) {
|
|
1077
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
841
1078
|
// A non-user-requested abort: interrupted if a stream had started, else a
|
|
842
1079
|
// connect-phase failure. Never "aborted" (that's reserved for user stop).
|
|
843
1080
|
const e = streamingStarted
|
|
@@ -852,6 +1089,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
852
1089
|
});
|
|
853
1090
|
}
|
|
854
1091
|
else {
|
|
1092
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
855
1093
|
subjects.error$.next(toAgentError(err));
|
|
856
1094
|
subjects.status$.next(ResourceStatus.Error);
|
|
857
1095
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
|
|
@@ -860,11 +1098,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
860
1098
|
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
861
1099
|
});
|
|
862
1100
|
}
|
|
1101
|
+
return finishOutcome(attempt);
|
|
1102
|
+
}
|
|
1103
|
+
finally {
|
|
1104
|
+
if (abortController === controller)
|
|
1105
|
+
abortController = null;
|
|
863
1106
|
}
|
|
864
1107
|
}
|
|
865
1108
|
function processEvent(event) {
|
|
866
1109
|
const baseType = getBaseEventType(event.type);
|
|
867
1110
|
const namespace = getEventNamespace(event);
|
|
1111
|
+
if (baseType === 'checkpoints' || baseType === 'messages/complete') {
|
|
1112
|
+
markNormalTerminal(event);
|
|
1113
|
+
}
|
|
868
1114
|
if (isMessagesEvent(event.type)) {
|
|
869
1115
|
const msgs = normalizeMessages(event);
|
|
870
1116
|
if (!msgs)
|
|
@@ -887,8 +1133,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
887
1133
|
// Partial and message-tuple events are incremental. Merge them by id
|
|
888
1134
|
// so optimistic human messages and earlier tool messages are preserved.
|
|
889
1135
|
if (event.type === 'messages/partial' || event.messageMetadata) {
|
|
1136
|
+
if (!isTranscriptMessageEvent(event, options.transcriptNodeNames)) {
|
|
1137
|
+
storeMessageMetadata(normalized, event);
|
|
1138
|
+
syncSubagentsFromMessages(normalized);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
890
1141
|
const mode = event.messageMetadata ? 'delta' : 'snapshot';
|
|
891
|
-
|
|
1142
|
+
const affectedMessageIds = new Set();
|
|
1143
|
+
const merged = mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds, affectedMessageIds, activeAttempt?.currentAssistantMessageId !== undefined
|
|
1144
|
+
&& activeAttempt.currentStepHasTerminalEvidence !== true);
|
|
1145
|
+
subjects.messages$.next(merged);
|
|
1146
|
+
if (!isSubagentNamespace(namespace)) {
|
|
1147
|
+
trackAssistantMessages(merged.filter(message => {
|
|
1148
|
+
const id = message['id'];
|
|
1149
|
+
return typeof id === 'string' && affectedMessageIds.has(id);
|
|
1150
|
+
}));
|
|
1151
|
+
}
|
|
892
1152
|
if (isLgTraceEnabled()) {
|
|
893
1153
|
const msgs = subjects.messages$.value;
|
|
894
1154
|
const last = msgs[msgs.length - 1];
|
|
@@ -903,8 +1163,17 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
903
1163
|
else {
|
|
904
1164
|
// Preserve existing ids by content so the final-id swap doesn't
|
|
905
1165
|
// tear down the chat-message DOM (and its streaming-md renderer).
|
|
906
|
-
|
|
1166
|
+
const affectedMessageIds = new Set();
|
|
1167
|
+
const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds);
|
|
1168
|
+
subjects.messages$.next(preserved);
|
|
1169
|
+
if (!isSubagentNamespace(namespace)) {
|
|
1170
|
+
trackAssistantMessages(preserved.filter(message => {
|
|
1171
|
+
const id = message['id'];
|
|
1172
|
+
return typeof id === 'string' && affectedMessageIds.has(id);
|
|
1173
|
+
}));
|
|
1174
|
+
}
|
|
907
1175
|
}
|
|
1176
|
+
markNormalTerminal(event);
|
|
908
1177
|
storeMessageMetadata(normalized, event);
|
|
909
1178
|
syncSubagentsFromMessages(normalized);
|
|
910
1179
|
syncToolCallsFromMessages();
|
|
@@ -920,6 +1189,15 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
920
1189
|
updateSubagentValues(namespace, vals);
|
|
921
1190
|
break;
|
|
922
1191
|
}
|
|
1192
|
+
if ((namespace?.length ?? 0) === 0) {
|
|
1193
|
+
if (hasInterrupts(vals)) {
|
|
1194
|
+
if (activeAttempt)
|
|
1195
|
+
finalizeAttempt(activeAttempt, 'paused');
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
markNormalTerminal(event);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
923
1201
|
if (vals != null) {
|
|
924
1202
|
extractInterrupts(vals, subjects);
|
|
925
1203
|
subjects.values$.next(vals);
|
|
@@ -988,13 +1266,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
988
1266
|
break;
|
|
989
1267
|
}
|
|
990
1268
|
case 'error':
|
|
1269
|
+
if (activeAttempt)
|
|
1270
|
+
finalizeAttempt(activeAttempt, 'error');
|
|
991
1271
|
subjects.error$.next(toAgentError(event['error']));
|
|
992
1272
|
subjects.status$.next(ResourceStatus.Error);
|
|
993
1273
|
break;
|
|
994
1274
|
case 'interrupt':
|
|
1275
|
+
if (activeAttempt)
|
|
1276
|
+
finalizeAttempt(activeAttempt, 'paused');
|
|
995
1277
|
subjects.interrupt$.next(event['interrupt']);
|
|
996
1278
|
break;
|
|
997
1279
|
case 'interrupts':
|
|
1280
|
+
if (activeAttempt)
|
|
1281
|
+
finalizeAttempt(activeAttempt, 'paused');
|
|
998
1282
|
subjects.interrupts$.next(event['interrupts']);
|
|
999
1283
|
break;
|
|
1000
1284
|
case 'custom': {
|
|
@@ -1127,19 +1411,24 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1127
1411
|
submit: async (payload, opts) => {
|
|
1128
1412
|
if (opts?.multitaskStrategy === 'enqueue' && subjects.status$.value === ResourceStatus.Loading) {
|
|
1129
1413
|
await enqueueRun(payload, opts);
|
|
1130
|
-
return;
|
|
1414
|
+
return 'success';
|
|
1131
1415
|
}
|
|
1132
|
-
|
|
1416
|
+
return runStream(payload, opts);
|
|
1133
1417
|
},
|
|
1134
1418
|
stop: async () => {
|
|
1135
|
-
|
|
1419
|
+
invalidateQueueDrain();
|
|
1420
|
+
const shouldAbortAttempt = Boolean(abortController && activeAttempt && !activeAttempt.terminalOutcome);
|
|
1421
|
+
if (shouldAbortAttempt && abortController)
|
|
1422
|
+
userAbortedControllers.add(abortController);
|
|
1423
|
+
if (shouldAbortAttempt && activeAttempt)
|
|
1424
|
+
finalizeAttempt(activeAttempt, 'aborted');
|
|
1136
1425
|
abortController?.abort();
|
|
1426
|
+
if (activeAttempt?.awaitingFinalSync)
|
|
1427
|
+
historyAbortController?.abort();
|
|
1137
1428
|
await clearQueue();
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
// catch never fires) or when clearQueue() raised an error.
|
|
1142
|
-
if (subjects.status$.value !== ResourceStatus.Idle) {
|
|
1429
|
+
// Set Idle synchronously for an active user cancellation. Attempts that
|
|
1430
|
+
// already reached a terminal outcome retain their existing status.
|
|
1431
|
+
if (shouldAbortAttempt && subjects.status$.value !== ResourceStatus.Idle) {
|
|
1143
1432
|
subjects.status$.next(ResourceStatus.Idle);
|
|
1144
1433
|
}
|
|
1145
1434
|
},
|
|
@@ -1149,8 +1438,12 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1149
1438
|
joinStream: async (runId, lastEventId) => {
|
|
1150
1439
|
if (!currentThreadId)
|
|
1151
1440
|
return;
|
|
1441
|
+
invalidateQueueDrain();
|
|
1152
1442
|
abortController?.abort();
|
|
1153
|
-
|
|
1443
|
+
const controller = new AbortController();
|
|
1444
|
+
abortController = controller;
|
|
1445
|
+
const attempt = beginAttempt(true);
|
|
1446
|
+
const threadId = currentThreadId;
|
|
1154
1447
|
const startedAt = Date.now();
|
|
1155
1448
|
captureRuntimeRequestTelemetry('join');
|
|
1156
1449
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
|
|
@@ -1158,34 +1451,61 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1158
1451
|
subjects.toolProgress$.next([]);
|
|
1159
1452
|
toolProgressMap.clear();
|
|
1160
1453
|
subjects.status$.next(ResourceStatus.Loading);
|
|
1454
|
+
subjects.error$.next(undefined);
|
|
1161
1455
|
try {
|
|
1162
1456
|
const iter = transport.joinStream
|
|
1163
|
-
? transport.joinStream(
|
|
1457
|
+
? transport.joinStream(threadId, runId, lastEventId, controller.signal)
|
|
1164
1458
|
: [];
|
|
1165
1459
|
for await (const event of iter) {
|
|
1460
|
+
if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
|
|
1461
|
+
break;
|
|
1166
1462
|
processEvent(event);
|
|
1167
1463
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1464
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1465
|
+
return;
|
|
1466
|
+
const outcome = await finalizeClosedAttempt(controller, attempt);
|
|
1467
|
+
if (outcome === null)
|
|
1468
|
+
return;
|
|
1469
|
+
if (!controller.signal.aborted) {
|
|
1470
|
+
if (outcome !== 'error') {
|
|
1471
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
1472
|
+
}
|
|
1473
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
|
|
1474
|
+
...telemetryProperties,
|
|
1475
|
+
durationMs: Date.now() - startedAt,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1174
1478
|
}
|
|
1175
1479
|
catch (err) {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1480
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1481
|
+
return;
|
|
1482
|
+
if (attempt.terminalOutcome)
|
|
1483
|
+
return;
|
|
1484
|
+
if (isAbortError(err) && userAbortedControllers.has(controller)) {
|
|
1485
|
+
finalizeAttempt(attempt, 'aborted');
|
|
1486
|
+
subjects.error$.next(undefined);
|
|
1487
|
+
subjects.status$.next(ResourceStatus.Idle);
|
|
1488
|
+
}
|
|
1489
|
+
else {
|
|
1490
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
1491
|
+
subjects.error$.next(toAgentError(err));
|
|
1492
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
1493
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
|
|
1494
|
+
...telemetryProperties,
|
|
1495
|
+
durationMs: Date.now() - startedAt,
|
|
1496
|
+
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
finally {
|
|
1501
|
+
if (abortController === controller)
|
|
1502
|
+
abortController = null;
|
|
1183
1503
|
}
|
|
1184
1504
|
},
|
|
1185
1505
|
resubmitLast: async () => {
|
|
1186
|
-
if (lastPayload
|
|
1187
|
-
|
|
1188
|
-
|
|
1506
|
+
if (lastPayload === null)
|
|
1507
|
+
return 'not-started';
|
|
1508
|
+
return runStream(lastPayload, lastOptions, 'resubmit');
|
|
1189
1509
|
},
|
|
1190
1510
|
getReasoningDurationMs: (id) => {
|
|
1191
1511
|
const entry = reasoningTimingMap.get(id);
|
|
@@ -1195,6 +1515,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1195
1515
|
return undefined;
|
|
1196
1516
|
return entry.endedAt - entry.startedAt;
|
|
1197
1517
|
},
|
|
1518
|
+
getMessageDelivery: (id) => messageDeliveries.get(id) ?? staticDelivery(id),
|
|
1519
|
+
getSubagentMessageDelivery: (toolCallId, message) => subagentManager.getMessageDelivery(toolCallId, message),
|
|
1520
|
+
deliveryRevision,
|
|
1198
1521
|
updateState: async (values, opts) => {
|
|
1199
1522
|
// No-op when there is no thread yet or the transport doesn't support
|
|
1200
1523
|
// updateState (e.g. MockAgentTransport in unit tests without a threadId).
|
|
@@ -1208,6 +1531,14 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1208
1531
|
},
|
|
1209
1532
|
};
|
|
1210
1533
|
}
|
|
1534
|
+
function isTranscriptMessageEvent(event, transcriptNodeNames) {
|
|
1535
|
+
if (!transcriptNodeNames || transcriptNodeNames.length === 0)
|
|
1536
|
+
return true;
|
|
1537
|
+
const node = event.messageMetadata?.['langgraph_node'];
|
|
1538
|
+
if (typeof node !== 'string')
|
|
1539
|
+
return true;
|
|
1540
|
+
return transcriptNodeNames.includes(node);
|
|
1541
|
+
}
|
|
1211
1542
|
/**
|
|
1212
1543
|
* Extracts the payload data from a normalized SDK event.
|
|
1213
1544
|
*
|
|
@@ -1242,6 +1573,12 @@ function extractInterrupts(payload, subjects) {
|
|
|
1242
1573
|
subjects.interrupts$.next([]);
|
|
1243
1574
|
}
|
|
1244
1575
|
}
|
|
1576
|
+
function hasInterrupts(payload) {
|
|
1577
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1578
|
+
return false;
|
|
1579
|
+
const raw = payload['__interrupt__'];
|
|
1580
|
+
return Array.isArray(raw) && raw.length > 0;
|
|
1581
|
+
}
|
|
1245
1582
|
/**
|
|
1246
1583
|
* Projects pending interrupts from the latest history checkpoint onto the
|
|
1247
1584
|
* interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under
|
|
@@ -1337,7 +1674,7 @@ function normalizeMessages(event) {
|
|
|
1337
1674
|
* carry the full text we collapse them, keeping the older slot's id so
|
|
1338
1675
|
* track-by-id stays stable in the chat list.
|
|
1339
1676
|
*/
|
|
1340
|
-
function collapseAdjacentAi(messages) {
|
|
1677
|
+
function collapseAdjacentAi(messages, affectedMessageIds, allowCrossIdAiMerge = true) {
|
|
1341
1678
|
if (messages.length < 2)
|
|
1342
1679
|
return messages;
|
|
1343
1680
|
const out = [];
|
|
@@ -1352,13 +1689,21 @@ function collapseAdjacentAi(messages) {
|
|
|
1352
1689
|
if (lastType === 'ai' && msgType === 'ai') {
|
|
1353
1690
|
const lastText = extractText(last.content);
|
|
1354
1691
|
const msgText = extractText(msg.content);
|
|
1355
|
-
|
|
1692
|
+
const lastRaw = last;
|
|
1693
|
+
const msgRaw = msg;
|
|
1694
|
+
const differentIds = lastRaw['id'] !== msgRaw['id'];
|
|
1695
|
+
if (!(differentIds && !allowCrossIdAiMerge) && (lastText.length === 0
|
|
1356
1696
|
|| msgText.length === 0
|
|
1357
1697
|
|| lastText === msgText
|
|
1358
1698
|
|| lastText.startsWith(msgText)
|
|
1359
|
-
|| msgText.startsWith(lastText)) {
|
|
1699
|
+
|| msgText.startsWith(lastText))) {
|
|
1360
1700
|
// Keep the longer content; preserve last (older) id and metadata.
|
|
1361
1701
|
const longerText = msgText.length >= lastText.length ? msgText : lastText;
|
|
1702
|
+
const lastId = last['id'];
|
|
1703
|
+
const msgId = msg['id'];
|
|
1704
|
+
if (typeof msgId === 'string' && affectedMessageIds?.delete(msgId) && typeof lastId === 'string') {
|
|
1705
|
+
affectedMessageIds.add(lastId);
|
|
1706
|
+
}
|
|
1362
1707
|
out[out.length - 1] = { ...last, content: longerText };
|
|
1363
1708
|
continue;
|
|
1364
1709
|
}
|
|
@@ -1367,7 +1712,7 @@ function collapseAdjacentAi(messages) {
|
|
|
1367
1712
|
}
|
|
1368
1713
|
return out;
|
|
1369
1714
|
}
|
|
1370
|
-
function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds) {
|
|
1715
|
+
function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds, affectedMessageIds, allowCrossIdAiMerge = true) {
|
|
1371
1716
|
const merged = [...existing];
|
|
1372
1717
|
for (const msg of incoming) {
|
|
1373
1718
|
const rawIn = msg;
|
|
@@ -1381,6 +1726,9 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1381
1726
|
// prevents DOM teardown + animation restarts mid-stream.
|
|
1382
1727
|
if (idx < 0) {
|
|
1383
1728
|
idx = findContentMatch(merged, msg);
|
|
1729
|
+
if (idx >= 0 && !canMergeCrossIdAi(merged[idx], msg, allowCrossIdAiMerge)) {
|
|
1730
|
+
idx = -1;
|
|
1731
|
+
}
|
|
1384
1732
|
}
|
|
1385
1733
|
// When an AIMessageChunk arrives without an id-match or content-prefix
|
|
1386
1734
|
// match, treat the trailing AI message as its accumulator. The
|
|
@@ -1395,6 +1743,8 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1395
1743
|
? merged[i]._getType()
|
|
1396
1744
|
: merged[i]['type']);
|
|
1397
1745
|
if (t === 'ai') {
|
|
1746
|
+
if (!canMergeCrossIdAi(merged[i], msg, allowCrossIdAiMerge))
|
|
1747
|
+
break;
|
|
1398
1748
|
idx = i;
|
|
1399
1749
|
break;
|
|
1400
1750
|
}
|
|
@@ -1450,7 +1800,10 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1450
1800
|
if (existingId) {
|
|
1451
1801
|
next['id'] = existingId;
|
|
1452
1802
|
}
|
|
1803
|
+
const changed = mode === 'delta' || messageChangedForDelivery(existing, next);
|
|
1453
1804
|
merged[idx] = next;
|
|
1805
|
+
if (targetId && changed)
|
|
1806
|
+
affectedMessageIds?.add(targetId);
|
|
1454
1807
|
}
|
|
1455
1808
|
else {
|
|
1456
1809
|
const incomingRaw = msg;
|
|
@@ -1467,9 +1820,19 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1467
1820
|
const next = { ...msg };
|
|
1468
1821
|
next['reasoning'] = initialReasoning;
|
|
1469
1822
|
merged.push(next);
|
|
1823
|
+
const nextId = next['id'];
|
|
1824
|
+
if (typeof nextId === 'string')
|
|
1825
|
+
affectedMessageIds?.add(nextId);
|
|
1470
1826
|
}
|
|
1471
1827
|
}
|
|
1472
|
-
return collapseAdjacentAi(merged);
|
|
1828
|
+
return collapseAdjacentAi(merged, affectedMessageIds, allowCrossIdAiMerge);
|
|
1829
|
+
}
|
|
1830
|
+
function canMergeCrossIdAi(candidate, incoming, allowCrossIdAiMerge) {
|
|
1831
|
+
const candidateRaw = candidate;
|
|
1832
|
+
const incomingRaw = incoming;
|
|
1833
|
+
if (candidateRaw['id'] === incomingRaw['id'])
|
|
1834
|
+
return true;
|
|
1835
|
+
return allowCrossIdAiMerge;
|
|
1473
1836
|
}
|
|
1474
1837
|
/**
|
|
1475
1838
|
* Merge an incoming chunk's content into prior accumulated content for the
|
|
@@ -1627,9 +1990,15 @@ function accumulateReasoning(existing, incoming) {
|
|
|
1627
1990
|
* (role, content) matches positionally and the existing id differs. Keeps
|
|
1628
1991
|
* track-by-id stable across server echoes and final-id swaps.
|
|
1629
1992
|
*/
|
|
1630
|
-
function preserveIds(existing, incoming) {
|
|
1631
|
-
if (existing.length === 0)
|
|
1632
|
-
|
|
1993
|
+
function preserveIds(existing, incoming, affectedMessageIds) {
|
|
1994
|
+
if (existing.length === 0) {
|
|
1995
|
+
for (const message of incoming) {
|
|
1996
|
+
const id = message['id'];
|
|
1997
|
+
if (typeof id === 'string')
|
|
1998
|
+
affectedMessageIds?.add(id);
|
|
1999
|
+
}
|
|
2000
|
+
return collapseAdjacentAi(incoming, affectedMessageIds);
|
|
2001
|
+
}
|
|
1633
2002
|
const usedExisting = new Set();
|
|
1634
2003
|
const remapped = incoming.map((msg, i) => {
|
|
1635
2004
|
const inRaw = msg;
|
|
@@ -1643,15 +2012,40 @@ function preserveIds(existing, incoming) {
|
|
|
1643
2012
|
// Fallback: any unused existing message with matching role+content.
|
|
1644
2013
|
matchIdx = existing.findIndex((m, j) => !usedExisting.has(j) && sameRoleAndContent(m, msg));
|
|
1645
2014
|
}
|
|
1646
|
-
if (matchIdx < 0)
|
|
2015
|
+
if (matchIdx < 0) {
|
|
2016
|
+
if (typeof inId === 'string')
|
|
2017
|
+
affectedMessageIds?.add(inId);
|
|
1647
2018
|
return msg;
|
|
2019
|
+
}
|
|
1648
2020
|
usedExisting.add(matchIdx);
|
|
1649
2021
|
const existingId = existing[matchIdx]['id'];
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
2022
|
+
const remappedMessage = !existingId || existingId === inId
|
|
2023
|
+
? msg
|
|
2024
|
+
: { ...msg, id: existingId };
|
|
2025
|
+
if (typeof existingId === 'string' && messageChangedForDelivery(existing[matchIdx], remappedMessage)) {
|
|
2026
|
+
affectedMessageIds?.add(existingId);
|
|
2027
|
+
}
|
|
2028
|
+
return remappedMessage;
|
|
1653
2029
|
});
|
|
1654
|
-
return collapseAdjacentAi(remapped);
|
|
2030
|
+
return collapseAdjacentAi(remapped, affectedMessageIds);
|
|
2031
|
+
}
|
|
2032
|
+
function messageChangedForDelivery(existing, incoming) {
|
|
2033
|
+
const existingRaw = existing;
|
|
2034
|
+
const incomingRaw = incoming;
|
|
2035
|
+
const existingType = normalizeMessageType(typeof existing._getType === 'function' ? existing._getType() : existingRaw['type']);
|
|
2036
|
+
const incomingType = normalizeMessageType(typeof incoming._getType === 'function' ? incoming._getType() : incomingRaw['type']);
|
|
2037
|
+
if (existingType !== incomingType || extractText(existing.content) !== extractText(incoming.content)) {
|
|
2038
|
+
return true;
|
|
2039
|
+
}
|
|
2040
|
+
const existingReasoning = typeof existingRaw['reasoning'] === 'string'
|
|
2041
|
+
? existingRaw['reasoning']
|
|
2042
|
+
: extractReasoning(existingRaw['reasoning']);
|
|
2043
|
+
const incomingReasoning = typeof incomingRaw['reasoning'] === 'string'
|
|
2044
|
+
? incomingRaw['reasoning']
|
|
2045
|
+
: extractReasoning(incomingRaw['reasoning']);
|
|
2046
|
+
if (existingReasoning !== incomingReasoning)
|
|
2047
|
+
return true;
|
|
2048
|
+
return JSON.stringify(existingRaw['tool_calls'] ?? null) !== JSON.stringify(incomingRaw['tool_calls'] ?? null);
|
|
1655
2049
|
}
|
|
1656
2050
|
function sameRoleAndContent(a, b) {
|
|
1657
2051
|
const aType = normalizeMessageType(typeof a._getType === 'function' ? a._getType() : a['type']);
|
|
@@ -1723,6 +2117,14 @@ function normalizeMessageType(t) {
|
|
|
1723
2117
|
return 'system';
|
|
1724
2118
|
return t;
|
|
1725
2119
|
}
|
|
2120
|
+
function getTailAssistantMessageId(messages) {
|
|
2121
|
+
const tail = messages[messages.length - 1];
|
|
2122
|
+
if (!tail)
|
|
2123
|
+
return undefined;
|
|
2124
|
+
const raw = tail;
|
|
2125
|
+
const type = normalizeMessageType(typeof tail._getType === 'function' ? tail._getType() : raw['type']);
|
|
2126
|
+
return type === 'ai' && typeof raw['id'] === 'string' ? raw['id'] : undefined;
|
|
2127
|
+
}
|
|
1726
2128
|
function toSubagentRefs(subagents) {
|
|
1727
2129
|
const refs = new Map();
|
|
1728
2130
|
subagents.forEach((subagent, key) => {
|
|
@@ -1921,12 +2323,19 @@ function normalizeCitation(entry, fallbackIndex) {
|
|
|
1921
2323
|
}
|
|
1922
2324
|
return undefined;
|
|
1923
2325
|
};
|
|
2326
|
+
const publishedAt = e['publishedAt'];
|
|
2327
|
+
const normalizedPublishedAt = typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date
|
|
2328
|
+
? publishedAt
|
|
2329
|
+
: undefined;
|
|
1924
2330
|
return {
|
|
1925
2331
|
id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
|
|
1926
2332
|
index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
|
|
1927
2333
|
title: firstStr('title', 'name'),
|
|
1928
2334
|
url: firstStr('url', 'href', 'source'),
|
|
1929
2335
|
snippet: firstStr('snippet', 'content', 'excerpt'),
|
|
2336
|
+
sourceType: str('sourceType'),
|
|
2337
|
+
iconUrl: str('iconUrl'),
|
|
2338
|
+
publishedAt: normalizedPublishedAt,
|
|
1930
2339
|
extra: typeof e['extra'] === 'object' && e['extra'] !== null
|
|
1931
2340
|
? e['extra']
|
|
1932
2341
|
: undefined,
|
|
@@ -1959,6 +2368,41 @@ function mergeClientTools(payload, catalog) {
|
|
|
1959
2368
|
return payload;
|
|
1960
2369
|
return { ...payload, client_tools: catalog };
|
|
1961
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
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Prepend staged tool messages to a run payload's message list.
|
|
2391
|
+
*
|
|
2392
|
+
* Mirrors mergeClientTools: a null payload signals a no-input resume and must
|
|
2393
|
+
* stay null, so staged messages cannot ride along and are left buffered.
|
|
2394
|
+
*/
|
|
2395
|
+
function mergeStagedToolMessages(payload, staged) {
|
|
2396
|
+
if (staged.length === 0)
|
|
2397
|
+
return payload;
|
|
2398
|
+
if (payload === null || payload === undefined)
|
|
2399
|
+
return payload;
|
|
2400
|
+
if (typeof payload !== 'object' || Array.isArray(payload))
|
|
2401
|
+
return payload;
|
|
2402
|
+
const record = payload;
|
|
2403
|
+
const existing = Array.isArray(record['messages']) ? record['messages'] : [];
|
|
2404
|
+
return { ...record, messages: [...staged, ...existing] };
|
|
2405
|
+
}
|
|
1962
2406
|
/**
|
|
1963
2407
|
* Creates a ClientToolsCapability backed by a LangGraph submit function and
|
|
1964
2408
|
* a store of tool-call signals. Extracted into a factory so it can be
|
|
@@ -1973,14 +2417,26 @@ function mergeClientTools(payload, catalog) {
|
|
|
1973
2417
|
* yet — but ONLY when the run is not in progress (isLoading===false).
|
|
1974
2418
|
* The backend ends the run without emitting a ToolMessage result for
|
|
1975
2419
|
* client tools, so `result` stays undefined on those entries.
|
|
1976
|
-
* -
|
|
1977
|
-
*
|
|
2420
|
+
* - settle(id, result): marks the call as resolved, writes the local result,
|
|
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
|
|
2423
|
+
* starting a run — the settlement path for tool groups that never continue.
|
|
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.
|
|
2431
|
+
* - resolve(id, result): settles the result, then issues a NEW run on the SAME
|
|
2432
|
+
* thread by calling submitFn with a non-destructive ToolMessage snapshot:
|
|
1978
2433
|
* input: {
|
|
1979
2434
|
* messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
|
|
1980
2435
|
* client_tools: catalog(),
|
|
1981
2436
|
* }
|
|
1982
|
-
* The
|
|
1983
|
-
*
|
|
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
|
|
1984
2440
|
* full tool catalog on the continuation run.
|
|
1985
2441
|
*
|
|
1986
2442
|
* Catalog shipping: the catalog is NOT injected by this factory's
|
|
@@ -1990,62 +2446,199 @@ function mergeClientTools(payload, catalog) {
|
|
|
1990
2446
|
* before forwarding to manager.submit. This keeps injection concerns
|
|
1991
2447
|
* co-located with the run-issuing call sites.
|
|
1992
2448
|
*/
|
|
1993
|
-
function createClientToolsCapability(submitFn, store) {
|
|
2449
|
+
function createClientToolsCapability(submitFn, store, persistFn, currentThreadIdFn) {
|
|
1994
2450
|
const catalog = signal([], ...(ngDevMode ? [{ debugName: "catalog" }] : []));
|
|
1995
2451
|
const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
|
|
2452
|
+
const toolMessageBuffer = [];
|
|
2453
|
+
let flushInFlight;
|
|
2454
|
+
// Bumped whenever a thread reset discards staged state. Every snapshot keeps
|
|
2455
|
+
// its generation, making acknowledgments from the prior thread no-ops.
|
|
2456
|
+
let bufferGeneration = 0;
|
|
2457
|
+
// Tool calls belonging to threads we have left. A handler still running when
|
|
2458
|
+
// the user switches threads settles AFTER the switch, by which point both the
|
|
2459
|
+
// store and the current thread id already describe the NEW thread — the id is
|
|
2460
|
+
// the only durable way left to recognise the result as stale.
|
|
2461
|
+
const retiredToolCallIds = new Set();
|
|
1996
2462
|
const pending = computed(() => {
|
|
1997
2463
|
// Client tools are only actionable after the run ends (the backend
|
|
1998
2464
|
// signals this by ending the run WITHOUT emitting a ToolMessage result
|
|
1999
2465
|
// for client tools).
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2466
|
+
return selectPendingClientToolCalls({
|
|
2467
|
+
isLoading: store.isLoading(),
|
|
2468
|
+
toolCalls: store.toolCalls(),
|
|
2469
|
+
catalogNames: new Set(catalog().map((s) => s.name)),
|
|
2470
|
+
resolvedIds: resolvedIds(),
|
|
2471
|
+
});
|
|
2005
2472
|
}, ...(ngDevMode ? [{ debugName: "pending" }] : []));
|
|
2473
|
+
function settleResult(id, result) {
|
|
2474
|
+
// Mark as resolved first so pending() drops it immediately.
|
|
2475
|
+
resolvedIds.update((s) => new Set(s).add(id));
|
|
2476
|
+
// Cast rather than rely on discriminant narrowing: consumer apps that
|
|
2477
|
+
// compile this source with `strictNullChecks: false` don't narrow the
|
|
2478
|
+
// ClientToolResult union in a ternary.
|
|
2479
|
+
const ok = result.ok;
|
|
2480
|
+
const value = result.value;
|
|
2481
|
+
const error = result.error;
|
|
2482
|
+
// Write the outcome onto the LOCAL ToolCall (via the adapter's override
|
|
2483
|
+
// layer). The client tool DID produce a result client-side, so this is
|
|
2484
|
+
// semantically correct — and it freezes the transcript card: the mounted
|
|
2485
|
+
// ask component re-renders with its own emitted value as props and can
|
|
2486
|
+
// branch to a resolved/frozen state. Without this, the LOCAL tool call
|
|
2487
|
+
// never gets a result (only the backend ToolMessage does) so the card
|
|
2488
|
+
// stays interactive forever.
|
|
2489
|
+
store.applyClientResult(id, {
|
|
2490
|
+
result: ok ? value : { error },
|
|
2491
|
+
...(ok ? {} : { error, status: 'error' }),
|
|
2492
|
+
});
|
|
2493
|
+
const content = ok
|
|
2494
|
+
? safeStringify(value)
|
|
2495
|
+
: `Error: ${error}`;
|
|
2496
|
+
// The tool call belongs to a thread the user has already left, so there is
|
|
2497
|
+
// nowhere valid to send this result: the current thread has no matching
|
|
2498
|
+
// tool call, and the old thread is no longer the write target.
|
|
2499
|
+
if (retiredToolCallIds.has(id)) {
|
|
2500
|
+
console.warn(`Discarding client tool result for ${id}: its thread is no longer active.`);
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
// Message shape: both `type` and `role` are set for compatibility —
|
|
2504
|
+
// the LangGraph server's add_messages coercion reads `role` (Python
|
|
2505
|
+
// side), while the bridge's local optimistic-message path reads `type`
|
|
2506
|
+
// (via toMessage's normalizeMessageType). This mirrors the human-message
|
|
2507
|
+
// shape used in buildSubmitUpdate (agent.fn.ts line 732).
|
|
2508
|
+
toolMessageBuffer.push({
|
|
2509
|
+
threadId: currentThreadIdFn?.() ?? null,
|
|
2510
|
+
message: {
|
|
2511
|
+
id: `client-tool-result-${id}`,
|
|
2512
|
+
type: 'tool',
|
|
2513
|
+
role: 'tool',
|
|
2514
|
+
tool_call_id: id,
|
|
2515
|
+
content,
|
|
2516
|
+
},
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
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.
|
|
2526
|
+
*
|
|
2527
|
+
* A null on either side means "thread not tracked yet" (no threadId option
|
|
2528
|
+
* and no run has reported one); those are kept, since there is no evidence of
|
|
2529
|
+
* a switch and dropping them would lose results on untracked transports.
|
|
2530
|
+
*/
|
|
2531
|
+
function snapshotToolMessages() {
|
|
2532
|
+
const current = currentThreadIdFn?.() ?? null;
|
|
2533
|
+
for (let index = toolMessageBuffer.length - 1; index >= 0; index -= 1) {
|
|
2534
|
+
const entry = toolMessageBuffer[index];
|
|
2535
|
+
const stale = entry.threadId !== null && current !== null && entry.threadId !== current;
|
|
2536
|
+
if (stale) {
|
|
2537
|
+
console.warn(`Discarding a client tool result staged for thread ${entry.threadId}; ` +
|
|
2538
|
+
`the active thread is now ${current}.`);
|
|
2539
|
+
toolMessageBuffer.splice(index, 1);
|
|
2540
|
+
}
|
|
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
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Persist one snapshot. Only that snapshot is acknowledged after persistence
|
|
2563
|
+
* succeeds; failure leaves its exact entries staged for retry or submit.
|
|
2564
|
+
*/
|
|
2565
|
+
function runFlush() {
|
|
2566
|
+
const batch = snapshotToolMessages();
|
|
2567
|
+
if (batch.messages.length === 0)
|
|
2568
|
+
return Promise.resolve();
|
|
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();
|
|
2576
|
+
})
|
|
2577
|
+
.catch((err) => {
|
|
2578
|
+
console.warn(`Client tool flush failed; ${batch.messages.length} result(s) remain staged for the next run.`, err);
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2006
2581
|
const capability = {
|
|
2007
2582
|
catalog,
|
|
2008
2583
|
setCatalog(specs) {
|
|
2009
2584
|
catalog.set([...specs]);
|
|
2010
2585
|
},
|
|
2011
2586
|
pending,
|
|
2587
|
+
settle(id, result) {
|
|
2588
|
+
settleResult(id, result);
|
|
2589
|
+
},
|
|
2590
|
+
snapshotToolMessages() {
|
|
2591
|
+
return snapshotToolMessages();
|
|
2592
|
+
},
|
|
2593
|
+
/**
|
|
2594
|
+
* Discard everything staged. Called when the active thread changes: a
|
|
2595
|
+
* ToolMessage only makes sense against the thread whose AIMessage produced
|
|
2596
|
+
* its tool_call_id, so carrying it over would poison the new thread.
|
|
2597
|
+
*/
|
|
2598
|
+
clearStagedToolMessages() {
|
|
2599
|
+
// Retire the outgoing thread's tool calls so a handler that settles after
|
|
2600
|
+
// the switch is recognised as stale. Read the store BEFORE it resets —
|
|
2601
|
+
// agent.fn.ts calls this ahead of manager.switchThread for that reason.
|
|
2602
|
+
for (const toolCall of store.toolCalls())
|
|
2603
|
+
retiredToolCallIds.add(toolCall.id);
|
|
2604
|
+
toolMessageBuffer.length = 0;
|
|
2605
|
+
// Invalidate every outstanding snapshot acknowledgment from the old thread.
|
|
2606
|
+
bufferGeneration += 1;
|
|
2607
|
+
},
|
|
2608
|
+
flush() {
|
|
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;
|
|
2625
|
+
},
|
|
2012
2626
|
resolve(id, result) {
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
//
|
|
2016
|
-
//
|
|
2017
|
-
//
|
|
2018
|
-
|
|
2019
|
-
const
|
|
2020
|
-
const error = result.error;
|
|
2021
|
-
// Write the outcome onto the LOCAL ToolCall (via the adapter's override
|
|
2022
|
-
// layer). The client tool DID produce a result client-side, so this is
|
|
2023
|
-
// semantically correct — and it freezes the transcript card: the mounted
|
|
2024
|
-
// ask component re-renders with its own emitted value as props and can
|
|
2025
|
-
// branch to a resolved/frozen state. Without this, the LOCAL tool call
|
|
2026
|
-
// never gets a result (only the backend ToolMessage does) so the card
|
|
2027
|
-
// stays interactive forever.
|
|
2028
|
-
store.applyClientResult(id, {
|
|
2029
|
-
result: ok ? value : { error },
|
|
2030
|
-
...(ok ? {} : { error, status: 'error' }),
|
|
2031
|
-
});
|
|
2032
|
-
const content = ok
|
|
2033
|
-
? safeStringify(value)
|
|
2034
|
-
: `Error: ${error}`;
|
|
2035
|
-
// Issue a new run on the same thread. LangGraph's add_messages reducer
|
|
2036
|
-
// appends the ToolMessage to the thread state. `client_tools` is
|
|
2037
|
-
// included so the model sees the full tool catalog on the continuation.
|
|
2038
|
-
//
|
|
2039
|
-
// Message shape: both `type` and `role` are set for compatibility —
|
|
2040
|
-
// the LangGraph server's add_messages coercion reads `role` (Python
|
|
2041
|
-
// side), while the bridge's local optimistic-message path reads `type`
|
|
2042
|
-
// (via toMessage's normalizeMessageType). This mirrors the human-message
|
|
2043
|
-
// shape used in buildSubmitUpdate (agent.fn.ts line 732).
|
|
2627
|
+
settleResult(id, result);
|
|
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();
|
|
2044
2634
|
const toolPayload = {
|
|
2045
|
-
messages:
|
|
2635
|
+
messages: batch.messages,
|
|
2046
2636
|
client_tools: catalog(),
|
|
2047
2637
|
};
|
|
2048
|
-
void submitFn(toolPayload)
|
|
2638
|
+
void submitFn(toolPayload, undefined, batch).then((outcome) => {
|
|
2639
|
+
if (outcome === 'success')
|
|
2640
|
+
batch.acknowledge();
|
|
2641
|
+
});
|
|
2049
2642
|
},
|
|
2050
2643
|
};
|
|
2051
2644
|
return capability;
|
|
@@ -2156,10 +2749,22 @@ function agent(options) {
|
|
|
2156
2749
|
});
|
|
2157
2750
|
const custom$ = new BehaviorSubject([]);
|
|
2158
2751
|
const hasValue$ = new BehaviorSubject(false);
|
|
2752
|
+
// Forward reference. The client-tools capability is built much further down
|
|
2753
|
+
// (it needs `manager`, which needs these subjects), but the thread-change
|
|
2754
|
+
// seam lives here. A holder keeps the binding itself a `const` while its
|
|
2755
|
+
// member is filled in later.
|
|
2756
|
+
const clientToolStaging = {};
|
|
2757
|
+
let retryableToolMessageBatch;
|
|
2159
2758
|
function resetDerivedThreadState() {
|
|
2160
2759
|
status$.next(ResourceStatus.Idle);
|
|
2161
2760
|
error$.next(undefined);
|
|
2162
2761
|
hasValue$.next(false);
|
|
2762
|
+
// Staged client-tool results belong to the thread whose AIMessage produced
|
|
2763
|
+
// their tool_call_ids. Carrying them into a different thread would prepend
|
|
2764
|
+
// a ToolMessage that matches no tool call there — a 400 on that turn.
|
|
2765
|
+
// Runs BEFORE manager.switchThread resets the store, so the capability can
|
|
2766
|
+
// still read the outgoing thread's tool calls.
|
|
2767
|
+
clientToolStaging.clear?.();
|
|
2163
2768
|
}
|
|
2164
2769
|
// Track hasValue — becomes true once values or messages arrive
|
|
2165
2770
|
values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
|
|
@@ -2314,7 +2919,10 @@ function agent(options) {
|
|
|
2314
2919
|
// `@let content = messageContent(message)` short-circuits — DOM never
|
|
2315
2920
|
// updates per token. DOM stability is provided by `track message.id`
|
|
2316
2921
|
// in chat-message-list, not by Message identity.
|
|
2317
|
-
const messagesNeutral = computed(() =>
|
|
2922
|
+
const messagesNeutral = computed(() => {
|
|
2923
|
+
manager.deliveryRevision();
|
|
2924
|
+
return rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs, manager.getMessageDelivery));
|
|
2925
|
+
}, ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
|
|
2318
2926
|
// Client-tool resolutions written client-side. The raw `toolCalls$` stream
|
|
2319
2927
|
// (and thus `rawToolCalls`) only ever carries backend results — a resolved
|
|
2320
2928
|
// client tool (`ask`/`view`) never receives a backend ToolMessage on its
|
|
@@ -2341,7 +2949,7 @@ function agent(options) {
|
|
|
2341
2949
|
}, ...(ngDevMode ? [{ debugName: "interruptNeutral" }] : []));
|
|
2342
2950
|
const subagentsNeutral = computed(() => {
|
|
2343
2951
|
const out = new Map();
|
|
2344
|
-
subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa)));
|
|
2952
|
+
subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa, manager)));
|
|
2345
2953
|
return out;
|
|
2346
2954
|
}, ...(ngDevMode ? [{ debugName: "subagentsNeutral" }] : []));
|
|
2347
2955
|
const historyNeutral = computed(() => historySig().map(toCheckpoint), ...(ngDevMode ? [{ debugName: "historyNeutral" }] : []));
|
|
@@ -2353,11 +2961,47 @@ function agent(options) {
|
|
|
2353
2961
|
// follow-up runs (resolve) without going through the full submit() wrapper.
|
|
2354
2962
|
// The catalog is injected into every outbound payload via mergeClientTools()
|
|
2355
2963
|
// in the submit wrapper below and in the resolve path inside the capability.
|
|
2356
|
-
|
|
2964
|
+
//
|
|
2965
|
+
// flush() needs a durable write that does NOT start a run. The bridge's
|
|
2966
|
+
// updateState() silently no-ops when the transport has no updateState, so
|
|
2967
|
+
// only supply a persist function when the effective transport supports it —
|
|
2968
|
+
// an omitted transport means the bridge builds a FetchStreamTransport, which
|
|
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.
|
|
2972
|
+
const canPersistToolMessages = !transport || typeof transport.updateState === 'function';
|
|
2973
|
+
const clientToolsCap = createClientToolsCapability((payload, opts, batch) => {
|
|
2974
|
+
retryableToolMessageBatch = batch;
|
|
2975
|
+
return manager.submit(payload, opts);
|
|
2976
|
+
}, {
|
|
2357
2977
|
toolCalls: toolCallsNeutral,
|
|
2358
2978
|
isLoading,
|
|
2359
2979
|
applyClientResult: (id, patch) => clientResultOverrides.update((m) => new Map(m).set(id, patch)),
|
|
2360
|
-
}
|
|
2980
|
+
}, canPersistToolMessages
|
|
2981
|
+
? async (messages) => {
|
|
2982
|
+
// Throw rather than let the bridge no-op: without a thread there is
|
|
2983
|
+
// nothing to write to, and flush() must keep the buffer staged.
|
|
2984
|
+
if (!manager.currentThreadId) {
|
|
2985
|
+
throw new Error('no threadId for client tool flush');
|
|
2986
|
+
}
|
|
2987
|
+
// No asNode: add_messages appends the ToolMessages and the graph's
|
|
2988
|
+
// resume point is left untouched, so no run is started.
|
|
2989
|
+
await manager.updateState({ messages: [...messages] });
|
|
2990
|
+
}
|
|
2991
|
+
: undefined,
|
|
2992
|
+
// Stamps each staged result with the thread it was settled on, so a write
|
|
2993
|
+
// can never land on a thread the user has since moved to.
|
|
2994
|
+
() => manager.currentThreadId);
|
|
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
|
+
}
|
|
2361
3005
|
return {
|
|
2362
3006
|
// ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
|
|
2363
3007
|
messages: messagesNeutral,
|
|
@@ -2371,7 +3015,7 @@ function agent(options) {
|
|
|
2371
3015
|
events$,
|
|
2372
3016
|
history: historyNeutral,
|
|
2373
3017
|
messageCheckpoints: messageCheckpointsSig,
|
|
2374
|
-
submit: (input, opts) => {
|
|
3018
|
+
submit: async (input, opts) => {
|
|
2375
3019
|
// Lifecycle: first submit with no existing threadId → thread create.
|
|
2376
3020
|
if (lcThreadCreatedAt() === null && lastThreadId == null) {
|
|
2377
3021
|
lcThreadCreatedAt.set(Date.now());
|
|
@@ -2384,15 +3028,34 @@ function agent(options) {
|
|
|
2384
3028
|
// Thread the client-tools catalog into every outbound payload so the
|
|
2385
3029
|
// backend middleware can merge them into the model's tool list. Null
|
|
2386
3030
|
// payloads (regenerate re-runs, command resumes) are left unchanged.
|
|
2387
|
-
|
|
2388
|
-
|
|
3031
|
+
//
|
|
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 ?? [];
|
|
3041
|
+
const withStaged = staged.length > 0
|
|
3042
|
+
? mergeStagedToolMessages(request.payload, staged)
|
|
3043
|
+
: request.payload;
|
|
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();
|
|
2389
3052
|
},
|
|
2390
3053
|
stop: () => manager.stop(),
|
|
2391
3054
|
retry: async () => {
|
|
2392
3055
|
if (isLoading())
|
|
2393
3056
|
return; // no-op while a run is in flight
|
|
2394
3057
|
error$.next(undefined); // clear the error before re-running
|
|
2395
|
-
await
|
|
3058
|
+
await resubmitWithToolRecovery();
|
|
2396
3059
|
},
|
|
2397
3060
|
clientTools: clientToolsCap,
|
|
2398
3061
|
regenerate: async (assistantMessageIndex) => {
|
|
@@ -2449,6 +3112,7 @@ function agent(options) {
|
|
|
2449
3112
|
// at `__start__`, this resumes at the entry node and produces a fresh
|
|
2450
3113
|
// assistant message — the trailing user message becomes the active
|
|
2451
3114
|
// prompt without being re-appended.
|
|
3115
|
+
retryableToolMessageBatch = undefined;
|
|
2452
3116
|
await manager.submit(null, undefined);
|
|
2453
3117
|
},
|
|
2454
3118
|
// ── Raw LangGraph signals ─────────────────────────────────────────────
|
|
@@ -2460,7 +3124,9 @@ function agent(options) {
|
|
|
2460
3124
|
// ── Other LangGraph-specific fields ──────────────────────────────────
|
|
2461
3125
|
value: value,
|
|
2462
3126
|
hasValue: hasValueSig,
|
|
2463
|
-
reload: () =>
|
|
3127
|
+
reload: () => {
|
|
3128
|
+
void resubmitWithToolRecovery();
|
|
3129
|
+
},
|
|
2464
3130
|
toolProgress: toolProgSig,
|
|
2465
3131
|
queue: queueSig,
|
|
2466
3132
|
activeSubagents,
|
|
@@ -2537,7 +3203,7 @@ function mapStatus(s) {
|
|
|
2537
3203
|
return 'idle';
|
|
2538
3204
|
}
|
|
2539
3205
|
}
|
|
2540
|
-
function toMessage(m, getReasoningDurationMs) {
|
|
3206
|
+
function toMessage(m, getReasoningDurationMs, getDelivery) {
|
|
2541
3207
|
const raw = m;
|
|
2542
3208
|
const typeVal = typeof m._getType === 'function'
|
|
2543
3209
|
? m._getType()
|
|
@@ -2556,6 +3222,7 @@ function toMessage(m, getReasoningDurationMs) {
|
|
|
2556
3222
|
const result = {
|
|
2557
3223
|
id,
|
|
2558
3224
|
role,
|
|
3225
|
+
delivery: getDelivery?.(id) ?? staticDelivery(id),
|
|
2559
3226
|
content: extractTextContent(m.content),
|
|
2560
3227
|
toolCallId: raw['tool_call_id'],
|
|
2561
3228
|
name: raw['name'],
|
|
@@ -2632,12 +3299,15 @@ function toInterrupt(ix) {
|
|
|
2632
3299
|
resumable: true,
|
|
2633
3300
|
};
|
|
2634
3301
|
}
|
|
2635
|
-
function toSubagent(sa) {
|
|
3302
|
+
function toSubagent(sa, manager) {
|
|
2636
3303
|
return {
|
|
2637
3304
|
toolCallId: sa.toolCallId,
|
|
2638
3305
|
name: sa.name,
|
|
2639
3306
|
status: sa.status,
|
|
2640
|
-
messages: computed(() =>
|
|
3307
|
+
messages: computed(() => {
|
|
3308
|
+
manager.deliveryRevision();
|
|
3309
|
+
return sa.messages().map((m) => toMessage(m, undefined, () => manager.getSubagentMessageDelivery(sa.toolCallId, m)));
|
|
3310
|
+
}),
|
|
2641
3311
|
state: sa.values,
|
|
2642
3312
|
};
|
|
2643
3313
|
}
|
|
@@ -2756,6 +3426,7 @@ function agentFactory() {
|
|
|
2756
3426
|
...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}),
|
|
2757
3427
|
...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}),
|
|
2758
3428
|
...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}),
|
|
3429
|
+
...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}),
|
|
2759
3430
|
});
|
|
2760
3431
|
}
|
|
2761
3432
|
function isAgentRef(x) {
|