@yeaft/webchat-agent 1.0.213 → 1.0.215
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
- package/yeaft/work-center/action-identity.js +25 -0
- package/yeaft/work-center/mainline-projection.js +6 -11
- package/yeaft/work-center/projection.js +36 -8
- package/yeaft/work-center/service.js +5 -2
- package/yeaft/work-center/store.js +106 -11
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;
|