@yeaft/webchat-agent 1.0.213 → 1.0.214
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.
- package/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +153 -21
- package/yeaft/engine.js +259 -164
- package/yeaft/router/continuity.js +2 -2
- package/yeaft/web-bridge.js +13 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.214"}
|
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync } from 'fs';
|
|
21
21
|
import { join, basename } from 'path';
|
|
22
22
|
import { isPermissionError } from '../init.js';
|
|
23
|
+
import { writeAtomic } from '../storage/atomic.js';
|
|
23
24
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
24
25
|
import { sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
|
|
25
26
|
import { isHiddenConversationRow } from './internal-control.js';
|
|
@@ -76,6 +77,7 @@ function emptySegmentIndex() {
|
|
|
76
77
|
lastMessageId: null,
|
|
77
78
|
activeSegment: SEGMENT_FIRST_NAME,
|
|
78
79
|
segments: [],
|
|
80
|
+
foldedMessageIds: [],
|
|
79
81
|
};
|
|
80
82
|
}
|
|
81
83
|
|
|
@@ -91,6 +93,26 @@ function normalizeSegmentRecord(msg) {
|
|
|
91
93
|
return out;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
function foldedMessageIdsFrom(rows) {
|
|
97
|
+
const ids = new Set();
|
|
98
|
+
for (const row of rows || []) {
|
|
99
|
+
if (!row?._reflection || !Array.isArray(row.foldedMessageIds)) continue;
|
|
100
|
+
for (const id of row.foldedMessageIds) {
|
|
101
|
+
if (typeof id === 'string' && id) ids.add(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return ids;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applyFoldedMessageTombstones(rows, additionalIds = []) {
|
|
108
|
+
const foldedIds = foldedMessageIdsFrom(rows);
|
|
109
|
+
for (const id of additionalIds || []) {
|
|
110
|
+
if (typeof id === 'string' && id) foldedIds.add(id);
|
|
111
|
+
}
|
|
112
|
+
if (foldedIds.size === 0) return rows;
|
|
113
|
+
return rows.filter(row => !foldedIds.has(row?.id));
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
function segmentNameForNumber(n) {
|
|
95
117
|
return `${String(n).padStart(6, '0')}.jsonl`;
|
|
96
118
|
}
|
|
@@ -363,6 +385,8 @@ function serializeMessage(msg) {
|
|
|
363
385
|
if (msg.sessionId) fm.push(`sessionId: ${msg.sessionId}`);
|
|
364
386
|
if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
|
|
365
387
|
if (msg.clientMessageId) fm.push(`clientMessageId: ${msg.clientMessageId}`);
|
|
388
|
+
if (msg.incomplete) fm.push('incomplete: true');
|
|
389
|
+
if (msg.stopReason) fm.push(`stopReason: ${msg.stopReason}`);
|
|
366
390
|
// Session attribution: when a VP authors an assistant turn (either
|
|
367
391
|
// its own reply or a route_forward injection from another VP), stamp
|
|
368
392
|
// the speaker so the UI can render the message on the correct VP track.
|
|
@@ -492,6 +516,8 @@ export function parseMessage(raw) {
|
|
|
492
516
|
case 'sessionId': msg.sessionId = value; break;
|
|
493
517
|
case 'chatId': msg.chatId = value; break;
|
|
494
518
|
case 'clientMessageId': msg.clientMessageId = value; break;
|
|
519
|
+
case 'incomplete': msg.incomplete = value === 'true'; break;
|
|
520
|
+
case 'stopReason': msg.stopReason = value; break;
|
|
495
521
|
case 'speakerVpId': msg.speakerVpId = value; break;
|
|
496
522
|
case 'attachmentsB64':
|
|
497
523
|
try {
|
|
@@ -621,8 +647,9 @@ class SegmentStore {
|
|
|
621
647
|
idx = null;
|
|
622
648
|
}
|
|
623
649
|
}
|
|
624
|
-
|
|
625
|
-
|
|
650
|
+
const indexWasStale = idx && !this.#indexMatchesDisk(idx);
|
|
651
|
+
this.index = this.#normalizeIndex(!idx || indexWasStale ? this.#rebuildIndex() : idx);
|
|
652
|
+
if ((!existsSync(this.indexPath) || indexWasStale) && this.hasData()) this.saveIndex();
|
|
626
653
|
return this.index;
|
|
627
654
|
}
|
|
628
655
|
|
|
@@ -659,6 +686,12 @@ class SegmentStore {
|
|
|
659
686
|
idx.totalMessages = (idx.totalMessages || 0) + 1;
|
|
660
687
|
idx.lastMessageId = msg.id || null;
|
|
661
688
|
idx.nextSeq = Math.max(Number(idx.nextSeq) || 1, seq + 1);
|
|
689
|
+
if (msg._reflection && Array.isArray(msg.foldedMessageIds)) {
|
|
690
|
+
idx.foldedMessageIds = Array.from(new Set([
|
|
691
|
+
...(Array.isArray(idx.foldedMessageIds) ? idx.foldedMessageIds : []),
|
|
692
|
+
...msg.foldedMessageIds.filter(id => typeof id === 'string' && id),
|
|
693
|
+
]));
|
|
694
|
+
}
|
|
662
695
|
this.saveIndex();
|
|
663
696
|
}
|
|
664
697
|
|
|
@@ -672,9 +705,11 @@ class SegmentStore {
|
|
|
672
705
|
const out = [];
|
|
673
706
|
for (const seg of segments) {
|
|
674
707
|
const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
|
|
675
|
-
out.push(...rows);
|
|
708
|
+
out.push(...applyFoldedMessageTombstones(rows, idx.foldedMessageIds));
|
|
676
709
|
}
|
|
677
|
-
return desc
|
|
710
|
+
return desc
|
|
711
|
+
? out.sort((a, b) => parseSeqFromId(b.id) - parseSeqFromId(a.id))
|
|
712
|
+
: out.sort(compareMessagesBySeq);
|
|
678
713
|
}
|
|
679
714
|
|
|
680
715
|
*scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
@@ -685,7 +720,8 @@ class SegmentStore {
|
|
|
685
720
|
.slice()
|
|
686
721
|
.sort((a, b) => desc ? (b.lastSeq || 0) - (a.lastSeq || 0) : (a.firstSeq || 0) - (b.firstSeq || 0));
|
|
687
722
|
for (const seg of segments) {
|
|
688
|
-
|
|
723
|
+
const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
|
|
724
|
+
yield* applyFoldedMessageTombstones(rows, idx.foldedMessageIds);
|
|
689
725
|
}
|
|
690
726
|
}
|
|
691
727
|
|
|
@@ -705,18 +741,39 @@ class SegmentStore {
|
|
|
705
741
|
for (const msg of (rows || []).filter(Boolean).sort(compareMessagesBySeq)) this.append(msg);
|
|
706
742
|
}
|
|
707
743
|
|
|
744
|
+
updateById(id, updater) {
|
|
745
|
+
if (!id || typeof updater !== 'function' || !this.hasData()) return null;
|
|
746
|
+
const targetSeq = parseSeqFromId(id);
|
|
747
|
+
const idx = this.loadIndex();
|
|
748
|
+
const segment = (idx.segments || []).find((candidate) => {
|
|
749
|
+
const first = Number(candidate.firstSeq);
|
|
750
|
+
const last = Number(candidate.lastSeq);
|
|
751
|
+
return Number.isFinite(targetSeq)
|
|
752
|
+
&& Number.isFinite(first)
|
|
753
|
+
&& Number.isFinite(last)
|
|
754
|
+
&& targetSeq >= first
|
|
755
|
+
&& targetSeq <= last;
|
|
756
|
+
});
|
|
757
|
+
if (!segment?.file) return null;
|
|
758
|
+
|
|
759
|
+
const path = join(this.segmentDir, segment.file);
|
|
760
|
+
const rows = this.#readSegment(segment.file, { includeCold: true });
|
|
761
|
+
const rowIndex = rows.findIndex(row => row?.id === id);
|
|
762
|
+
if (rowIndex < 0) return null;
|
|
763
|
+
const next = updater({ ...rows[rowIndex] });
|
|
764
|
+
if (!next || typeof next !== 'object') return null;
|
|
765
|
+
const updated = { ...next, id: rows[rowIndex].id };
|
|
766
|
+
rows[rowIndex] = updated;
|
|
767
|
+
|
|
768
|
+
const body = rows.map(row => JSON.stringify(row)).join('\n') + (rows.length > 0 ? '\n' : '');
|
|
769
|
+
writeAtomic(path, body);
|
|
770
|
+
segment.bytes = Buffer.byteLength(body);
|
|
771
|
+
this.saveIndex();
|
|
772
|
+
return updated;
|
|
773
|
+
}
|
|
774
|
+
|
|
708
775
|
markCold(id) {
|
|
709
|
-
|
|
710
|
-
const rows = this.readAll({ includeCold: true });
|
|
711
|
-
let changed = 0;
|
|
712
|
-
for (const msg of rows) {
|
|
713
|
-
if (msg?.id === id && msg.cold !== true) {
|
|
714
|
-
msg.cold = true;
|
|
715
|
-
changed += 1;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
if (changed > 0) this.replaceAll(rows);
|
|
719
|
-
return changed;
|
|
776
|
+
return this.updateById(id, msg => ({ ...msg, cold: true })) ? 1 : 0;
|
|
720
777
|
}
|
|
721
778
|
|
|
722
779
|
clear() {
|
|
@@ -729,6 +786,19 @@ class SegmentStore {
|
|
|
729
786
|
this.index = emptySegmentIndex();
|
|
730
787
|
}
|
|
731
788
|
|
|
789
|
+
#indexMatchesDisk(idx) {
|
|
790
|
+
if (!existsSync(this.segmentDir)) return !(idx?.segments?.length > 0);
|
|
791
|
+
const diskFiles = readdirSync(this.segmentDir).filter(file => file.endsWith('.jsonl')).sort();
|
|
792
|
+
const indexedSegments = Array.isArray(idx?.segments) ? idx.segments : [];
|
|
793
|
+
const indexedFiles = indexedSegments.map(segment => segment?.file).filter(Boolean).sort();
|
|
794
|
+
if (diskFiles.length !== indexedFiles.length
|
|
795
|
+
|| diskFiles.some((file, index) => file !== indexedFiles[index])) return false;
|
|
796
|
+
return indexedSegments.every(segment => {
|
|
797
|
+
const path = join(this.segmentDir, segment.file);
|
|
798
|
+
return existsSync(path) && statSync(path).size === Number(segment.bytes);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
732
802
|
#normalizeIndex(idx) {
|
|
733
803
|
const out = { ...emptySegmentIndex(), ...(idx || {}) };
|
|
734
804
|
out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
|
|
@@ -737,6 +807,9 @@ class SegmentStore {
|
|
|
737
807
|
out.nextSeq = Math.max(Number(out.nextSeq) || 1, maxSeq + 1);
|
|
738
808
|
out.activeSegment = out.activeSegment || out.segments[out.segments.length - 1]?.file || SEGMENT_FIRST_NAME;
|
|
739
809
|
out.totalMessages = Number(out.totalMessages) || out.segments.reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
|
|
810
|
+
out.foldedMessageIds = Array.isArray(out.foldedMessageIds)
|
|
811
|
+
? Array.from(new Set(out.foldedMessageIds.filter(id => typeof id === 'string' && id)))
|
|
812
|
+
: [];
|
|
740
813
|
return out;
|
|
741
814
|
}
|
|
742
815
|
|
|
@@ -758,10 +831,12 @@ class SegmentStore {
|
|
|
758
831
|
};
|
|
759
832
|
idx.segments.push(seg);
|
|
760
833
|
idx.totalMessages += rows.length;
|
|
834
|
+
idx.foldedMessageIds.push(...foldedMessageIdsFrom(rows));
|
|
761
835
|
idx.lastMessageId = rows[rows.length - 1]?.id || idx.lastMessageId;
|
|
762
836
|
idx.nextSeq = Math.max(idx.nextSeq, seg.lastSeq + 1);
|
|
763
837
|
idx.activeSegment = file;
|
|
764
838
|
}
|
|
839
|
+
idx.foldedMessageIds = Array.from(new Set(idx.foldedMessageIds));
|
|
765
840
|
return idx;
|
|
766
841
|
}
|
|
767
842
|
|
|
@@ -931,6 +1006,51 @@ export class ConversationStore {
|
|
|
931
1006
|
return messages.map(m => this.append(m));
|
|
932
1007
|
}
|
|
933
1008
|
|
|
1009
|
+
/**
|
|
1010
|
+
* Replace fields on one persisted message while preserving its id/order.
|
|
1011
|
+
* Rewrites only the owning conversation segment set; used for async tool
|
|
1012
|
+
* results that complete after their initial tool row was appended.
|
|
1013
|
+
*
|
|
1014
|
+
* @param {object} message — persisted row returned by append()
|
|
1015
|
+
* @param {object} patch — fields to merge into the row
|
|
1016
|
+
* @returns {object|null}
|
|
1017
|
+
*/
|
|
1018
|
+
update(message, patch) {
|
|
1019
|
+
if (!message?.id || !patch || typeof patch !== 'object') return null;
|
|
1020
|
+
try {
|
|
1021
|
+
const store = this.#segmentStoreFor(message, { create: false });
|
|
1022
|
+
return store.updateById(message.id, current => ({ ...current, ...patch }));
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
if (isPermissionError(err)) {
|
|
1025
|
+
if (!_permissionWarned) {
|
|
1026
|
+
console.warn(`[Yeaft] Cannot update message ${message.id}: ${err.code}`);
|
|
1027
|
+
_permissionWarned = true;
|
|
1028
|
+
}
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
throw err;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Atomically publish a logical range replacement for tool folding.
|
|
1037
|
+
*
|
|
1038
|
+
* The original rows stay append-only on disk. A single reflection row owns
|
|
1039
|
+
* their ids as tombstones, so readers either observe the complete old arc or
|
|
1040
|
+
* the complete reflection — never a half-rewritten tool pair.
|
|
1041
|
+
*
|
|
1042
|
+
* @param {object[]} messages — persisted rows being folded
|
|
1043
|
+
* @param {object} reflection — synthetic `_reflection` user row
|
|
1044
|
+
* @returns {object|null}
|
|
1045
|
+
*/
|
|
1046
|
+
foldMessages(messages, reflection) {
|
|
1047
|
+
const foldedMessageIds = Array.from(new Set(
|
|
1048
|
+
(messages || []).map(message => message?.id).filter(id => typeof id === 'string' && id),
|
|
1049
|
+
));
|
|
1050
|
+
if (foldedMessageIds.length === 0 || !reflection || reflection._reflection !== true) return null;
|
|
1051
|
+
return this.append({ ...reflection, foldedMessageIds });
|
|
1052
|
+
}
|
|
1053
|
+
|
|
934
1054
|
/**
|
|
935
1055
|
* Move a message from hot (messages/) to cold (cold/).
|
|
936
1056
|
*
|
|
@@ -1264,11 +1384,12 @@ export class ConversationStore {
|
|
|
1264
1384
|
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
|
|
1265
1385
|
* @returns {object[]}
|
|
1266
1386
|
*/
|
|
1267
|
-
loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
1387
|
+
loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS, { includeReflections = false } = {}) {
|
|
1268
1388
|
if (!sessionId) return [];
|
|
1269
1389
|
if (turnsLimit === Infinity || turnsLimit < 0) {
|
|
1270
1390
|
const all = this.#loadSessionMessages(sessionId);
|
|
1271
|
-
const filtered = all.filter(m => m && m.sessionId === sessionId
|
|
1391
|
+
const filtered = all.filter(m => m && m.sessionId === sessionId
|
|
1392
|
+
&& (!isHiddenConversationRow(m) || (includeReflections && m._reflection === true)));
|
|
1272
1393
|
return pairSanitize(filtered);
|
|
1273
1394
|
}
|
|
1274
1395
|
if (!(turnsLimit > 0)) return [];
|
|
@@ -1276,6 +1397,7 @@ export class ConversationStore {
|
|
|
1276
1397
|
const { messages, truncated } = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
|
|
1277
1398
|
roles: null,
|
|
1278
1399
|
stripAssistantToolCalls: false,
|
|
1400
|
+
includeReflections,
|
|
1279
1401
|
});
|
|
1280
1402
|
if (truncated) {
|
|
1281
1403
|
const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
|
|
@@ -1324,9 +1446,14 @@ export class ConversationStore {
|
|
|
1324
1446
|
loadSessionHistoryForVp(sessionId, vpId) {
|
|
1325
1447
|
if (!sessionId || !vpId) return [];
|
|
1326
1448
|
const all = this.#loadSessionMessages(sessionId);
|
|
1449
|
+
const foldedIds = foldedMessageIdsFrom(all);
|
|
1327
1450
|
const out = [];
|
|
1328
1451
|
for (const m of all) {
|
|
1329
|
-
if (!m || m.sessionId !== sessionId) continue;
|
|
1452
|
+
if (!m || m.sessionId !== sessionId || foldedIds.has(m.id)) continue;
|
|
1453
|
+
if (m._reflection === true) {
|
|
1454
|
+
out.push(m);
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1330
1457
|
if (isHiddenConversationRow(m)) continue;
|
|
1331
1458
|
if (m.role === 'user') {
|
|
1332
1459
|
out.push(m);
|
|
@@ -2320,7 +2447,12 @@ export class ConversationStore {
|
|
|
2320
2447
|
return parseMessage(readFileSync(path, 'utf8'));
|
|
2321
2448
|
}
|
|
2322
2449
|
|
|
2323
|
-
#loadRecentSessionWindow(sessionId, turnsLimit, {
|
|
2450
|
+
#loadRecentSessionWindow(sessionId, turnsLimit, {
|
|
2451
|
+
beforeSeq = Infinity,
|
|
2452
|
+
roles = null,
|
|
2453
|
+
stripAssistantToolCalls = false,
|
|
2454
|
+
includeReflections = false,
|
|
2455
|
+
} = {}) {
|
|
2324
2456
|
const kept = [];
|
|
2325
2457
|
const pendingBoundaryRows = [];
|
|
2326
2458
|
let turnsFromEnd = 0;
|
|
@@ -2366,7 +2498,7 @@ export class ConversationStore {
|
|
|
2366
2498
|
if (!m || m.sessionId !== sessionId) continue;
|
|
2367
2499
|
|
|
2368
2500
|
const boundaryComplete = turnsFromEnd >= turnsLimit;
|
|
2369
|
-
if (isHiddenConversationRow(m)) {
|
|
2501
|
+
if (isHiddenConversationRow(m) && !(includeReflections && m._reflection === true)) {
|
|
2370
2502
|
if (boundaryComplete) {
|
|
2371
2503
|
truncated = true;
|
|
2372
2504
|
break;
|
package/yeaft/engine.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 3. Call adapter.stream()
|
|
8
8
|
* 4. Collect text + tool_calls from stream events
|
|
9
9
|
* 5. If tool_calls → execute tools → append results → goto 3
|
|
10
|
-
* 6.
|
|
10
|
+
* 6. Persist each completed message at its durability boundary; end_turn runs maintenance
|
|
11
11
|
* 7. If max_tokens → auto-continue (up to maxContinueTurns)
|
|
12
12
|
* 8. On LLMContextError → force compact → retry
|
|
13
13
|
* 9. On retryable error with fallbackModel → switch model → retry
|
|
@@ -34,7 +34,6 @@ import { readSummary as readScopeSummary } from './memory/store.js';
|
|
|
34
34
|
import { runAdjust } from './memory/adjust.js';
|
|
35
35
|
import { cleanMemoryPromptText, isMemoryPromptRelevant } from './memory/prompt-cleanup.js';
|
|
36
36
|
import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
|
|
37
|
-
import { runStopHooks } from './stop-hooks.js';
|
|
38
37
|
import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
39
38
|
// Default thread marker for legacy / non-group flows. Group VP runtime may
|
|
40
39
|
// pass a real threadId per (sessionId, vpId, threadId) engine instance.
|
|
@@ -512,7 +511,8 @@ export class Engine {
|
|
|
512
511
|
* LLMAbortError (or a synthetic abort check) and yields exactly one pair
|
|
513
512
|
* of events — `{type:'aborted', reason}` followed by
|
|
514
513
|
* `{type:'turn_end', stopReason:'aborted'}` — then returns without
|
|
515
|
-
*
|
|
514
|
+
* running consolidation or other terminal maintenance. Any assistant text
|
|
515
|
+
* already streamed is durably recorded as an incomplete response.
|
|
516
516
|
*
|
|
517
517
|
* @type {AbortController|null}
|
|
518
518
|
*/
|
|
@@ -586,6 +586,9 @@ export class Engine {
|
|
|
586
586
|
/** Task results already spliced into conversationMessages for the next request. */
|
|
587
587
|
#pendingAsyncTaskConfirmIds = new Set();
|
|
588
588
|
|
|
589
|
+
/** Persisted tool rows that may receive a same-turn background-task update. */
|
|
590
|
+
#persistedToolMessages = new Map();
|
|
591
|
+
|
|
589
592
|
/** Reject new same-turn deliveries once the current query starts closing. */
|
|
590
593
|
#asyncTaskDeliveryClosed = true;
|
|
591
594
|
|
|
@@ -1377,57 +1380,57 @@ export class Engine {
|
|
|
1377
1380
|
return this.#conversationStore.readCompactSummary();
|
|
1378
1381
|
}
|
|
1379
1382
|
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
*
|
|
1384
|
-
* Multi-VP fan-out (Bug 1): when several engines run the same user
|
|
1385
|
-
* prompt in parallel, we must NOT each write our own copy of the user
|
|
1386
|
-
* message — `coord.ingest`/the orchestrator already wrote it once. Pass
|
|
1387
|
-
* `userAlreadyPersisted: true` from the caller to skip the user-row
|
|
1388
|
-
* append while still persisting the assistant + tool rows.
|
|
1389
|
-
*
|
|
1390
|
-
* @param {string} userContent
|
|
1391
|
-
* @param {string} assistantContent
|
|
1392
|
-
* @param {object[]} [toolCalls]
|
|
1393
|
-
* @param {string} [sessionId]
|
|
1394
|
-
* @param {boolean} [userAlreadyPersisted]
|
|
1395
|
-
*/
|
|
1396
|
-
#persistMessages(userContent, assistantContent, toolCalls, sessionId, userAlreadyPersisted = false) {
|
|
1397
|
-
if (!this.#conversationStore) return;
|
|
1398
|
-
if (this.#config._readOnly) return;
|
|
1399
|
-
|
|
1400
|
-
// Persist with the active runtime thread. Legacy / non-group flows use
|
|
1401
|
-
// MAIN_THREAD_ID; group VP flows pass their classified threadId.
|
|
1402
|
-
const threadId = this.#currentThreadId || MAIN_THREAD_ID;
|
|
1403
|
-
|
|
1404
|
-
// Persist user message — unless an upstream caller (e.g. the group
|
|
1405
|
-
// coordinator) has already done so for this turn.
|
|
1406
|
-
if (!userAlreadyPersisted) {
|
|
1407
|
-
this.#conversationStore.append({
|
|
1408
|
-
role: 'user',
|
|
1409
|
-
content: userContent,
|
|
1410
|
-
threadId,
|
|
1411
|
-
// Bug 6: stamp sessionId/chatId so history replay can route by container.
|
|
1412
|
-
...(sessionId ? { sessionId } : {}),
|
|
1413
|
-
...(this.#chatId ? { chatId: this.#chatId } : {}),
|
|
1414
|
-
});
|
|
1415
|
-
}
|
|
1383
|
+
#canPersistConversation() {
|
|
1384
|
+
return Boolean(this.#conversationStore) && !this.#config._readOnly;
|
|
1385
|
+
}
|
|
1416
1386
|
|
|
1417
|
-
|
|
1418
|
-
const
|
|
1419
|
-
role:
|
|
1420
|
-
content:
|
|
1421
|
-
|
|
1422
|
-
|
|
1387
|
+
#conversationRecord(message, { sessionId, turnId, model, incomplete = false, stopReason = null } = {}) {
|
|
1388
|
+
const record = {
|
|
1389
|
+
role: message.role,
|
|
1390
|
+
content: typeof message.content === 'string'
|
|
1391
|
+
? message.content
|
|
1392
|
+
: JSON.stringify(message.content ?? ''),
|
|
1393
|
+
model: model || this.#config.model,
|
|
1394
|
+
threadId: this.#currentThreadId || MAIN_THREAD_ID,
|
|
1423
1395
|
...(sessionId ? { sessionId } : {}),
|
|
1424
1396
|
...(this.#chatId ? { chatId: this.#chatId } : {}),
|
|
1425
|
-
...(this.#vpId ? { speakerVpId: this.#vpId } : {}),
|
|
1426
1397
|
};
|
|
1427
|
-
if (
|
|
1428
|
-
|
|
1398
|
+
if (message.toolCallId) record.toolCallId = message.toolCallId;
|
|
1399
|
+
if (Array.isArray(message.toolCalls) && message.toolCalls.length > 0) record.toolCalls = message.toolCalls;
|
|
1400
|
+
if (Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length > 0) record.thinkingBlocks = message.thinkingBlocks;
|
|
1401
|
+
if (message.isError) record.isError = true;
|
|
1402
|
+
if (message.imageAssetAnchor) record.imageAssetAnchor = true;
|
|
1403
|
+
if (message._reflection) record._reflection = true;
|
|
1404
|
+
if (Array.isArray(message.foldedMessageIds) && message.foldedMessageIds.length > 0) {
|
|
1405
|
+
record.foldedMessageIds = [...message.foldedMessageIds];
|
|
1429
1406
|
}
|
|
1430
|
-
|
|
1407
|
+
if (turnId && (message.role === 'assistant' || message.role === 'tool')) record.turnId = turnId;
|
|
1408
|
+
if (this.#vpId && (message.role === 'assistant' || message.role === 'tool')) record.speakerVpId = this.#vpId;
|
|
1409
|
+
if (incomplete) record.incomplete = true;
|
|
1410
|
+
if (stopReason) record.stopReason = stopReason;
|
|
1411
|
+
return record;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
#persistConversationMessage(message, context = {}) {
|
|
1415
|
+
if (!this.#canPersistConversation() || !message?.role) return null;
|
|
1416
|
+
const hasContent = typeof message.content === 'string'
|
|
1417
|
+
? message.content.length > 0
|
|
1418
|
+
: message.content != null;
|
|
1419
|
+
const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0;
|
|
1420
|
+
const hasThinking = Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length > 0;
|
|
1421
|
+
if (!hasContent && !hasToolCalls && !hasThinking && message.role !== 'tool') return null;
|
|
1422
|
+
return this.#conversationStore.append(this.#conversationRecord(message, context));
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
#persistFoldedRange(messages, startIdx, endIdx, reflection, context = {}) {
|
|
1426
|
+
if (!this.#canPersistConversation() || typeof this.#conversationStore?.foldMessages !== 'function') return null;
|
|
1427
|
+
const persistedRows = (messages || []).slice(startIdx, endIdx + 1)
|
|
1428
|
+
.map(message => message?._persistedMessageId || message?.id)
|
|
1429
|
+
.filter(id => typeof id === 'string' && id)
|
|
1430
|
+
.map(id => ({ id }));
|
|
1431
|
+
if (persistedRows.length === 0) return null;
|
|
1432
|
+
const record = this.#conversationRecord(reflection, context);
|
|
1433
|
+
return this.#conversationStore.foldMessages(persistedRows, record);
|
|
1431
1434
|
}
|
|
1432
1435
|
|
|
1433
1436
|
/**
|
|
@@ -1652,6 +1655,15 @@ export class Engine {
|
|
|
1652
1655
|
? toolMsg.content
|
|
1653
1656
|
: this.#formatTaskResultUpdateContent(toolMsg.content);
|
|
1654
1657
|
toolMsg.content = `${prior}\n\n${appendText}`;
|
|
1658
|
+
const persistedTool = this.#persistedToolMessages.get(update.toolCallId);
|
|
1659
|
+
if (persistedTool && typeof this.#conversationStore?.update === 'function') {
|
|
1660
|
+
const durablePrior = typeof persistedTool.content === 'string'
|
|
1661
|
+
? persistedTool.content
|
|
1662
|
+
: this.#formatTaskResultUpdateContent(persistedTool.content);
|
|
1663
|
+
const durableContent = `${durablePrior}\n\n${appendText}`;
|
|
1664
|
+
const updated = this.#conversationStore.update(persistedTool, { content: durableContent });
|
|
1665
|
+
if (updated) this.#persistedToolMessages.set(update.toolCallId, updated);
|
|
1666
|
+
}
|
|
1655
1667
|
applied.push(update);
|
|
1656
1668
|
if (this.#acceptedAsyncTaskResults.has(update.taskId)) {
|
|
1657
1669
|
this.#pendingAsyncTaskConfirmIds.add(update.taskId);
|
|
@@ -1688,6 +1700,12 @@ export class Engine {
|
|
|
1688
1700
|
return deliveries.length;
|
|
1689
1701
|
}
|
|
1690
1702
|
|
|
1703
|
+
#persistAppendedUserMessage(item, sessionId) {
|
|
1704
|
+
if (!item || item.persisted || item.internal) return;
|
|
1705
|
+
this.#persistConversationMessage({ role: 'user', content: item.content }, { sessionId });
|
|
1706
|
+
item.persisted = true;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1691
1709
|
#drainPendingUserMessages(drainPendingUserMessages) {
|
|
1692
1710
|
const pending = [];
|
|
1693
1711
|
this.#externalUserWakePending = false;
|
|
@@ -1726,6 +1744,7 @@ export class Engine {
|
|
|
1726
1744
|
content,
|
|
1727
1745
|
preview,
|
|
1728
1746
|
internal: Boolean(item.internal),
|
|
1747
|
+
persisted: Boolean(item.persisted),
|
|
1729
1748
|
taskId,
|
|
1730
1749
|
};
|
|
1731
1750
|
})
|
|
@@ -1850,6 +1869,7 @@ export class Engine {
|
|
|
1850
1869
|
this.#asyncTaskToolMeta.clear();
|
|
1851
1870
|
this.#pendingTaskResultMessages.length = 0;
|
|
1852
1871
|
this.#pendingTaskResultUpdates.length = 0;
|
|
1872
|
+
this.#persistedToolMessages.clear();
|
|
1853
1873
|
// Release any parked waiters so they don't pin a microtask after
|
|
1854
1874
|
// query() returns. The loop has already exited so they're harmless,
|
|
1855
1875
|
// but cleanup keeps the promise graph tight.
|
|
@@ -1879,6 +1899,24 @@ export class Engine {
|
|
|
1879
1899
|
const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
|
|
1880
1900
|
? threadId.trim()
|
|
1881
1901
|
: MAIN_THREAD_ID;
|
|
1902
|
+
const queryTurnId = randomUUID();
|
|
1903
|
+
const queryStartedAt = Date.now();
|
|
1904
|
+
const userQuestionPreview = String(prompt || '').slice(0, 200);
|
|
1905
|
+
const queryVpId = vpPersona && typeof vpPersona === 'object'
|
|
1906
|
+
&& typeof vpPersona.vpId === 'string'
|
|
1907
|
+
? vpPersona.vpId
|
|
1908
|
+
: (typeof senderVpId === 'string' ? senderVpId : null);
|
|
1909
|
+
|
|
1910
|
+
// Durability boundary: a valid user turn must exist on disk before any
|
|
1911
|
+
// memory pre-flow or provider request can fail. The Web Session bridge
|
|
1912
|
+
// already writes one shared user row before multi-VP fan-out, so those
|
|
1913
|
+
// callers set userAlreadyPersisted and every VP skips this append.
|
|
1914
|
+
if (!userAlreadyPersisted) {
|
|
1915
|
+
this.#persistConversationMessage({ role: 'user', content: prompt }, {
|
|
1916
|
+
sessionId: runtimeSessionId,
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1882
1920
|
const perfTraceId = typeof inboundEnvelope?._perfTraceId === 'string' && inboundEnvelope._perfTraceId.trim()
|
|
1883
1921
|
? inboundEnvelope._perfTraceId.trim()
|
|
1884
1922
|
: (typeof inboundEnvelope?.perfTraceId === 'string' && inboundEnvelope.perfTraceId.trim() ? inboundEnvelope.perfTraceId.trim() : null);
|
|
@@ -2159,7 +2197,10 @@ export class Engine {
|
|
|
2159
2197
|
// reflection; only high context pressure (>=80% of model window)
|
|
2160
2198
|
// enables the carry-forward rewrite.
|
|
2161
2199
|
if (groupReflectionAllowed) {
|
|
2162
|
-
yield* this.#applyPendingT2Reflections(conversationMessages, prompt
|
|
2200
|
+
yield* this.#applyPendingT2Reflections(conversationMessages, prompt, {
|
|
2201
|
+
sessionId: runtimeSessionId,
|
|
2202
|
+
model: this.#config.model,
|
|
2203
|
+
});
|
|
2163
2204
|
}
|
|
2164
2205
|
|
|
2165
2206
|
// PR-L: track this query()'s tool-arc for reflection.
|
|
@@ -2194,13 +2235,6 @@ export class Engine {
|
|
|
2194
2235
|
// `queryTurnId` is the wire-level turn identifier; every event emitted
|
|
2195
2236
|
// during this query() carries it as `turnId`. Each LLM call inside
|
|
2196
2237
|
// the loop is a `loopNumber` (was wire field `turnNumber`).
|
|
2197
|
-
const queryTurnId = randomUUID();
|
|
2198
|
-
const queryStartedAt = Date.now();
|
|
2199
|
-
const userQuestionPreview = String(prompt || '').slice(0, 200);
|
|
2200
|
-
const queryVpId = vpPersona && typeof vpPersona === 'object'
|
|
2201
|
-
&& typeof vpPersona.vpId === 'string'
|
|
2202
|
-
? vpPersona.vpId
|
|
2203
|
-
: (typeof senderVpId === 'string' ? senderVpId : null);
|
|
2204
2238
|
|
|
2205
2239
|
yield {
|
|
2206
2240
|
type: 'turn_open',
|
|
@@ -2250,7 +2284,8 @@ export class Engine {
|
|
|
2250
2284
|
let continueTurns = 0; // auto-continue counter
|
|
2251
2285
|
let toolLoopTurns = 0; // task-327b: tool-use turns for long-loop auto-bump
|
|
2252
2286
|
let fullResponseText = '';
|
|
2253
|
-
let
|
|
2287
|
+
let displayImageAnchorMessage = null;
|
|
2288
|
+
let lastPersistedAssistantMessage = null;
|
|
2254
2289
|
let currentModel = this.#config.model;
|
|
2255
2290
|
let cumulativeInputTokens = 0;
|
|
2256
2291
|
let cumulativeOutputTokens = 0;
|
|
@@ -2323,6 +2358,18 @@ export class Engine {
|
|
|
2323
2358
|
});
|
|
2324
2359
|
let ttfbMs = null; // Time to first token
|
|
2325
2360
|
let responseText = '';
|
|
2361
|
+
let incompleteAssistantPersisted = false;
|
|
2362
|
+
const persistIncompleteAssistantOnce = (reason) => {
|
|
2363
|
+
if (incompleteAssistantPersisted || !responseText) return null;
|
|
2364
|
+
incompleteAssistantPersisted = true;
|
|
2365
|
+
return this.#persistConversationMessage({ role: 'assistant', content: responseText }, {
|
|
2366
|
+
sessionId: runtimeSessionId,
|
|
2367
|
+
turnId: vpTurnId || queryTurnId,
|
|
2368
|
+
model: currentModel,
|
|
2369
|
+
incomplete: true,
|
|
2370
|
+
stopReason: reason,
|
|
2371
|
+
});
|
|
2372
|
+
};
|
|
2326
2373
|
const toolCalls = [];
|
|
2327
2374
|
const thinkingBlocks = []; // task-327d: collected from adapter for round-trip
|
|
2328
2375
|
let stopReason = 'end_turn';
|
|
@@ -2351,6 +2398,7 @@ export class Engine {
|
|
|
2351
2398
|
const appendedBeforeStream = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
2352
2399
|
if (appendedBeforeStream.length > 0) {
|
|
2353
2400
|
for (const item of appendedBeforeStream) {
|
|
2401
|
+
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
2354
2402
|
conversationMessages.push({ role: 'user', content: item.content });
|
|
2355
2403
|
yield {
|
|
2356
2404
|
type: 'user_append',
|
|
@@ -2671,6 +2719,7 @@ export class Engine {
|
|
|
2671
2719
|
|| err?.name === 'LLMAbortError'
|
|
2672
2720
|
|| (signal?.aborted && /abort/i.test(err?.message || ''));
|
|
2673
2721
|
if (earlyIsAbort || signal?.aborted) {
|
|
2722
|
+
persistIncompleteAssistantOnce('aborted');
|
|
2674
2723
|
traceRequest('llm.request_abort', {
|
|
2675
2724
|
durationMs: perfNowMs() - requestPerfStart,
|
|
2676
2725
|
ok: false,
|
|
@@ -2741,6 +2790,7 @@ export class Engine {
|
|
|
2741
2790
|
};
|
|
2742
2791
|
const slept = await sleepWithAbort(delayMs, signal);
|
|
2743
2792
|
if (!slept || signal?.aborted) {
|
|
2793
|
+
persistIncompleteAssistantOnce('aborted');
|
|
2744
2794
|
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
|
|
2745
2795
|
yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
|
|
2746
2796
|
break;
|
|
@@ -2761,6 +2811,8 @@ export class Engine {
|
|
|
2761
2811
|
continue;
|
|
2762
2812
|
}
|
|
2763
2813
|
|
|
2814
|
+
persistIncompleteAssistantOnce('error');
|
|
2815
|
+
|
|
2764
2816
|
this.#trace.endTurn(turnId, {
|
|
2765
2817
|
model: currentModel,
|
|
2766
2818
|
inputTokens: totalUsage.inputTokens,
|
|
@@ -2873,6 +2925,67 @@ export class Engine {
|
|
|
2873
2925
|
rawResponse,
|
|
2874
2926
|
});
|
|
2875
2927
|
|
|
2928
|
+
// Build and durably append this completed provider response before
|
|
2929
|
+
// yielding any post-stream diagnostics. A consumer may stop iterating at
|
|
2930
|
+
// any yield; persistence therefore cannot wait for turn_end or even the
|
|
2931
|
+
// debug `loop` event below.
|
|
2932
|
+
const assistantMsg = { role: 'assistant', content: responseText };
|
|
2933
|
+
if (toolCalls.length > 0) {
|
|
2934
|
+
assistantMsg.toolCalls = toolCalls.map(tc => ({
|
|
2935
|
+
id: tc.id,
|
|
2936
|
+
name: tc.name,
|
|
2937
|
+
input: tc.input,
|
|
2938
|
+
}));
|
|
2939
|
+
}
|
|
2940
|
+
if (thinkingBlocks.length > 0) {
|
|
2941
|
+
assistantMsg.thinkingBlocks = thinkingBlocks.map(tb => (
|
|
2942
|
+
tb.redacted
|
|
2943
|
+
? { redacted: true, data: tb.data, signature: tb.signature }
|
|
2944
|
+
: { thinking: tb.thinking, signature: tb.signature }
|
|
2945
|
+
));
|
|
2946
|
+
}
|
|
2947
|
+
if (vpPersona && vpPersona.vpId) {
|
|
2948
|
+
const planForThisVp = (vpPlan && typeof vpPlan === 'object'
|
|
2949
|
+
&& typeof vpPlan.vpId === 'string' && vpPlan.vpId === vpPersona.vpId)
|
|
2950
|
+
? vpPlan
|
|
2951
|
+
: null;
|
|
2952
|
+
attachRouterPlan(assistantMsg, {
|
|
2953
|
+
vpId: vpPersona.vpId,
|
|
2954
|
+
forwardQuery: planForThisVp && planForThisVp.forwardQuery
|
|
2955
|
+
? planForThisVp.forwardQuery
|
|
2956
|
+
: { userOriginal: prompt || '', intent: '' },
|
|
2957
|
+
preselect: planForThisVp && planForThisVp.preselect
|
|
2958
|
+
? planForThisVp.preselect
|
|
2959
|
+
: undefined,
|
|
2960
|
+
thinking: planForThisVp && (planForThisVp.thinking === 'high' || planForThisVp.thinking === 'max')
|
|
2961
|
+
? planForThisVp.thinking
|
|
2962
|
+
: null,
|
|
2963
|
+
thinkingReason: planForThisVp && typeof planForThisVp.thinkingReason === 'string'
|
|
2964
|
+
? planForThisVp.thinkingReason
|
|
2965
|
+
: '',
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
const previousImageAnchorMessage = displayImageAnchorMessage;
|
|
2969
|
+
if (previousImageAnchorMessage && typeof this.#conversationStore?.update === 'function') {
|
|
2970
|
+
const cleared = this.#conversationStore.update(previousImageAnchorMessage, { imageAssetAnchor: false });
|
|
2971
|
+
if (cleared) displayImageAnchorMessage = null;
|
|
2972
|
+
}
|
|
2973
|
+
if (previousImageAnchorMessage && displayImageAnchorMessage === null) assistantMsg.imageAssetAnchor = true;
|
|
2974
|
+
const persistedAssistantMessage = this.#persistConversationMessage(assistantMsg, {
|
|
2975
|
+
sessionId: runtimeSessionId,
|
|
2976
|
+
turnId: vpTurnId || queryTurnId,
|
|
2977
|
+
model: currentModel,
|
|
2978
|
+
});
|
|
2979
|
+
if (persistedAssistantMessage) {
|
|
2980
|
+
assistantMsg._persistedMessageId = persistedAssistantMessage.id;
|
|
2981
|
+
if (assistantMsg.imageAssetAnchor) displayImageAnchorMessage = persistedAssistantMessage;
|
|
2982
|
+
lastPersistedAssistantMessage = persistedAssistantMessage;
|
|
2983
|
+
}
|
|
2984
|
+
if (previousImageAnchorMessage && displayImageAnchorMessage === null && !persistedAssistantMessage) {
|
|
2985
|
+
const restored = this.#conversationStore.update(previousImageAnchorMessage, { imageAssetAnchor: true });
|
|
2986
|
+
displayImageAnchorMessage = restored || null;
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2876
2989
|
// Emit `loop` event for the debug panel.
|
|
2877
2990
|
// feat-6af5f9f1 PR B: a Loop is one LLM call inside a Turn. The wire
|
|
2878
2991
|
// event was historically named `debug_turn` and carried `turnNumber`,
|
|
@@ -2913,63 +3026,20 @@ export class Engine {
|
|
|
2913
3026
|
rawResponse,
|
|
2914
3027
|
};
|
|
2915
3028
|
|
|
2916
|
-
//
|
|
2917
|
-
|
|
2918
|
-
if (toolCalls.length > 0) {
|
|
2919
|
-
assistantMsg.toolCalls = toolCalls.map(tc => ({
|
|
2920
|
-
id: tc.id,
|
|
2921
|
-
name: tc.name,
|
|
2922
|
-
input: tc.input,
|
|
2923
|
-
}));
|
|
2924
|
-
}
|
|
2925
|
-
// task-327d: persist thinking blocks for the next turn's replay.
|
|
2926
|
-
// Anthropic requires assistant.thinking blocks to be echoed back
|
|
2927
|
-
// verbatim (text + signature) when the previous turn used extended
|
|
2928
|
-
// thinking — see translateMessages in anthropic.js.
|
|
2929
|
-
if (thinkingBlocks.length > 0) {
|
|
2930
|
-
assistantMsg.thinkingBlocks = thinkingBlocks.map(tb => (
|
|
2931
|
-
tb.redacted
|
|
2932
|
-
? { redacted: true, data: tb.data, signature: tb.signature }
|
|
2933
|
-
: { thinking: tb.thinking, signature: tb.signature }
|
|
2934
|
-
));
|
|
2935
|
-
}
|
|
2936
|
-
// Phase 8 (DESIGN.md §9.15): carry the router plan back on the
|
|
2937
|
-
// assistant message that produced it. Stripped at the wire by
|
|
2938
|
-
// stripMetaForWire — pure bookkeeping for priorPlan continuity.
|
|
2939
|
-
if (vpPersona && vpPersona.vpId) {
|
|
2940
|
-
// PR-I: when the dispatcher hands us a per-VP plan whose vpId matches
|
|
2941
|
-
// the active persona, persist its `forwardQuery`, `preselect`, and
|
|
2942
|
-
// `thinking` on the assistant message so the next turn's
|
|
2943
|
-
// priorPlan continuity (DESIGN.md §9.15) sees the live router's
|
|
2944
|
-
// decision — not a synthetic stub.
|
|
2945
|
-
const planForThisVp = (vpPlan && typeof vpPlan === 'object'
|
|
2946
|
-
&& typeof vpPlan.vpId === 'string' && vpPlan.vpId === vpPersona.vpId)
|
|
2947
|
-
? vpPlan
|
|
2948
|
-
: null;
|
|
2949
|
-
attachRouterPlan(assistantMsg, {
|
|
2950
|
-
vpId: vpPersona.vpId,
|
|
2951
|
-
forwardQuery: planForThisVp && planForThisVp.forwardQuery
|
|
2952
|
-
? planForThisVp.forwardQuery
|
|
2953
|
-
: { userOriginal: prompt || '', intent: '' },
|
|
2954
|
-
preselect: planForThisVp && planForThisVp.preselect
|
|
2955
|
-
? planForThisVp.preselect
|
|
2956
|
-
: undefined,
|
|
2957
|
-
thinking: planForThisVp && (planForThisVp.thinking === 'high' || planForThisVp.thinking === 'max')
|
|
2958
|
-
? planForThisVp.thinking
|
|
2959
|
-
: null,
|
|
2960
|
-
thinkingReason: planForThisVp && typeof planForThisVp.thinkingReason === 'string'
|
|
2961
|
-
? planForThisVp.thinkingReason
|
|
2962
|
-
: '',
|
|
2963
|
-
});
|
|
2964
|
-
}
|
|
3029
|
+
// Keep the same durable assistant object in the live model history.
|
|
3030
|
+
// Private router metadata is stripped only at the next wire boundary.
|
|
2965
3031
|
conversationMessages.push(assistantMsg);
|
|
2966
3032
|
fullResponseText += responseText;
|
|
2967
3033
|
|
|
2968
3034
|
// ─── Handle max_tokens → auto-continue ────────────
|
|
2969
3035
|
if (stopReason === 'max_tokens' && continueTurns < MAX_CONTINUE_TURNS) {
|
|
2970
3036
|
continueTurns++;
|
|
2971
|
-
//
|
|
2972
|
-
|
|
3037
|
+
// This synthetic continuation is part of the model-visible protocol.
|
|
3038
|
+
// Persist it before the next provider request so a crash does not leave
|
|
3039
|
+
// the completed assistant row without its following user boundary.
|
|
3040
|
+
const continueMessage = { role: 'user', content: 'Continue' };
|
|
3041
|
+
this.#persistConversationMessage(continueMessage, { sessionId: runtimeSessionId });
|
|
3042
|
+
conversationMessages.push(continueMessage);
|
|
2973
3043
|
yield { type: 'turn_end', turnNumber, stopReason: 'max_tokens_continue', threadId };
|
|
2974
3044
|
continue; // loop back to call adapter again
|
|
2975
3045
|
}
|
|
@@ -2981,6 +3051,7 @@ export class Engine {
|
|
|
2981
3051
|
const appendedAfterAssistant = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
2982
3052
|
if (appendedAfterAssistant.length > 0) {
|
|
2983
3053
|
for (const item of appendedAfterAssistant) {
|
|
3054
|
+
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
2984
3055
|
conversationMessages.push({ role: 'user', content: item.content });
|
|
2985
3056
|
yield {
|
|
2986
3057
|
type: 'user_append',
|
|
@@ -3072,6 +3143,7 @@ export class Engine {
|
|
|
3072
3143
|
const appendedAfterAsyncWait = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
3073
3144
|
if (appendedAfterAsyncWait.length > 0) {
|
|
3074
3145
|
for (const item of appendedAfterAsyncWait) {
|
|
3146
|
+
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
3075
3147
|
conversationMessages.push({ role: 'user', content: item.content });
|
|
3076
3148
|
yield {
|
|
3077
3149
|
type: 'user_append',
|
|
@@ -3102,6 +3174,7 @@ export class Engine {
|
|
|
3102
3174
|
throw new Error('Could not close pending user input for terminal completion');
|
|
3103
3175
|
}
|
|
3104
3176
|
for (const item of appendedBeforeClose) {
|
|
3177
|
+
this.#persistAppendedUserMessage(item, runtimeSessionId);
|
|
3105
3178
|
conversationMessages.push({ role: 'user', content: item.content });
|
|
3106
3179
|
yield {
|
|
3107
3180
|
type: 'user_append',
|
|
@@ -3120,57 +3193,12 @@ export class Engine {
|
|
|
3120
3193
|
}
|
|
3121
3194
|
yield { type: 'turn_end', turnNumber, stopReason, threadId, terminal: true };
|
|
3122
3195
|
|
|
3123
|
-
//
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
// but receives both configs — messages are persisted with primary model name
|
|
3130
|
-
const hookResult = await runStopHooks({
|
|
3131
|
-
yeaftDir: this.#yeaftDir,
|
|
3132
|
-
conversationStore: this.#conversationStore,
|
|
3133
|
-
adapter: this.#adapter,
|
|
3134
|
-
config: this.#fastConfig,
|
|
3135
|
-
primaryModel: this.#config.model,
|
|
3136
|
-
messages: conversationMessages,
|
|
3137
|
-
// Reflect-persist fix: tell stop-hooks the EXACT turn boundary
|
|
3138
|
-
// instead of letting it heuristically scan back to the last
|
|
3139
|
-
// role:'user'. With T1/T2 reflection collapse, the last
|
|
3140
|
-
// role:'user' is the synthetic reflection message — not the
|
|
3141
|
-
// original user prompt — so the heuristic was dropping
|
|
3142
|
-
// earlier reflection messages and the original prompt off
|
|
3143
|
-
// the persistence window. `turnStartIdx` is the index of
|
|
3144
|
-
// the original user prompt (set at query() entry); slicing
|
|
3145
|
-
// from there persists the full collapsed turn including all
|
|
3146
|
-
// reflection messages and the trailing assistant response.
|
|
3147
|
-
turnStartIdx,
|
|
3148
|
-
trace: this.#trace,
|
|
3149
|
-
// Bug 6: tag persisted messages with the originating group so
|
|
3150
|
-
// history replay can re-stamp them on reload.
|
|
3151
|
-
sessionId,
|
|
3152
|
-
threadId,
|
|
3153
|
-
turnId: vpTurnId || queryTurnId,
|
|
3154
|
-
vpId: this.#vpId,
|
|
3155
|
-
// Multi-VP fan-out (history-dedup): skip the user-row append
|
|
3156
|
-
// in stop-hooks when the orchestrator already wrote it once
|
|
3157
|
-
// for this turn. The hook still persists assistant + tool
|
|
3158
|
-
// rows for THIS VP's contribution.
|
|
3159
|
-
userAlreadyPersisted,
|
|
3160
|
-
hasDisplayImageAnchor,
|
|
3161
|
-
});
|
|
3162
|
-
|
|
3163
|
-
if (hookResult.consolidated) {
|
|
3164
|
-
yield { type: 'consolidate', archivedCount: 0, extractedCount: 0 };
|
|
3165
|
-
}
|
|
3166
|
-
} else {
|
|
3167
|
-
// Legacy path (no yeaftDir → use old behavior)
|
|
3168
|
-
this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls, sessionId, userAlreadyPersisted);
|
|
3169
|
-
|
|
3170
|
-
const consolidated = await this.#maybeConsolidate();
|
|
3171
|
-
if (consolidated && consolidated.archivedCount > 0) {
|
|
3172
|
-
yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
|
|
3173
|
-
}
|
|
3196
|
+
// Message durability is handled incrementally before this terminal
|
|
3197
|
+
// branch. End-of-turn owns maintenance only; re-appending the whole
|
|
3198
|
+
// turn here would duplicate rows and reintroduce the crash window.
|
|
3199
|
+
const consolidated = await this.#maybeConsolidate();
|
|
3200
|
+
if (consolidated && consolidated.archivedCount > 0) {
|
|
3201
|
+
yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
|
|
3174
3202
|
}
|
|
3175
3203
|
|
|
3176
3204
|
// ─── Post-turn AMS adjust ────────────────────────────────
|
|
@@ -3385,7 +3413,35 @@ export class Engine {
|
|
|
3385
3413
|
}
|
|
3386
3414
|
isError = toolErrorOutput === 'json-error-envelope' && isToolErrorOutput(output);
|
|
3387
3415
|
yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError, threadId: this.currentThreadId };
|
|
3388
|
-
if (displayImages.some(image => image.deliveryQueued === true)
|
|
3416
|
+
if (displayImages.some(image => image.deliveryQueued === true)
|
|
3417
|
+
&& lastPersistedAssistantMessage
|
|
3418
|
+
&& typeof this.#conversationStore?.update === 'function') {
|
|
3419
|
+
const priorAnchor = displayImageAnchorMessage;
|
|
3420
|
+
if (priorAnchor && priorAnchor.id !== lastPersistedAssistantMessage.id) {
|
|
3421
|
+
const cleared = this.#conversationStore.update(priorAnchor, { imageAssetAnchor: false });
|
|
3422
|
+
if (cleared) {
|
|
3423
|
+
const anchored = this.#conversationStore.update(lastPersistedAssistantMessage, {
|
|
3424
|
+
imageAssetAnchor: true,
|
|
3425
|
+
});
|
|
3426
|
+
if (anchored) {
|
|
3427
|
+
displayImageAnchorMessage = anchored;
|
|
3428
|
+
lastPersistedAssistantMessage = anchored;
|
|
3429
|
+
} else {
|
|
3430
|
+
displayImageAnchorMessage = this.#conversationStore.update(priorAnchor, {
|
|
3431
|
+
imageAssetAnchor: true,
|
|
3432
|
+
}) || null;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
} else if (!priorAnchor) {
|
|
3436
|
+
const anchored = this.#conversationStore.update(lastPersistedAssistantMessage, {
|
|
3437
|
+
imageAssetAnchor: true,
|
|
3438
|
+
});
|
|
3439
|
+
if (anchored) {
|
|
3440
|
+
displayImageAnchorMessage = anchored;
|
|
3441
|
+
lastPersistedAssistantMessage = anchored;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3389
3445
|
} catch (err) {
|
|
3390
3446
|
output = `Error: ${err.message}`;
|
|
3391
3447
|
isError = true;
|
|
@@ -3444,12 +3500,24 @@ export class Engine {
|
|
|
3444
3500
|
toolName: tc.name,
|
|
3445
3501
|
language: this.#config?.language,
|
|
3446
3502
|
});
|
|
3447
|
-
|
|
3503
|
+
const toolMessage = {
|
|
3448
3504
|
role: 'tool',
|
|
3449
3505
|
toolCallId: tc.id,
|
|
3450
3506
|
content: contextOutput,
|
|
3451
3507
|
isError,
|
|
3508
|
+
};
|
|
3509
|
+
conversationMessages.push(toolMessage);
|
|
3510
|
+
// Model context may use a bounded copy, but durable conversation
|
|
3511
|
+
// history keeps the raw normalized tool output for recovery/debug.
|
|
3512
|
+
const persistedToolMessage = this.#persistConversationMessage({ ...toolMessage, content: output }, {
|
|
3513
|
+
sessionId: runtimeSessionId,
|
|
3514
|
+
turnId: vpTurnId || queryTurnId,
|
|
3515
|
+
model: currentModel,
|
|
3452
3516
|
});
|
|
3517
|
+
if (persistedToolMessage) {
|
|
3518
|
+
toolMessage._persistedMessageId = persistedToolMessage.id;
|
|
3519
|
+
this.#persistedToolMessages.set(tc.id, persistedToolMessage);
|
|
3520
|
+
}
|
|
3453
3521
|
|
|
3454
3522
|
// PR-L: persist this execution to the exec-log for fallback-stub
|
|
3455
3523
|
// and duplicate-call detection. Best-effort — disk failures are
|
|
@@ -3563,6 +3631,20 @@ export class Engine {
|
|
|
3563
3631
|
const next = collapseRangeToReflection(
|
|
3564
3632
|
conversationMessages, batchStart, batchEnd, content,
|
|
3565
3633
|
);
|
|
3634
|
+
const reflectionMessage = next[batchStart];
|
|
3635
|
+
const durableRowsInRange = conversationMessages
|
|
3636
|
+
.slice(batchStart, batchEnd + 1)
|
|
3637
|
+
.some(message => message?._persistedMessageId || message?.id);
|
|
3638
|
+
const persistedReflection = this.#persistFoldedRange(
|
|
3639
|
+
conversationMessages,
|
|
3640
|
+
batchStart,
|
|
3641
|
+
batchEnd,
|
|
3642
|
+
reflectionMessage,
|
|
3643
|
+
{ sessionId: runtimeSessionId, model: currentModel },
|
|
3644
|
+
);
|
|
3645
|
+
if (durableRowsInRange && !persistedReflection) {
|
|
3646
|
+
throw new Error('T1 reflection could not publish its durable range replacement');
|
|
3647
|
+
}
|
|
3566
3648
|
conversationMessages.length = 0;
|
|
3567
3649
|
for (const m of next) conversationMessages.push(m);
|
|
3568
3650
|
// After collapse: the just-inserted reflection lives at
|
|
@@ -3696,8 +3778,9 @@ export class Engine {
|
|
|
3696
3778
|
*
|
|
3697
3779
|
* @param {Array} conversationMessages
|
|
3698
3780
|
* @param {string} originalUserMsg
|
|
3781
|
+
* @param {{sessionId?: string, model?: string}} context
|
|
3699
3782
|
*/
|
|
3700
|
-
async *#applyPendingT2Reflections(conversationMessages, originalUserMsg) {
|
|
3783
|
+
async *#applyPendingT2Reflections(conversationMessages, originalUserMsg, context = {}) {
|
|
3701
3784
|
if (this.#pendingT2.size === 0) return;
|
|
3702
3785
|
// Drain in insertion order (Map preserves it). We process all entries
|
|
3703
3786
|
// because the user could send multiple prompts back-to-back before
|
|
@@ -3739,8 +3822,20 @@ export class Engine {
|
|
|
3739
3822
|
continue;
|
|
3740
3823
|
}
|
|
3741
3824
|
|
|
3742
|
-
// Rewrite history.
|
|
3825
|
+
// Rewrite history and publish the same logical replacement to disk.
|
|
3743
3826
|
const next = collapseRangeToReflection(conversationMessages, startIdx, endIdx, content);
|
|
3827
|
+
const reflectionMessage = next[startIdx];
|
|
3828
|
+
const durableRowsInRange = conversationMessages
|
|
3829
|
+
.slice(startIdx, endIdx + 1)
|
|
3830
|
+
.some(message => message?._persistedMessageId || message?.id);
|
|
3831
|
+
const persistedReflection = this.#persistFoldedRange(
|
|
3832
|
+
conversationMessages,
|
|
3833
|
+
startIdx,
|
|
3834
|
+
endIdx,
|
|
3835
|
+
reflectionMessage,
|
|
3836
|
+
context,
|
|
3837
|
+
);
|
|
3838
|
+
if (durableRowsInRange && !persistedReflection) continue;
|
|
3744
3839
|
// Mutate in place so caller's reference stays valid.
|
|
3745
3840
|
conversationMessages.length = 0;
|
|
3746
3841
|
for (const m of next) conversationMessages.push(m);
|
|
@@ -98,9 +98,9 @@ export function stripMetaForWire(messages) {
|
|
|
98
98
|
let mutated = false;
|
|
99
99
|
const out = messages.map(m => {
|
|
100
100
|
if (m && typeof m === 'object'
|
|
101
|
-
&& ('_meta' in m || '_runtimeTurnId' in m || '_partialTurn' in m)) {
|
|
101
|
+
&& ('_meta' in m || '_runtimeTurnId' in m || '_partialTurn' in m || '_persistedMessageId' in m)) {
|
|
102
102
|
mutated = true;
|
|
103
|
-
const { _meta, _runtimeTurnId, _partialTurn, ...rest } = m;
|
|
103
|
+
const { _meta, _runtimeTurnId, _partialTurn, _persistedMessageId, ...rest } = m;
|
|
104
104
|
return rest;
|
|
105
105
|
}
|
|
106
106
|
return m;
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -1176,12 +1176,15 @@ function isPersistedInternalMessage(m) {
|
|
|
1176
1176
|
* @param {object} m — record from conversationStore.loadRecent*()
|
|
1177
1177
|
* @returns {object|null} history-shape entry, or null to skip
|
|
1178
1178
|
*/
|
|
1179
|
-
function projectPersistedToHistoryEntry(m) {
|
|
1179
|
+
function projectPersistedToHistoryEntry(m, { includeReflections = false } = {}) {
|
|
1180
1180
|
if (!m) return null;
|
|
1181
1181
|
if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') return null;
|
|
1182
|
-
if (isPersistedInternalMessage(m)) return null;
|
|
1182
|
+
if (isPersistedInternalMessage(m) && !(includeReflections && m._reflection === true)) return null;
|
|
1183
1183
|
const entry = { role: m.role, content: m.role === 'tool' ? m.content : __testNormalizePersistedVisibleContent(m.content) };
|
|
1184
|
-
if (m.id)
|
|
1184
|
+
if (m.id) {
|
|
1185
|
+
entry.id = m.id;
|
|
1186
|
+
entry._persistedMessageId = m.id;
|
|
1187
|
+
}
|
|
1185
1188
|
entry.threadId = m.threadId || m.turnId || 'main';
|
|
1186
1189
|
if (m.turnId) entry.turnId = m.turnId;
|
|
1187
1190
|
if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
|
|
@@ -1441,14 +1444,18 @@ function hydrateGroupHistory(sessionId) {
|
|
|
1441
1444
|
if (!session?.conversationStore || !sessionId) return [];
|
|
1442
1445
|
let recent;
|
|
1443
1446
|
try {
|
|
1444
|
-
recent = session.conversationStore.loadRecentBySession(
|
|
1447
|
+
recent = session.conversationStore.loadRecentBySession(
|
|
1448
|
+
sessionId,
|
|
1449
|
+
undefined,
|
|
1450
|
+
{ includeReflections: true },
|
|
1451
|
+
);
|
|
1445
1452
|
} catch (err) {
|
|
1446
1453
|
console.warn('[Yeaft] hydrateGroupHistory failed (sessionId=%s):', sessionId, err?.message || err);
|
|
1447
1454
|
return [];
|
|
1448
1455
|
}
|
|
1449
1456
|
const out = [];
|
|
1450
1457
|
for (const m of recent || []) {
|
|
1451
|
-
const entry = projectPersistedToHistoryEntry(m);
|
|
1458
|
+
const entry = projectPersistedToHistoryEntry(m, { includeReflections: true });
|
|
1452
1459
|
if (entry) out.push(entry);
|
|
1453
1460
|
}
|
|
1454
1461
|
return out;
|
|
@@ -5079,7 +5086,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
|
|
|
5079
5086
|
vpTurnId: turnId,
|
|
5080
5087
|
drainPendingUserMessages: () => {
|
|
5081
5088
|
if (!thread || !Array.isArray(thread.pendingQueries) || thread.pendingQueries.length === 0) return [];
|
|
5082
|
-
return thread.pendingQueries.splice(0);
|
|
5089
|
+
return thread.pendingQueries.splice(0).map(item => ({ ...item, persisted: true }));
|
|
5083
5090
|
},
|
|
5084
5091
|
...queryOpts,
|
|
5085
5092
|
})) {
|