@threadplane/langgraph 0.0.55 → 0.0.57
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
|
/**
|
|
@@ -13,8 +13,8 @@ import { toAgentError, isAbortError, AgentError, AGENT_ERROR_MESSAGES, mockAgent
|
|
|
13
13
|
* an Angular injection context. External instrumentation packages
|
|
14
14
|
* (e.g. cockpit-telemetry) provide this token and read from it.
|
|
15
15
|
*
|
|
16
|
-
* `@threadplane/langgraph` does NOT provide this itself —
|
|
17
|
-
* the registry only when an external consumer has provided it.
|
|
16
|
+
* `@threadplane/langgraph` does NOT provide this itself — the configured agent
|
|
17
|
+
* instance writes to the registry only when an external consumer has provided it.
|
|
18
18
|
*/
|
|
19
19
|
class AgentLifecycleRegistry {
|
|
20
20
|
_lifecycles = signal([], ...(ngDevMode ? [{ debugName: "_lifecycles" }] : []));
|
|
@@ -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,9 +739,16 @@ 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();
|
|
591
753
|
}
|
|
592
754
|
currentThreadId = id;
|
|
@@ -602,11 +764,18 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
602
764
|
setThreadId(id, shouldReset);
|
|
603
765
|
});
|
|
604
766
|
destroy$.subscribe(() => {
|
|
767
|
+
invalidateQueueDrain();
|
|
605
768
|
abortController?.abort();
|
|
606
769
|
historyAbortController?.abort();
|
|
607
770
|
reasoningTimingMap.clear();
|
|
771
|
+
if (activeAttempt && !activeAttempt.terminalOutcome) {
|
|
772
|
+
finalizeAttempt(activeAttempt, 'interrupted');
|
|
773
|
+
}
|
|
774
|
+
messageDeliveries.clear();
|
|
775
|
+
activeAttempt = null;
|
|
776
|
+
notifyDeliveryChange();
|
|
608
777
|
});
|
|
609
|
-
async function refreshHistory(force = false) {
|
|
778
|
+
async function refreshHistory(force = false, isRelevant = () => true) {
|
|
610
779
|
const getHistory = transport.getHistory?.bind(transport);
|
|
611
780
|
if (!currentThreadId || !getHistory)
|
|
612
781
|
return;
|
|
@@ -616,8 +785,8 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
616
785
|
const threadId = currentThreadId;
|
|
617
786
|
subjects.isThreadLoading$.next(true);
|
|
618
787
|
try {
|
|
619
|
-
const history = await getHistory(threadId, controller.signal);
|
|
620
|
-
if (!controller.signal.aborted && currentThreadId === threadId) {
|
|
788
|
+
const history = await waitForHistory(getHistory(threadId, controller.signal), controller.signal);
|
|
789
|
+
if (!controller.signal.aborted && currentThreadId === threadId && isRelevant()) {
|
|
621
790
|
subjects.history$.next(history);
|
|
622
791
|
// Project the latest checkpoint into messages$ + values$:
|
|
623
792
|
// - On first connect (`force=false`): only when messages$ is
|
|
@@ -636,7 +805,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
636
805
|
// canonical surface for them; keeping a duplicate in values$
|
|
637
806
|
// would confuse downstream consumers reading both subjects.
|
|
638
807
|
delete restoredValues.messages;
|
|
639
|
-
subjects.messages$.next(restoredMessages);
|
|
808
|
+
subjects.messages$.next(preserveIds(subjects.messages$.value, restoredMessages));
|
|
640
809
|
subjects.values$.next(restoredValues);
|
|
641
810
|
// Rebuild derived subjects from the new authoritative messages$.
|
|
642
811
|
// Tool-call results displayed by chat-tool-calls come from
|
|
@@ -653,7 +822,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
653
822
|
}
|
|
654
823
|
}
|
|
655
824
|
catch (err) {
|
|
656
|
-
if (!controller.signal.aborted && err?.name !== 'AbortError') {
|
|
825
|
+
if (!controller.signal.aborted && isRelevant() && err?.name !== 'AbortError') {
|
|
657
826
|
subjects.error$.next(toAgentError(err));
|
|
658
827
|
}
|
|
659
828
|
}
|
|
@@ -664,6 +833,30 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
664
833
|
}
|
|
665
834
|
}
|
|
666
835
|
}
|
|
836
|
+
function waitForHistory(history, signal) {
|
|
837
|
+
if (signal.aborted)
|
|
838
|
+
return Promise.reject(createHistoryAbortError());
|
|
839
|
+
return new Promise((resolve, reject) => {
|
|
840
|
+
const onAbort = () => {
|
|
841
|
+
cleanup();
|
|
842
|
+
reject(createHistoryAbortError());
|
|
843
|
+
};
|
|
844
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
845
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
846
|
+
history.then(value => {
|
|
847
|
+
cleanup();
|
|
848
|
+
resolve(value);
|
|
849
|
+
}, error => {
|
|
850
|
+
cleanup();
|
|
851
|
+
reject(error);
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
function createHistoryAbortError() {
|
|
856
|
+
const error = new Error('History refresh aborted.');
|
|
857
|
+
error.name = 'AbortError';
|
|
858
|
+
return error;
|
|
859
|
+
}
|
|
667
860
|
function publishQueue() {
|
|
668
861
|
subjects.queue$.next(createQueueSnapshot());
|
|
669
862
|
}
|
|
@@ -719,24 +912,33 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
719
912
|
await Promise.all(entries.map(entry => cancelRun(entry.threadId, entry.id, new AbortController().signal)));
|
|
720
913
|
}
|
|
721
914
|
async function drainQueue() {
|
|
722
|
-
if (
|
|
915
|
+
if (activeQueueDrainEpoch !== null || queuedRuns.length === 0)
|
|
723
916
|
return;
|
|
724
|
-
|
|
917
|
+
queueDrainEpoch += 1;
|
|
918
|
+
const drainEpoch = queueDrainEpoch;
|
|
919
|
+
activeQueueDrainEpoch = drainEpoch;
|
|
725
920
|
try {
|
|
726
921
|
while (queuedRuns.length > 0) {
|
|
922
|
+
if (activeQueueDrainEpoch !== drainEpoch)
|
|
923
|
+
return;
|
|
727
924
|
const entry = queuedRuns.shift();
|
|
728
925
|
publishQueue();
|
|
729
926
|
if (!entry || !transport.joinStream)
|
|
730
927
|
continue;
|
|
731
|
-
await joinQueuedRun(entry);
|
|
928
|
+
await joinQueuedRun(entry, drainEpoch);
|
|
732
929
|
}
|
|
733
930
|
}
|
|
734
931
|
finally {
|
|
735
|
-
|
|
932
|
+
if (activeQueueDrainEpoch === drainEpoch)
|
|
933
|
+
activeQueueDrainEpoch = null;
|
|
736
934
|
}
|
|
737
935
|
}
|
|
738
|
-
async function joinQueuedRun(entry) {
|
|
739
|
-
|
|
936
|
+
async function joinQueuedRun(entry, drainEpoch) {
|
|
937
|
+
if (activeQueueDrainEpoch !== drainEpoch)
|
|
938
|
+
return;
|
|
939
|
+
const controller = new AbortController();
|
|
940
|
+
abortController = controller;
|
|
941
|
+
const attempt = beginAttempt(true);
|
|
740
942
|
const startedAt = Date.now();
|
|
741
943
|
captureRuntimeRequestTelemetry('join_queued');
|
|
742
944
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
|
|
@@ -746,19 +948,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
746
948
|
subjects.status$.next(ResourceStatus.Loading);
|
|
747
949
|
try {
|
|
748
950
|
const iter = transport.joinStream
|
|
749
|
-
? transport.joinStream(entry.threadId, entry.id, undefined,
|
|
951
|
+
? transport.joinStream(entry.threadId, entry.id, undefined, controller.signal)
|
|
750
952
|
: [];
|
|
751
953
|
for await (const event of iter) {
|
|
752
|
-
if (
|
|
954
|
+
if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
|
|
753
955
|
break;
|
|
754
956
|
processEvent(event);
|
|
755
957
|
}
|
|
756
|
-
if (!
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
958
|
+
if (!isCurrentExecution(controller, attempt))
|
|
959
|
+
return;
|
|
960
|
+
const outcome = await finalizeClosedAttempt(controller, attempt);
|
|
961
|
+
if (outcome === null)
|
|
962
|
+
return;
|
|
963
|
+
if (!controller.signal.aborted) {
|
|
964
|
+
if (outcome !== 'error') {
|
|
965
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
966
|
+
}
|
|
762
967
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
|
|
763
968
|
...telemetryProperties,
|
|
764
969
|
durationMs: Date.now() - startedAt,
|
|
@@ -766,19 +971,37 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
766
971
|
}
|
|
767
972
|
}
|
|
768
973
|
catch (err) {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
974
|
+
if (!isCurrentExecution(controller, attempt))
|
|
975
|
+
return;
|
|
976
|
+
if (attempt.terminalOutcome)
|
|
977
|
+
return;
|
|
978
|
+
if (isAbortError(err) && userAbortedControllers.has(controller)) {
|
|
979
|
+
finalizeAttempt(attempt, 'aborted');
|
|
980
|
+
subjects.error$.next(undefined);
|
|
981
|
+
subjects.status$.next(ResourceStatus.Idle);
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
985
|
+
subjects.error$.next(toAgentError(err));
|
|
986
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
987
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
|
|
988
|
+
...telemetryProperties,
|
|
989
|
+
durationMs: Date.now() - startedAt,
|
|
990
|
+
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
finally {
|
|
995
|
+
if (abortController === controller)
|
|
996
|
+
abortController = null;
|
|
776
997
|
}
|
|
777
998
|
}
|
|
778
999
|
async function runStream(payload, opts, requestType = 'submit') {
|
|
1000
|
+
invalidateQueueDrain();
|
|
779
1001
|
abortController?.abort();
|
|
780
|
-
|
|
781
|
-
|
|
1002
|
+
const controller = new AbortController();
|
|
1003
|
+
abortController = controller;
|
|
1004
|
+
const attempt = beginAttempt(requestType === 'resubmit' || (isRecord$1(opts?.command) && 'resume' in opts.command));
|
|
782
1005
|
const startedAt = Date.now();
|
|
783
1006
|
captureRuntimeRequestTelemetry(requestType);
|
|
784
1007
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
|
|
@@ -813,18 +1036,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
813
1036
|
subjects.messages$.next([...existing, ...stamped]);
|
|
814
1037
|
}
|
|
815
1038
|
try {
|
|
816
|
-
const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ??
|
|
1039
|
+
const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? controller.signal, opts);
|
|
817
1040
|
for await (const event of iter) {
|
|
818
|
-
if (
|
|
1041
|
+
if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
|
|
819
1042
|
break;
|
|
820
1043
|
streamingStarted = true;
|
|
821
1044
|
processEvent(event);
|
|
822
1045
|
}
|
|
823
|
-
if (!
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1046
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1047
|
+
return;
|
|
1048
|
+
const outcome = await finalizeClosedAttempt(controller, attempt);
|
|
1049
|
+
if (outcome === null)
|
|
1050
|
+
return;
|
|
1051
|
+
if (!controller.signal.aborted) {
|
|
1052
|
+
if (outcome !== 'error') {
|
|
1053
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
1054
|
+
}
|
|
828
1055
|
await drainQueue();
|
|
829
1056
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
|
|
830
1057
|
...telemetryProperties,
|
|
@@ -833,11 +1060,18 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
833
1060
|
}
|
|
834
1061
|
}
|
|
835
1062
|
catch (err) {
|
|
836
|
-
if (
|
|
1063
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1064
|
+
return;
|
|
1065
|
+
if (attempt.terminalOutcome)
|
|
1066
|
+
return;
|
|
1067
|
+
if (isAbortError(err) && userAbortedControllers.has(controller)) {
|
|
1068
|
+
finalizeAttempt(attempt, 'aborted');
|
|
837
1069
|
// User explicitly called stop() — treat as graceful idle, not an error.
|
|
1070
|
+
subjects.error$.next(undefined);
|
|
838
1071
|
subjects.status$.next(ResourceStatus.Idle);
|
|
839
1072
|
}
|
|
840
1073
|
else if (isAbortError(err)) {
|
|
1074
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
841
1075
|
// A non-user-requested abort: interrupted if a stream had started, else a
|
|
842
1076
|
// connect-phase failure. Never "aborted" (that's reserved for user stop).
|
|
843
1077
|
const e = streamingStarted
|
|
@@ -852,6 +1086,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
852
1086
|
});
|
|
853
1087
|
}
|
|
854
1088
|
else {
|
|
1089
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
855
1090
|
subjects.error$.next(toAgentError(err));
|
|
856
1091
|
subjects.status$.next(ResourceStatus.Error);
|
|
857
1092
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
|
|
@@ -861,10 +1096,17 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
861
1096
|
});
|
|
862
1097
|
}
|
|
863
1098
|
}
|
|
1099
|
+
finally {
|
|
1100
|
+
if (abortController === controller)
|
|
1101
|
+
abortController = null;
|
|
1102
|
+
}
|
|
864
1103
|
}
|
|
865
1104
|
function processEvent(event) {
|
|
866
1105
|
const baseType = getBaseEventType(event.type);
|
|
867
1106
|
const namespace = getEventNamespace(event);
|
|
1107
|
+
if (baseType === 'checkpoints' || baseType === 'messages/complete') {
|
|
1108
|
+
markNormalTerminal(event);
|
|
1109
|
+
}
|
|
868
1110
|
if (isMessagesEvent(event.type)) {
|
|
869
1111
|
const msgs = normalizeMessages(event);
|
|
870
1112
|
if (!msgs)
|
|
@@ -887,8 +1129,22 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
887
1129
|
// Partial and message-tuple events are incremental. Merge them by id
|
|
888
1130
|
// so optimistic human messages and earlier tool messages are preserved.
|
|
889
1131
|
if (event.type === 'messages/partial' || event.messageMetadata) {
|
|
1132
|
+
if (!isTranscriptMessageEvent(event, options.transcriptNodeNames)) {
|
|
1133
|
+
storeMessageMetadata(normalized, event);
|
|
1134
|
+
syncSubagentsFromMessages(normalized);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
890
1137
|
const mode = event.messageMetadata ? 'delta' : 'snapshot';
|
|
891
|
-
|
|
1138
|
+
const affectedMessageIds = new Set();
|
|
1139
|
+
const merged = mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds, affectedMessageIds, activeAttempt?.currentAssistantMessageId !== undefined
|
|
1140
|
+
&& activeAttempt.currentStepHasTerminalEvidence !== true);
|
|
1141
|
+
subjects.messages$.next(merged);
|
|
1142
|
+
if (!isSubagentNamespace(namespace)) {
|
|
1143
|
+
trackAssistantMessages(merged.filter(message => {
|
|
1144
|
+
const id = message['id'];
|
|
1145
|
+
return typeof id === 'string' && affectedMessageIds.has(id);
|
|
1146
|
+
}));
|
|
1147
|
+
}
|
|
892
1148
|
if (isLgTraceEnabled()) {
|
|
893
1149
|
const msgs = subjects.messages$.value;
|
|
894
1150
|
const last = msgs[msgs.length - 1];
|
|
@@ -903,8 +1159,17 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
903
1159
|
else {
|
|
904
1160
|
// Preserve existing ids by content so the final-id swap doesn't
|
|
905
1161
|
// tear down the chat-message DOM (and its streaming-md renderer).
|
|
906
|
-
|
|
1162
|
+
const affectedMessageIds = new Set();
|
|
1163
|
+
const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds);
|
|
1164
|
+
subjects.messages$.next(preserved);
|
|
1165
|
+
if (!isSubagentNamespace(namespace)) {
|
|
1166
|
+
trackAssistantMessages(preserved.filter(message => {
|
|
1167
|
+
const id = message['id'];
|
|
1168
|
+
return typeof id === 'string' && affectedMessageIds.has(id);
|
|
1169
|
+
}));
|
|
1170
|
+
}
|
|
907
1171
|
}
|
|
1172
|
+
markNormalTerminal(event);
|
|
908
1173
|
storeMessageMetadata(normalized, event);
|
|
909
1174
|
syncSubagentsFromMessages(normalized);
|
|
910
1175
|
syncToolCallsFromMessages();
|
|
@@ -920,6 +1185,15 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
920
1185
|
updateSubagentValues(namespace, vals);
|
|
921
1186
|
break;
|
|
922
1187
|
}
|
|
1188
|
+
if ((namespace?.length ?? 0) === 0) {
|
|
1189
|
+
if (hasInterrupts(vals)) {
|
|
1190
|
+
if (activeAttempt)
|
|
1191
|
+
finalizeAttempt(activeAttempt, 'paused');
|
|
1192
|
+
}
|
|
1193
|
+
else {
|
|
1194
|
+
markNormalTerminal(event);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
923
1197
|
if (vals != null) {
|
|
924
1198
|
extractInterrupts(vals, subjects);
|
|
925
1199
|
subjects.values$.next(vals);
|
|
@@ -988,13 +1262,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
988
1262
|
break;
|
|
989
1263
|
}
|
|
990
1264
|
case 'error':
|
|
1265
|
+
if (activeAttempt)
|
|
1266
|
+
finalizeAttempt(activeAttempt, 'error');
|
|
991
1267
|
subjects.error$.next(toAgentError(event['error']));
|
|
992
1268
|
subjects.status$.next(ResourceStatus.Error);
|
|
993
1269
|
break;
|
|
994
1270
|
case 'interrupt':
|
|
1271
|
+
if (activeAttempt)
|
|
1272
|
+
finalizeAttempt(activeAttempt, 'paused');
|
|
995
1273
|
subjects.interrupt$.next(event['interrupt']);
|
|
996
1274
|
break;
|
|
997
1275
|
case 'interrupts':
|
|
1276
|
+
if (activeAttempt)
|
|
1277
|
+
finalizeAttempt(activeAttempt, 'paused');
|
|
998
1278
|
subjects.interrupts$.next(event['interrupts']);
|
|
999
1279
|
break;
|
|
1000
1280
|
case 'custom': {
|
|
@@ -1132,14 +1412,19 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1132
1412
|
await runStream(payload, opts);
|
|
1133
1413
|
},
|
|
1134
1414
|
stop: async () => {
|
|
1135
|
-
|
|
1415
|
+
invalidateQueueDrain();
|
|
1416
|
+
const shouldAbortAttempt = Boolean(abortController && activeAttempt && !activeAttempt.terminalOutcome);
|
|
1417
|
+
if (shouldAbortAttempt && abortController)
|
|
1418
|
+
userAbortedControllers.add(abortController);
|
|
1419
|
+
if (shouldAbortAttempt && activeAttempt)
|
|
1420
|
+
finalizeAttempt(activeAttempt, 'aborted');
|
|
1136
1421
|
abortController?.abort();
|
|
1422
|
+
if (activeAttempt?.awaitingFinalSync)
|
|
1423
|
+
historyAbortController?.abort();
|
|
1137
1424
|
await clearQueue();
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
// catch never fires) or when clearQueue() raised an error.
|
|
1142
|
-
if (subjects.status$.value !== ResourceStatus.Idle) {
|
|
1425
|
+
// Set Idle synchronously for an active user cancellation. Attempts that
|
|
1426
|
+
// already reached a terminal outcome retain their existing status.
|
|
1427
|
+
if (shouldAbortAttempt && subjects.status$.value !== ResourceStatus.Idle) {
|
|
1143
1428
|
subjects.status$.next(ResourceStatus.Idle);
|
|
1144
1429
|
}
|
|
1145
1430
|
},
|
|
@@ -1149,8 +1434,12 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1149
1434
|
joinStream: async (runId, lastEventId) => {
|
|
1150
1435
|
if (!currentThreadId)
|
|
1151
1436
|
return;
|
|
1437
|
+
invalidateQueueDrain();
|
|
1152
1438
|
abortController?.abort();
|
|
1153
|
-
|
|
1439
|
+
const controller = new AbortController();
|
|
1440
|
+
abortController = controller;
|
|
1441
|
+
const attempt = beginAttempt(true);
|
|
1442
|
+
const threadId = currentThreadId;
|
|
1154
1443
|
const startedAt = Date.now();
|
|
1155
1444
|
captureRuntimeRequestTelemetry('join');
|
|
1156
1445
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_started', telemetryProperties);
|
|
@@ -1158,28 +1447,55 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1158
1447
|
subjects.toolProgress$.next([]);
|
|
1159
1448
|
toolProgressMap.clear();
|
|
1160
1449
|
subjects.status$.next(ResourceStatus.Loading);
|
|
1450
|
+
subjects.error$.next(undefined);
|
|
1161
1451
|
try {
|
|
1162
1452
|
const iter = transport.joinStream
|
|
1163
|
-
? transport.joinStream(
|
|
1453
|
+
? transport.joinStream(threadId, runId, lastEventId, controller.signal)
|
|
1164
1454
|
: [];
|
|
1165
1455
|
for await (const event of iter) {
|
|
1456
|
+
if (controller.signal.aborted || !isCurrentExecution(controller, attempt))
|
|
1457
|
+
break;
|
|
1166
1458
|
processEvent(event);
|
|
1167
1459
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1460
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1461
|
+
return;
|
|
1462
|
+
const outcome = await finalizeClosedAttempt(controller, attempt);
|
|
1463
|
+
if (outcome === null)
|
|
1464
|
+
return;
|
|
1465
|
+
if (!controller.signal.aborted) {
|
|
1466
|
+
if (outcome !== 'error') {
|
|
1467
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
1468
|
+
}
|
|
1469
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
|
|
1470
|
+
...telemetryProperties,
|
|
1471
|
+
durationMs: Date.now() - startedAt,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1174
1474
|
}
|
|
1175
1475
|
catch (err) {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1476
|
+
if (!isCurrentExecution(controller, attempt))
|
|
1477
|
+
return;
|
|
1478
|
+
if (attempt.terminalOutcome)
|
|
1479
|
+
return;
|
|
1480
|
+
if (isAbortError(err) && userAbortedControllers.has(controller)) {
|
|
1481
|
+
finalizeAttempt(attempt, 'aborted');
|
|
1482
|
+
subjects.error$.next(undefined);
|
|
1483
|
+
subjects.status$.next(ResourceStatus.Idle);
|
|
1484
|
+
}
|
|
1485
|
+
else {
|
|
1486
|
+
finalizeAttempt(attempt, attempt.sawAssistantChunk ? 'interrupted' : 'error');
|
|
1487
|
+
subjects.error$.next(toAgentError(err));
|
|
1488
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
1489
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
|
|
1490
|
+
...telemetryProperties,
|
|
1491
|
+
durationMs: Date.now() - startedAt,
|
|
1492
|
+
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
finally {
|
|
1497
|
+
if (abortController === controller)
|
|
1498
|
+
abortController = null;
|
|
1183
1499
|
}
|
|
1184
1500
|
},
|
|
1185
1501
|
resubmitLast: async () => {
|
|
@@ -1195,6 +1511,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1195
1511
|
return undefined;
|
|
1196
1512
|
return entry.endedAt - entry.startedAt;
|
|
1197
1513
|
},
|
|
1514
|
+
getMessageDelivery: (id) => messageDeliveries.get(id) ?? staticDelivery(id),
|
|
1515
|
+
getSubagentMessageDelivery: (toolCallId, message) => subagentManager.getMessageDelivery(toolCallId, message),
|
|
1516
|
+
deliveryRevision,
|
|
1198
1517
|
updateState: async (values, opts) => {
|
|
1199
1518
|
// No-op when there is no thread yet or the transport doesn't support
|
|
1200
1519
|
// updateState (e.g. MockAgentTransport in unit tests without a threadId).
|
|
@@ -1208,6 +1527,14 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
|
1208
1527
|
},
|
|
1209
1528
|
};
|
|
1210
1529
|
}
|
|
1530
|
+
function isTranscriptMessageEvent(event, transcriptNodeNames) {
|
|
1531
|
+
if (!transcriptNodeNames || transcriptNodeNames.length === 0)
|
|
1532
|
+
return true;
|
|
1533
|
+
const node = event.messageMetadata?.['langgraph_node'];
|
|
1534
|
+
if (typeof node !== 'string')
|
|
1535
|
+
return true;
|
|
1536
|
+
return transcriptNodeNames.includes(node);
|
|
1537
|
+
}
|
|
1211
1538
|
/**
|
|
1212
1539
|
* Extracts the payload data from a normalized SDK event.
|
|
1213
1540
|
*
|
|
@@ -1242,6 +1569,12 @@ function extractInterrupts(payload, subjects) {
|
|
|
1242
1569
|
subjects.interrupts$.next([]);
|
|
1243
1570
|
}
|
|
1244
1571
|
}
|
|
1572
|
+
function hasInterrupts(payload) {
|
|
1573
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1574
|
+
return false;
|
|
1575
|
+
const raw = payload['__interrupt__'];
|
|
1576
|
+
return Array.isArray(raw) && raw.length > 0;
|
|
1577
|
+
}
|
|
1245
1578
|
/**
|
|
1246
1579
|
* Projects pending interrupts from the latest history checkpoint onto the
|
|
1247
1580
|
* interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under
|
|
@@ -1337,7 +1670,7 @@ function normalizeMessages(event) {
|
|
|
1337
1670
|
* carry the full text we collapse them, keeping the older slot's id so
|
|
1338
1671
|
* track-by-id stays stable in the chat list.
|
|
1339
1672
|
*/
|
|
1340
|
-
function collapseAdjacentAi(messages) {
|
|
1673
|
+
function collapseAdjacentAi(messages, affectedMessageIds, allowCrossIdAiMerge = true) {
|
|
1341
1674
|
if (messages.length < 2)
|
|
1342
1675
|
return messages;
|
|
1343
1676
|
const out = [];
|
|
@@ -1352,13 +1685,21 @@ function collapseAdjacentAi(messages) {
|
|
|
1352
1685
|
if (lastType === 'ai' && msgType === 'ai') {
|
|
1353
1686
|
const lastText = extractText(last.content);
|
|
1354
1687
|
const msgText = extractText(msg.content);
|
|
1355
|
-
|
|
1688
|
+
const lastRaw = last;
|
|
1689
|
+
const msgRaw = msg;
|
|
1690
|
+
const differentIds = lastRaw['id'] !== msgRaw['id'];
|
|
1691
|
+
if (!(differentIds && !allowCrossIdAiMerge) && (lastText.length === 0
|
|
1356
1692
|
|| msgText.length === 0
|
|
1357
1693
|
|| lastText === msgText
|
|
1358
1694
|
|| lastText.startsWith(msgText)
|
|
1359
|
-
|| msgText.startsWith(lastText)) {
|
|
1695
|
+
|| msgText.startsWith(lastText))) {
|
|
1360
1696
|
// Keep the longer content; preserve last (older) id and metadata.
|
|
1361
1697
|
const longerText = msgText.length >= lastText.length ? msgText : lastText;
|
|
1698
|
+
const lastId = last['id'];
|
|
1699
|
+
const msgId = msg['id'];
|
|
1700
|
+
if (typeof msgId === 'string' && affectedMessageIds?.delete(msgId) && typeof lastId === 'string') {
|
|
1701
|
+
affectedMessageIds.add(lastId);
|
|
1702
|
+
}
|
|
1362
1703
|
out[out.length - 1] = { ...last, content: longerText };
|
|
1363
1704
|
continue;
|
|
1364
1705
|
}
|
|
@@ -1367,7 +1708,7 @@ function collapseAdjacentAi(messages) {
|
|
|
1367
1708
|
}
|
|
1368
1709
|
return out;
|
|
1369
1710
|
}
|
|
1370
|
-
function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds) {
|
|
1711
|
+
function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot', canonicalMessageIds, affectedMessageIds, allowCrossIdAiMerge = true) {
|
|
1371
1712
|
const merged = [...existing];
|
|
1372
1713
|
for (const msg of incoming) {
|
|
1373
1714
|
const rawIn = msg;
|
|
@@ -1381,6 +1722,9 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1381
1722
|
// prevents DOM teardown + animation restarts mid-stream.
|
|
1382
1723
|
if (idx < 0) {
|
|
1383
1724
|
idx = findContentMatch(merged, msg);
|
|
1725
|
+
if (idx >= 0 && !canMergeCrossIdAi(merged[idx], msg, allowCrossIdAiMerge)) {
|
|
1726
|
+
idx = -1;
|
|
1727
|
+
}
|
|
1384
1728
|
}
|
|
1385
1729
|
// When an AIMessageChunk arrives without an id-match or content-prefix
|
|
1386
1730
|
// match, treat the trailing AI message as its accumulator. The
|
|
@@ -1395,6 +1739,8 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1395
1739
|
? merged[i]._getType()
|
|
1396
1740
|
: merged[i]['type']);
|
|
1397
1741
|
if (t === 'ai') {
|
|
1742
|
+
if (!canMergeCrossIdAi(merged[i], msg, allowCrossIdAiMerge))
|
|
1743
|
+
break;
|
|
1398
1744
|
idx = i;
|
|
1399
1745
|
break;
|
|
1400
1746
|
}
|
|
@@ -1450,7 +1796,10 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1450
1796
|
if (existingId) {
|
|
1451
1797
|
next['id'] = existingId;
|
|
1452
1798
|
}
|
|
1799
|
+
const changed = mode === 'delta' || messageChangedForDelivery(existing, next);
|
|
1453
1800
|
merged[idx] = next;
|
|
1801
|
+
if (targetId && changed)
|
|
1802
|
+
affectedMessageIds?.add(targetId);
|
|
1454
1803
|
}
|
|
1455
1804
|
else {
|
|
1456
1805
|
const incomingRaw = msg;
|
|
@@ -1467,9 +1816,19 @@ function mergeMessages(existing, incoming, reasoningTimingMap, mode = 'snapshot'
|
|
|
1467
1816
|
const next = { ...msg };
|
|
1468
1817
|
next['reasoning'] = initialReasoning;
|
|
1469
1818
|
merged.push(next);
|
|
1819
|
+
const nextId = next['id'];
|
|
1820
|
+
if (typeof nextId === 'string')
|
|
1821
|
+
affectedMessageIds?.add(nextId);
|
|
1470
1822
|
}
|
|
1471
1823
|
}
|
|
1472
|
-
return collapseAdjacentAi(merged);
|
|
1824
|
+
return collapseAdjacentAi(merged, affectedMessageIds, allowCrossIdAiMerge);
|
|
1825
|
+
}
|
|
1826
|
+
function canMergeCrossIdAi(candidate, incoming, allowCrossIdAiMerge) {
|
|
1827
|
+
const candidateRaw = candidate;
|
|
1828
|
+
const incomingRaw = incoming;
|
|
1829
|
+
if (candidateRaw['id'] === incomingRaw['id'])
|
|
1830
|
+
return true;
|
|
1831
|
+
return allowCrossIdAiMerge;
|
|
1473
1832
|
}
|
|
1474
1833
|
/**
|
|
1475
1834
|
* Merge an incoming chunk's content into prior accumulated content for the
|
|
@@ -1627,9 +1986,15 @@ function accumulateReasoning(existing, incoming) {
|
|
|
1627
1986
|
* (role, content) matches positionally and the existing id differs. Keeps
|
|
1628
1987
|
* track-by-id stable across server echoes and final-id swaps.
|
|
1629
1988
|
*/
|
|
1630
|
-
function preserveIds(existing, incoming) {
|
|
1631
|
-
if (existing.length === 0)
|
|
1632
|
-
|
|
1989
|
+
function preserveIds(existing, incoming, affectedMessageIds) {
|
|
1990
|
+
if (existing.length === 0) {
|
|
1991
|
+
for (const message of incoming) {
|
|
1992
|
+
const id = message['id'];
|
|
1993
|
+
if (typeof id === 'string')
|
|
1994
|
+
affectedMessageIds?.add(id);
|
|
1995
|
+
}
|
|
1996
|
+
return collapseAdjacentAi(incoming, affectedMessageIds);
|
|
1997
|
+
}
|
|
1633
1998
|
const usedExisting = new Set();
|
|
1634
1999
|
const remapped = incoming.map((msg, i) => {
|
|
1635
2000
|
const inRaw = msg;
|
|
@@ -1643,15 +2008,40 @@ function preserveIds(existing, incoming) {
|
|
|
1643
2008
|
// Fallback: any unused existing message with matching role+content.
|
|
1644
2009
|
matchIdx = existing.findIndex((m, j) => !usedExisting.has(j) && sameRoleAndContent(m, msg));
|
|
1645
2010
|
}
|
|
1646
|
-
if (matchIdx < 0)
|
|
2011
|
+
if (matchIdx < 0) {
|
|
2012
|
+
if (typeof inId === 'string')
|
|
2013
|
+
affectedMessageIds?.add(inId);
|
|
1647
2014
|
return msg;
|
|
2015
|
+
}
|
|
1648
2016
|
usedExisting.add(matchIdx);
|
|
1649
2017
|
const existingId = existing[matchIdx]['id'];
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
2018
|
+
const remappedMessage = !existingId || existingId === inId
|
|
2019
|
+
? msg
|
|
2020
|
+
: { ...msg, id: existingId };
|
|
2021
|
+
if (typeof existingId === 'string' && messageChangedForDelivery(existing[matchIdx], remappedMessage)) {
|
|
2022
|
+
affectedMessageIds?.add(existingId);
|
|
2023
|
+
}
|
|
2024
|
+
return remappedMessage;
|
|
1653
2025
|
});
|
|
1654
|
-
return collapseAdjacentAi(remapped);
|
|
2026
|
+
return collapseAdjacentAi(remapped, affectedMessageIds);
|
|
2027
|
+
}
|
|
2028
|
+
function messageChangedForDelivery(existing, incoming) {
|
|
2029
|
+
const existingRaw = existing;
|
|
2030
|
+
const incomingRaw = incoming;
|
|
2031
|
+
const existingType = normalizeMessageType(typeof existing._getType === 'function' ? existing._getType() : existingRaw['type']);
|
|
2032
|
+
const incomingType = normalizeMessageType(typeof incoming._getType === 'function' ? incoming._getType() : incomingRaw['type']);
|
|
2033
|
+
if (existingType !== incomingType || extractText(existing.content) !== extractText(incoming.content)) {
|
|
2034
|
+
return true;
|
|
2035
|
+
}
|
|
2036
|
+
const existingReasoning = typeof existingRaw['reasoning'] === 'string'
|
|
2037
|
+
? existingRaw['reasoning']
|
|
2038
|
+
: extractReasoning(existingRaw['reasoning']);
|
|
2039
|
+
const incomingReasoning = typeof incomingRaw['reasoning'] === 'string'
|
|
2040
|
+
? incomingRaw['reasoning']
|
|
2041
|
+
: extractReasoning(incomingRaw['reasoning']);
|
|
2042
|
+
if (existingReasoning !== incomingReasoning)
|
|
2043
|
+
return true;
|
|
2044
|
+
return JSON.stringify(existingRaw['tool_calls'] ?? null) !== JSON.stringify(incomingRaw['tool_calls'] ?? null);
|
|
1655
2045
|
}
|
|
1656
2046
|
function sameRoleAndContent(a, b) {
|
|
1657
2047
|
const aType = normalizeMessageType(typeof a._getType === 'function' ? a._getType() : a['type']);
|
|
@@ -1723,6 +2113,14 @@ function normalizeMessageType(t) {
|
|
|
1723
2113
|
return 'system';
|
|
1724
2114
|
return t;
|
|
1725
2115
|
}
|
|
2116
|
+
function getTailAssistantMessageId(messages) {
|
|
2117
|
+
const tail = messages[messages.length - 1];
|
|
2118
|
+
if (!tail)
|
|
2119
|
+
return undefined;
|
|
2120
|
+
const raw = tail;
|
|
2121
|
+
const type = normalizeMessageType(typeof tail._getType === 'function' ? tail._getType() : raw['type']);
|
|
2122
|
+
return type === 'ai' && typeof raw['id'] === 'string' ? raw['id'] : undefined;
|
|
2123
|
+
}
|
|
1726
2124
|
function toSubagentRefs(subagents) {
|
|
1727
2125
|
const refs = new Map();
|
|
1728
2126
|
subagents.forEach((subagent, key) => {
|
|
@@ -1921,12 +2319,19 @@ function normalizeCitation(entry, fallbackIndex) {
|
|
|
1921
2319
|
}
|
|
1922
2320
|
return undefined;
|
|
1923
2321
|
};
|
|
2322
|
+
const publishedAt = e['publishedAt'];
|
|
2323
|
+
const normalizedPublishedAt = typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date
|
|
2324
|
+
? publishedAt
|
|
2325
|
+
: undefined;
|
|
1924
2326
|
return {
|
|
1925
2327
|
id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
|
|
1926
2328
|
index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
|
|
1927
2329
|
title: firstStr('title', 'name'),
|
|
1928
2330
|
url: firstStr('url', 'href', 'source'),
|
|
1929
2331
|
snippet: firstStr('snippet', 'content', 'excerpt'),
|
|
2332
|
+
sourceType: str('sourceType'),
|
|
2333
|
+
iconUrl: str('iconUrl'),
|
|
2334
|
+
publishedAt: normalizedPublishedAt,
|
|
1930
2335
|
extra: typeof e['extra'] === 'object' && e['extra'] !== null
|
|
1931
2336
|
? e['extra']
|
|
1932
2337
|
: undefined,
|
|
@@ -1959,6 +2364,23 @@ function mergeClientTools(payload, catalog) {
|
|
|
1959
2364
|
return payload;
|
|
1960
2365
|
return { ...payload, client_tools: catalog };
|
|
1961
2366
|
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Prepend staged tool messages to a run payload's message list.
|
|
2369
|
+
*
|
|
2370
|
+
* Mirrors mergeClientTools: a null payload signals a no-input resume and must
|
|
2371
|
+
* stay null, so staged messages cannot ride along and are left buffered.
|
|
2372
|
+
*/
|
|
2373
|
+
function mergeStagedToolMessages(payload, staged) {
|
|
2374
|
+
if (staged.length === 0)
|
|
2375
|
+
return payload;
|
|
2376
|
+
if (payload === null || payload === undefined)
|
|
2377
|
+
return payload;
|
|
2378
|
+
if (typeof payload !== 'object' || Array.isArray(payload))
|
|
2379
|
+
return payload;
|
|
2380
|
+
const record = payload;
|
|
2381
|
+
const existing = Array.isArray(record['messages']) ? record['messages'] : [];
|
|
2382
|
+
return { ...record, messages: [...staged, ...existing] };
|
|
2383
|
+
}
|
|
1962
2384
|
/**
|
|
1963
2385
|
* Creates a ClientToolsCapability backed by a LangGraph submit function and
|
|
1964
2386
|
* a store of tool-call signals. Extracted into a factory so it can be
|
|
@@ -1973,8 +2395,17 @@ function mergeClientTools(payload, catalog) {
|
|
|
1973
2395
|
* yet — but ONLY when the run is not in progress (isLoading===false).
|
|
1974
2396
|
* The backend ends the run without emitting a ToolMessage result for
|
|
1975
2397
|
* client tools, so `result` stays undefined on those entries.
|
|
1976
|
-
* -
|
|
1977
|
-
*
|
|
2398
|
+
* - 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
|
|
2401
|
+
* 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.
|
|
2407
|
+
* - 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:
|
|
1978
2409
|
* input: {
|
|
1979
2410
|
* messages: [{ type: 'tool', role: 'tool', tool_call_id: id, content }],
|
|
1980
2411
|
* client_tools: catalog(),
|
|
@@ -1990,59 +2421,176 @@ function mergeClientTools(payload, catalog) {
|
|
|
1990
2421
|
* before forwarding to manager.submit. This keeps injection concerns
|
|
1991
2422
|
* co-located with the run-issuing call sites.
|
|
1992
2423
|
*/
|
|
1993
|
-
function createClientToolsCapability(submitFn, store) {
|
|
2424
|
+
function createClientToolsCapability(submitFn, store, persistFn, currentThreadIdFn) {
|
|
1994
2425
|
const catalog = signal([], ...(ngDevMode ? [{ debugName: "catalog" }] : []));
|
|
1995
2426
|
const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
|
|
2427
|
+
const toolMessageBuffer = [];
|
|
2428
|
+
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.
|
|
2431
|
+
let bufferGeneration = 0;
|
|
2432
|
+
// Tool calls belonging to threads we have left. A handler still running when
|
|
2433
|
+
// the user switches threads settles AFTER the switch, by which point both the
|
|
2434
|
+
// store and the current thread id already describe the NEW thread — the id is
|
|
2435
|
+
// the only durable way left to recognise the result as stale.
|
|
2436
|
+
const retiredToolCallIds = new Set();
|
|
1996
2437
|
const pending = computed(() => {
|
|
1997
2438
|
// Client tools are only actionable after the run ends (the backend
|
|
1998
2439
|
// signals this by ending the run WITHOUT emitting a ToolMessage result
|
|
1999
2440
|
// for client tools).
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2441
|
+
return selectPendingClientToolCalls({
|
|
2442
|
+
isLoading: store.isLoading(),
|
|
2443
|
+
toolCalls: store.toolCalls(),
|
|
2444
|
+
catalogNames: new Set(catalog().map((s) => s.name)),
|
|
2445
|
+
resolvedIds: resolvedIds(),
|
|
2446
|
+
});
|
|
2005
2447
|
}, ...(ngDevMode ? [{ debugName: "pending" }] : []));
|
|
2448
|
+
function settleResult(id, result) {
|
|
2449
|
+
// Mark as resolved first so pending() drops it immediately.
|
|
2450
|
+
resolvedIds.update((s) => new Set(s).add(id));
|
|
2451
|
+
// Cast rather than rely on discriminant narrowing: consumer apps that
|
|
2452
|
+
// compile this source with `strictNullChecks: false` don't narrow the
|
|
2453
|
+
// ClientToolResult union in a ternary.
|
|
2454
|
+
const ok = result.ok;
|
|
2455
|
+
const value = result.value;
|
|
2456
|
+
const error = result.error;
|
|
2457
|
+
// Write the outcome onto the LOCAL ToolCall (via the adapter's override
|
|
2458
|
+
// layer). The client tool DID produce a result client-side, so this is
|
|
2459
|
+
// semantically correct — and it freezes the transcript card: the mounted
|
|
2460
|
+
// ask component re-renders with its own emitted value as props and can
|
|
2461
|
+
// branch to a resolved/frozen state. Without this, the LOCAL tool call
|
|
2462
|
+
// never gets a result (only the backend ToolMessage does) so the card
|
|
2463
|
+
// stays interactive forever.
|
|
2464
|
+
store.applyClientResult(id, {
|
|
2465
|
+
result: ok ? value : { error },
|
|
2466
|
+
...(ok ? {} : { error, status: 'error' }),
|
|
2467
|
+
});
|
|
2468
|
+
const content = ok
|
|
2469
|
+
? safeStringify(value)
|
|
2470
|
+
: `Error: ${error}`;
|
|
2471
|
+
// The tool call belongs to a thread the user has already left, so there is
|
|
2472
|
+
// nowhere valid to send this result: the current thread has no matching
|
|
2473
|
+
// tool call, and the old thread is no longer the write target.
|
|
2474
|
+
if (retiredToolCallIds.has(id)) {
|
|
2475
|
+
console.warn(`Discarding client tool result for ${id}: its thread is no longer active.`);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
// Message shape: both `type` and `role` are set for compatibility —
|
|
2479
|
+
// the LangGraph server's add_messages coercion reads `role` (Python
|
|
2480
|
+
// side), while the bridge's local optimistic-message path reads `type`
|
|
2481
|
+
// (via toMessage's normalizeMessageType). This mirrors the human-message
|
|
2482
|
+
// shape used in buildSubmitUpdate (agent.fn.ts line 732).
|
|
2483
|
+
toolMessageBuffer.push({
|
|
2484
|
+
threadId: currentThreadIdFn?.() ?? null,
|
|
2485
|
+
message: { type: 'tool', role: 'tool', tool_call_id: id, content },
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
/**
|
|
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.
|
|
2492
|
+
*
|
|
2493
|
+
* A null on either side means "thread not tracked yet" (no threadId option
|
|
2494
|
+
* and no run has reported one); those are kept, since there is no evidence of
|
|
2495
|
+
* a switch and dropping them would lose results on untracked transports.
|
|
2496
|
+
*/
|
|
2497
|
+
function takeStagedForCurrentThread() {
|
|
2498
|
+
const current = currentThreadIdFn?.() ?? null;
|
|
2499
|
+
const taken = toolMessageBuffer.splice(0, toolMessageBuffer.length);
|
|
2500
|
+
return taken.filter((entry) => {
|
|
2501
|
+
const stale = entry.threadId !== null && current !== null && entry.threadId !== current;
|
|
2502
|
+
if (stale) {
|
|
2503
|
+
console.warn(`Discarding a client tool result staged for thread ${entry.threadId}; ` +
|
|
2504
|
+
`the active thread is now ${current}.`);
|
|
2505
|
+
}
|
|
2506
|
+
return !stale;
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
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).
|
|
2515
|
+
*/
|
|
2516
|
+
function runFlush() {
|
|
2517
|
+
if (!persistFn)
|
|
2518
|
+
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);
|
|
2533
|
+
})
|
|
2534
|
+
.finally(() => {
|
|
2535
|
+
// Only clear if no later flush has already claimed the slot.
|
|
2536
|
+
if (flushInFlight === inFlight)
|
|
2537
|
+
flushInFlight = undefined;
|
|
2538
|
+
});
|
|
2539
|
+
flushInFlight = inFlight;
|
|
2540
|
+
return inFlight;
|
|
2541
|
+
}
|
|
2006
2542
|
const capability = {
|
|
2007
2543
|
catalog,
|
|
2008
2544
|
setCatalog(specs) {
|
|
2009
2545
|
catalog.set([...specs]);
|
|
2010
2546
|
},
|
|
2011
2547
|
pending,
|
|
2548
|
+
settle(id, result) {
|
|
2549
|
+
settleResult(id, result);
|
|
2550
|
+
},
|
|
2551
|
+
/** Remove and return every buffered tool message valid for this thread. */
|
|
2552
|
+
drainToolMessages() {
|
|
2553
|
+
return takeStagedForCurrentThread().map((entry) => entry.message);
|
|
2554
|
+
},
|
|
2555
|
+
/**
|
|
2556
|
+
* Discard everything staged. Called when the active thread changes: a
|
|
2557
|
+
* ToolMessage only makes sense against the thread whose AIMessage produced
|
|
2558
|
+
* its tool_call_id, so carrying it over would poison the new thread.
|
|
2559
|
+
*/
|
|
2560
|
+
clearStagedToolMessages() {
|
|
2561
|
+
// Retire the outgoing thread's tool calls so a handler that settles after
|
|
2562
|
+
// the switch is recognised as stale. Read the store BEFORE it resets —
|
|
2563
|
+
// agent.fn.ts calls this ahead of manager.switchThread for that reason.
|
|
2564
|
+
for (const toolCall of store.toolCalls())
|
|
2565
|
+
retiredToolCallIds.add(toolCall.id);
|
|
2566
|
+
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.
|
|
2569
|
+
bufferGeneration += 1;
|
|
2570
|
+
},
|
|
2571
|
+
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();
|
|
2586
|
+
},
|
|
2012
2587
|
resolve(id, result) {
|
|
2013
|
-
|
|
2014
|
-
resolvedIds.update((s) => new Set(s).add(id));
|
|
2015
|
-
// Cast rather than rely on discriminant narrowing: consumer apps that
|
|
2016
|
-
// compile this source with `strictNullChecks: false` don't narrow the
|
|
2017
|
-
// ClientToolResult union in a ternary.
|
|
2018
|
-
const ok = result.ok;
|
|
2019
|
-
const value = result.value;
|
|
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}`;
|
|
2588
|
+
settleResult(id, result);
|
|
2035
2589
|
// Issue a new run on the same thread. LangGraph's add_messages reducer
|
|
2036
|
-
// appends the
|
|
2590
|
+
// appends the ToolMessages to the thread state. `client_tools` is
|
|
2037
2591
|
// 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).
|
|
2044
2592
|
const toolPayload = {
|
|
2045
|
-
messages:
|
|
2593
|
+
messages: takeStagedForCurrentThread().map((entry) => entry.message),
|
|
2046
2594
|
client_tools: catalog(),
|
|
2047
2595
|
};
|
|
2048
2596
|
void submitFn(toolPayload);
|
|
@@ -2156,10 +2704,21 @@ function agent(options) {
|
|
|
2156
2704
|
});
|
|
2157
2705
|
const custom$ = new BehaviorSubject([]);
|
|
2158
2706
|
const hasValue$ = new BehaviorSubject(false);
|
|
2707
|
+
// Forward reference. The client-tools capability is built much further down
|
|
2708
|
+
// (it needs `manager`, which needs these subjects), but the thread-change
|
|
2709
|
+
// seam lives here. A holder keeps the binding itself a `const` while its
|
|
2710
|
+
// member is filled in later.
|
|
2711
|
+
const clientToolStaging = {};
|
|
2159
2712
|
function resetDerivedThreadState() {
|
|
2160
2713
|
status$.next(ResourceStatus.Idle);
|
|
2161
2714
|
error$.next(undefined);
|
|
2162
2715
|
hasValue$.next(false);
|
|
2716
|
+
// Staged client-tool results belong to the thread whose AIMessage produced
|
|
2717
|
+
// their tool_call_ids. Carrying them into a different thread would prepend
|
|
2718
|
+
// a ToolMessage that matches no tool call there — a 400 on that turn.
|
|
2719
|
+
// Runs BEFORE manager.switchThread resets the store, so the capability can
|
|
2720
|
+
// still read the outgoing thread's tool calls.
|
|
2721
|
+
clientToolStaging.clear?.();
|
|
2163
2722
|
}
|
|
2164
2723
|
// Track hasValue — becomes true once values or messages arrive
|
|
2165
2724
|
values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
|
|
@@ -2314,7 +2873,10 @@ function agent(options) {
|
|
|
2314
2873
|
// `@let content = messageContent(message)` short-circuits — DOM never
|
|
2315
2874
|
// updates per token. DOM stability is provided by `track message.id`
|
|
2316
2875
|
// in chat-message-list, not by Message identity.
|
|
2317
|
-
const messagesNeutral = computed(() =>
|
|
2876
|
+
const messagesNeutral = computed(() => {
|
|
2877
|
+
manager.deliveryRevision();
|
|
2878
|
+
return rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs, manager.getMessageDelivery));
|
|
2879
|
+
}, ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
|
|
2318
2880
|
// Client-tool resolutions written client-side. The raw `toolCalls$` stream
|
|
2319
2881
|
// (and thus `rawToolCalls`) only ever carries backend results — a resolved
|
|
2320
2882
|
// client tool (`ask`/`view`) never receives a backend ToolMessage on its
|
|
@@ -2341,7 +2903,7 @@ function agent(options) {
|
|
|
2341
2903
|
}, ...(ngDevMode ? [{ debugName: "interruptNeutral" }] : []));
|
|
2342
2904
|
const subagentsNeutral = computed(() => {
|
|
2343
2905
|
const out = new Map();
|
|
2344
|
-
subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa)));
|
|
2906
|
+
subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa, manager)));
|
|
2345
2907
|
return out;
|
|
2346
2908
|
}, ...(ngDevMode ? [{ debugName: "subagentsNeutral" }] : []));
|
|
2347
2909
|
const historyNeutral = computed(() => historySig().map(toCheckpoint), ...(ngDevMode ? [{ debugName: "historyNeutral" }] : []));
|
|
@@ -2353,11 +2915,34 @@ function agent(options) {
|
|
|
2353
2915
|
// follow-up runs (resolve) without going through the full submit() wrapper.
|
|
2354
2916
|
// The catalog is injected into every outbound payload via mergeClientTools()
|
|
2355
2917
|
// in the submit wrapper below and in the resolve path inside the capability.
|
|
2918
|
+
//
|
|
2919
|
+
// flush() needs a durable write that does NOT start a run. The bridge's
|
|
2920
|
+
// updateState() silently no-ops when the transport has no updateState, so
|
|
2921
|
+
// only supply a persist function when the effective transport supports it —
|
|
2922
|
+
// 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.
|
|
2925
|
+
const canPersistToolMessages = !transport || typeof transport.updateState === 'function';
|
|
2356
2926
|
const clientToolsCap = createClientToolsCapability((payload, opts) => manager.submit(payload, opts), {
|
|
2357
2927
|
toolCalls: toolCallsNeutral,
|
|
2358
2928
|
isLoading,
|
|
2359
2929
|
applyClientResult: (id, patch) => clientResultOverrides.update((m) => new Map(m).set(id, patch)),
|
|
2360
|
-
}
|
|
2930
|
+
}, canPersistToolMessages
|
|
2931
|
+
? async (messages) => {
|
|
2932
|
+
// Throw rather than let the bridge no-op: without a thread there is
|
|
2933
|
+
// nothing to write to, and flush() must keep the buffer staged.
|
|
2934
|
+
if (!manager.currentThreadId) {
|
|
2935
|
+
throw new Error('no threadId for client tool flush');
|
|
2936
|
+
}
|
|
2937
|
+
// No asNode: add_messages appends the ToolMessages and the graph's
|
|
2938
|
+
// resume point is left untouched, so no run is started.
|
|
2939
|
+
await manager.updateState({ messages: [...messages] });
|
|
2940
|
+
}
|
|
2941
|
+
: undefined,
|
|
2942
|
+
// Stamps each staged result with the thread it was settled on, so a write
|
|
2943
|
+
// can never land on a thread the user has since moved to.
|
|
2944
|
+
() => manager.currentThreadId);
|
|
2945
|
+
clientToolStaging.clear = () => clientToolsCap.clearStagedToolMessages();
|
|
2361
2946
|
return {
|
|
2362
2947
|
// ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
|
|
2363
2948
|
messages: messagesNeutral,
|
|
@@ -2384,7 +2969,18 @@ function agent(options) {
|
|
|
2384
2969
|
// Thread the client-tools catalog into every outbound payload so the
|
|
2385
2970
|
// backend middleware can merge them into the model's tool list. Null
|
|
2386
2971
|
// payloads (regenerate re-runs, command resumes) are left unchanged.
|
|
2387
|
-
|
|
2972
|
+
//
|
|
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();
|
|
2980
|
+
const withStaged = staged.length > 0
|
|
2981
|
+
? mergeStagedToolMessages(request.payload, staged)
|
|
2982
|
+
: request.payload;
|
|
2983
|
+
const payload = mergeClientTools(withStaged, clientToolsCap.catalog());
|
|
2388
2984
|
return manager.submit(payload, request.options);
|
|
2389
2985
|
},
|
|
2390
2986
|
stop: () => manager.stop(),
|
|
@@ -2537,7 +3133,7 @@ function mapStatus(s) {
|
|
|
2537
3133
|
return 'idle';
|
|
2538
3134
|
}
|
|
2539
3135
|
}
|
|
2540
|
-
function toMessage(m, getReasoningDurationMs) {
|
|
3136
|
+
function toMessage(m, getReasoningDurationMs, getDelivery) {
|
|
2541
3137
|
const raw = m;
|
|
2542
3138
|
const typeVal = typeof m._getType === 'function'
|
|
2543
3139
|
? m._getType()
|
|
@@ -2556,6 +3152,7 @@ function toMessage(m, getReasoningDurationMs) {
|
|
|
2556
3152
|
const result = {
|
|
2557
3153
|
id,
|
|
2558
3154
|
role,
|
|
3155
|
+
delivery: getDelivery?.(id) ?? staticDelivery(id),
|
|
2559
3156
|
content: extractTextContent(m.content),
|
|
2560
3157
|
toolCallId: raw['tool_call_id'],
|
|
2561
3158
|
name: raw['name'],
|
|
@@ -2632,12 +3229,15 @@ function toInterrupt(ix) {
|
|
|
2632
3229
|
resumable: true,
|
|
2633
3230
|
};
|
|
2634
3231
|
}
|
|
2635
|
-
function toSubagent(sa) {
|
|
3232
|
+
function toSubagent(sa, manager) {
|
|
2636
3233
|
return {
|
|
2637
3234
|
toolCallId: sa.toolCallId,
|
|
2638
3235
|
name: sa.name,
|
|
2639
3236
|
status: sa.status,
|
|
2640
|
-
messages: computed(() =>
|
|
3237
|
+
messages: computed(() => {
|
|
3238
|
+
manager.deliveryRevision();
|
|
3239
|
+
return sa.messages().map((m) => toMessage(m, undefined, () => manager.getSubagentMessageDelivery(sa.toolCallId, m)));
|
|
3240
|
+
}),
|
|
2641
3241
|
state: sa.values,
|
|
2642
3242
|
};
|
|
2643
3243
|
}
|
|
@@ -2756,6 +3356,7 @@ function agentFactory() {
|
|
|
2756
3356
|
...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}),
|
|
2757
3357
|
...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}),
|
|
2758
3358
|
...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}),
|
|
3359
|
+
...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}),
|
|
2759
3360
|
});
|
|
2760
3361
|
}
|
|
2761
3362
|
function isAgentRef(x) {
|
|
@@ -3105,7 +3706,7 @@ function delay(ms) {
|
|
|
3105
3706
|
* @example
|
|
3106
3707
|
* ```ts
|
|
3107
3708
|
* TestBed.configureTestingModule({
|
|
3108
|
-
* providers: [provideFakeAgent({
|
|
3709
|
+
* providers: [provideFakeAgent({ tokens: ['Hi from the fake LangGraph agent'] })],
|
|
3109
3710
|
* });
|
|
3110
3711
|
* ```
|
|
3111
3712
|
*/
|