@yeaft/webchat-agent 1.0.640 → 1.0.642

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/yeaft/engine.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * Reference: yeaft-yeaft-implementation-plan.md §3.1, §4 (Phase 2)
18
18
  */
19
19
 
20
- import { randomUUID, createHash } from 'crypto';
20
+ import { randomUUID } from 'crypto';
21
21
  import { QueryProgress, progressReminder } from './query-progress.js';
22
22
  import { createWebResearchState } from './web-research.js';
23
23
  import { promises as fsp } from 'fs';
@@ -55,12 +55,6 @@ import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
55
55
  // pass a real threadId per (sessionId, vpId, threadId) engine instance.
56
56
  const MAIN_THREAD_ID = 'main';
57
57
 
58
- function foldingMessageKey(message) {
59
- return message?._persistedMessageId || message?.id || createHash('sha256').update(JSON.stringify([
60
- message?.role, message?.toolCallId, message?.toolCalls, message?.content,
61
- ])).digest('hex');
62
- }
63
-
64
58
  import { pickEffort, parseEffortPrefix, snapshotEffortDecision } from './effort.js';
65
59
  import { bindProviderState } from './llm/provider-state.js';
66
60
  import {
@@ -71,8 +65,7 @@ import {
71
65
  removePostCompactIfSource,
72
66
  savePostCompact,
73
67
  } from './post-compact.js';
74
- import { DEFAULT_CONTEXT_WINDOW, getModelInfo, normalizeEffort, parseModelRef, resolveContextWindow, resolveMaxOutputTokens, resolveModel } from './models.js';
75
- import { lookupModelLimitSync } from './llm/models-dev.js';
68
+ import { getModelInfo, normalizeEffort, parseModelRef, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
76
69
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
77
70
  import { resolveThinking } from './router/thinking.js';
78
71
  import { approxTokens, computeBudget } from './memory/budget.js';
@@ -84,18 +77,11 @@ import { createPluginSkillManager } from './plugins.js';
84
77
  import { extractDisplayImages, stripDisplayImageData } from './image-assets.js';
85
78
  import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
86
79
  import {
87
- TOOL_LOOP_REFLECTION_INTERVAL,
88
- TURN_SUMMARY_THRESHOLD,
89
80
  DUP_TOOL_THRESHOLD,
90
81
  ExecLog,
91
82
  buildEntry as buildExecLogEntry,
92
83
  argsHashOf,
93
- runT1Reflection,
94
- runT2Reflection,
95
- buildFallbackStub,
96
- collapseRangeToReflection,
97
84
  buildDuplicateReminder,
98
- extractToolPairsFromRange,
99
85
  } from './tool-folding/index.js';
100
86
 
101
87
  /**
@@ -465,47 +451,6 @@ export function estimateProviderInputBreakdown({
465
451
  };
466
452
  }
467
453
 
468
- export const GROUP_CONTEXT_PRESSURE_RATIO = 0.8;
469
-
470
- export function shouldAllowGroupReflection({
471
- system = '',
472
- messages = [],
473
- model = null,
474
- config = {},
475
- sessionId = null,
476
- } = {}) {
477
- if (!sessionId) {
478
- return {
479
- allowed: true,
480
- tokenEstimate: estimateMessagesTokens(system, messages),
481
- threshold: 0,
482
- contextWindow: null,
483
- ratio: GROUP_CONTEXT_PRESSURE_RATIO,
484
- usedFallbackContextWindow: false,
485
- };
486
- }
487
- const contextWindow = resolveContextWindow(model, config);
488
- // Telemetry: did the resolver hit either of its top non-default rungs?
489
- // Used by `usedFallbackContextWindow` below — if neither models.dev nor
490
- // the global config provided a number, we fell through to DEFAULT and
491
- // callers may want to surface that to the user.
492
- const hasModelsDevContext = !!lookupModelLimitSync(model, resolveModel(model)?.provider || null)?.context;
493
- const hasConfigContext = Number.isFinite(config?.maxContextTokens) && config.maxContextTokens > 0;
494
- const threshold = Math.floor(contextWindow * GROUP_CONTEXT_PRESSURE_RATIO);
495
- const tokenEstimate = estimateMessagesTokens(system, messages);
496
- const overThreshold = tokenEstimate >= threshold;
497
- return {
498
- // Group send defaults to no reflection. Trust the model until context
499
- // pressure says we are near the model window.
500
- allowed: overThreshold,
501
- tokenEstimate,
502
- threshold,
503
- contextWindow,
504
- ratio: GROUP_CONTEXT_PRESSURE_RATIO,
505
- usedFallbackContextWindow: !hasModelsDevContext && !hasConfigContext && contextWindow === DEFAULT_CONTEXT_WINDOW,
506
- };
507
- }
508
-
509
454
  // ─── Engine Events (superset of adapter events) ──────────────────
510
455
 
511
456
  /**
@@ -769,24 +714,8 @@ export class Engine {
769
714
  */
770
715
  #currentAbortCtrl = null;
771
716
 
772
- /**
773
- * PR-L — V7 Tool History Reflection state. Owned per Engine instance.
774
- *
775
- * • `#execLog` — append-only log of every tool execution, used for
776
- * fallback-stub generation and duplicate-call detection. Persists
777
- * to <yeaftDir>/tool-log/<traceId>/<turnIdx>.jsonl when yeaftDir
778
- * is set; in-memory only otherwise.
779
- * • `#pendingT2` — Map<turnNumber, { promise, loopRange, count, ... }>
780
- * keyed by the turn number that triggered T2. The next query() call
781
- * non-blocking-checks this map; if the promise has resolved, the
782
- * prior turn's history is rewritten with the reflection. If still
783
- * pending, the engine falls back to the exec-log stub.
784
- * • `#reflectedTurns` — Set<turnNumber>; ensures T1 fires at most
785
- * once per reflection interval measured in tool loops.
786
- */
717
+ /** Append-only tool execution diagnostics and query-local duplicate lookup. */
787
718
  #execLog = null;
788
- #pendingT2 = new Map();
789
- #reflectedTurns = new Set();
790
719
  #__queryCounter = 0;
791
720
 
792
721
  /** Derived post-response summaries, keyed by Session/VP/thread scope. */
@@ -1446,6 +1375,7 @@ export class Engine {
1446
1375
  effortDecision: snapshotEffortDecision(vpCtx?.effortDecision),
1447
1376
  requestIdentity: vpCtx?.requestIdentity,
1448
1377
  queryTurnId: vpCtx?.queryTurnId,
1378
+ causalRootId: vpCtx?.causalRootId || null,
1449
1379
  webResearch: vpCtx?.webResearch,
1450
1380
  historyBeforeSeq: vpCtx?.historyBeforeSeq,
1451
1381
  yeaftDir: this.#yeaftDir,
@@ -1683,17 +1613,6 @@ export class Engine {
1683
1613
  return this.#conversationStore.append(this.#conversationRecord(message, context), { strict: context.strict === true });
1684
1614
  }
1685
1615
 
1686
- #persistFoldedRange(foldedMessages, reflection, context = {}) {
1687
- if (!this.#canPersistConversation() || typeof this.#conversationStore?.foldMessages !== 'function') return null;
1688
- const persistedRows = (foldedMessages || [])
1689
- .map(message => message?._persistedMessageId || message?.id)
1690
- .filter(id => typeof id === 'string' && id)
1691
- .map(id => ({ id }));
1692
- if (persistedRows.length === 0) return null;
1693
- const record = this.#conversationRecord(reflection, context);
1694
- return this.#conversationStore.foldMessages(persistedRows, record);
1695
- }
1696
-
1697
1616
  #formatTaskResultUpdateContent(content) {
1698
1617
  if (typeof content === 'string') return content;
1699
1618
  try { return JSON.stringify(content); } catch { return String(content); }
@@ -1713,10 +1632,9 @@ export class Engine {
1713
1632
  msg && msg.role === 'tool' && msg.toolCallId === update.toolCallId
1714
1633
  ));
1715
1634
  if (!toolMsg) {
1716
- // T1/T2 may have folded the original tool row before this task
1717
- // completed. The reflection is the canonical history, so a late
1718
- // completion must not recreate the hidden tool arc on disk or in the
1719
- // provider transcript. This is engine control context, not a fresh
1635
+ // Legacy histories may have folded the original tool row before this
1636
+ // task completed. A late completion must not recreate the hidden tool arc
1637
+ // on disk or in the provider transcript. This is engine control context, not a fresh
1720
1638
  // user-authored message; ConversationStore keeps it off the visible
1721
1639
  // transcript while retaining it for the next provider boundary.
1722
1640
  const contextContent = truncateToolResultIfNeeded(appendText, {
@@ -1734,6 +1652,7 @@ export class Engine {
1734
1652
  const persistedContinuation = this.#persistConversationMessage(continuation, {
1735
1653
  sessionId: update.sessionId || this.#sessionId,
1736
1654
  turnId: update.turnId || null,
1655
+ causalRootId: update.causalRootId || null,
1737
1656
  });
1738
1657
  if (persistedContinuation?.id) continuation._persistedMessageId = persistedContinuation.id;
1739
1658
  conversationMessages.push(continuation);
@@ -2535,64 +2454,10 @@ export class Engine {
2535
2454
  },
2536
2455
  ];
2537
2456
 
2538
- const groupReflectionGate = shouldAllowGroupReflection({
2539
- system: systemPrompt,
2540
- messages: conversationMessages,
2541
- model: this.#config.model,
2542
- config: this.#config,
2543
- sessionId,
2544
- });
2545
- const groupReflectionAllowed = groupReflectionGate.allowed === true;
2546
- if (sessionId && groupReflectionGate?.usedFallbackContextWindow) {
2547
- this.#trace.log?.('group_context_window_fallback', {
2548
- sessionId,
2549
- model: this.#config.model,
2550
- contextWindow: groupReflectionGate.contextWindow,
2551
- threshold: groupReflectionGate.threshold,
2552
- });
2553
- }
2554
-
2555
- // PR-L: T2 carry-forward. If a previous query()'s end-of-turn
2556
- // reflection has resolved, rewrite that turn's range in
2557
- // `conversationMessages` to a single assistant reflection message.
2558
- // If still pending, fall back to the exec-log stub — non-blocking,
2559
- // never wait. This runs BEFORE the first adapter.stream so the
2560
- // upcoming call sees the rewritten history. Group send defaults to no
2561
- // reflection; only high context pressure (>=80% of model window)
2562
- // enables the carry-forward rewrite.
2563
- if (groupReflectionAllowed) {
2564
- yield* this.#applyPendingT2Reflections(conversationMessages, prompt, {
2565
- sessionId: runtimeSessionId,
2566
- model: this.#config.model,
2567
- });
2568
- }
2569
-
2570
- // PR-L: track this query()'s tool-arc for reflection.
2571
- // `turnStartIdx` is where the current user message lives; the arc
2572
- // we may collapse spans (arcStartIdx .. last assistant/tool).
2573
- //
2574
- // Periodic T1 fires every 30 tool loops, not every 30 calls. A single
2575
- // provider batch can contain many parallel calls but is still one loop.
2576
- // track:
2577
- // • `lastT1AtLoopCount` — tool-loop snapshot at the last T1
2578
- // ATTEMPT (success OR error). Trigger when
2579
- // `completedToolLoops - lastT1AtLoopCount >= interval`.
2580
- // • `arcStartIdx` — first index of the current (uncollapsed)
2581
- // tool arc. Initialised to turnStartIdx + 1; reset after each
2582
- // successful T1 collapse to `conversationMessages.length`
2583
- // (i.e. the slot the next assistant message will land in).
2584
- // • `t1CollapsesDone` — count of T1 firings that ACTUALLY
2585
- // rewrote history. Distinct from `lastT1AtLoopCount` because
2586
- // the catch block bumps the latter to back off after a
2587
- // transient reflector error WITHOUT having collapsed
2588
- // anything. The T2 schedule check below is gated on this
2589
- // counter (==0 means "no T1 ever rewrote the arc, T2 may
2590
- // fall back at end_turn").
2457
+ // Current-turn protocol stays on the live tape. Only the disposable
2458
+ // provider copy is compacted when its actual model-window budget is full.
2591
2459
  const turnStartIdx = conversationMessages.length - 1;
2592
2460
  let queryToolCount = 0;
2593
- let lastT1AtLoopCount = 0;
2594
- let arcStartIdx = turnStartIdx + 1;
2595
- let t1CollapsesDone = 0;
2596
2461
  // Duplicate policy is scoped to one user query. Only successful, real
2597
2462
  // executions increment these counters; errors and cache reuse do not.
2598
2463
  const queryDuplicateCounts = new Map();
@@ -3063,7 +2928,7 @@ export class Engine {
3063
2928
  ));
3064
2929
  const toolSchemaTokens = toolDefs.length > 0
3065
2930
  ? approxTokens(JSON.stringify(toolDefs)) : 0;
3066
- const fittedRequest = fitProviderRequestToContext(wireMessages, {
2931
+ const fitOptions = {
3067
2932
  contextWindow: Math.max(1, Math.floor(currentContextWindow * providerContextScale)),
3068
2933
  systemTokens: estimateMessagesTokens(systemPrompt, []),
3069
2934
  toolSchemaTokens,
@@ -3071,9 +2936,50 @@ export class Engine {
3071
2936
  historyMessageCount,
3072
2937
  historyTokenBudget: historyBudget,
3073
2938
  language: requestConfig.language,
2939
+ };
2940
+ let fittedRequest = fitProviderRequestToContext(wireMessages, fitOptions);
2941
+ // A task can complete on an old tool row. Sampling or evicting that row
2942
+ // must not acknowledge unseen evidence. Re-inject only missing pending
2943
+ // deliveries as fresh provider-only notes, then check the final copy.
2944
+ const taskDeliveries = Array.from(this.#pendingAsyncTaskConfirmIds).flatMap(taskId => {
2945
+ const delivery = this.#acceptedAsyncTaskResults.get(taskId);
2946
+ if (!delivery) return [];
2947
+ const text = truncateToolResultIfNeeded(this.#formatTaskResultUpdateContent(delivery.content), {
2948
+ toolName: 'async task result', language: requestConfig.language,
2949
+ });
2950
+ return [{ taskId, text }];
3074
2951
  });
2952
+ const includesDelivery = (messages, text) => messages.some(message => (
2953
+ this.#formatTaskResultUpdateContent(message.content).includes(text)
2954
+ ));
2955
+ const missingDeliveries = taskDeliveries.filter(({ text }) => !includesDelivery(fittedRequest.messages, text));
2956
+ if (missingDeliveries.length > 0) {
2957
+ fittedRequest = fitProviderRequestToContext([...wireMessages, ...missingDeliveries.map(({ text }) => ({
2958
+ role: 'user', content: `[system note] Async task completion:\n${text}`,
2959
+ }))], fitOptions);
2960
+ }
3075
2961
  wireMessages = fittedRequest.messages;
2962
+ const deliveredTaskIds = taskDeliveries.filter(({ text }) => includesDelivery(wireMessages, text))
2963
+ .map(({ taskId }) => taskId);
3076
2964
  historyMessageCount = fittedRequest.meta.historyMessagesAfter;
2965
+ if (fittedRequest.meta.compactedToolResults > 0) {
2966
+ // Compaction invalidates file-read hints, not execution/reuse safety
2967
+ // ledgers. The complete result remains in transcript/debug storage.
2968
+ fileReadObservations.clear();
2969
+ recordAgentPerfTrace(requestConfig, {
2970
+ traceId: perfTraceId || queryTurnId,
2971
+ phase: 'tool_result_compact',
2972
+ sessionId: runtimeSessionId,
2973
+ vpId: queryVpId,
2974
+ turnId: queryTurnId,
2975
+ threadId: runtimeThreadId,
2976
+ detail: {
2977
+ model: currentModel,
2978
+ recoveryAttempt: contextOverflowRecoveryAttempts,
2979
+ ...fittedRequest.meta,
2980
+ },
2981
+ });
2982
+ }
3077
2983
  if (fittedRequest.meta.droppedHistoryMessages > 0
3078
2984
  || fittedRequest.meta.droppedCurrentMessages > 0
3079
2985
  || providerContextScale < 1) {
@@ -3086,49 +2992,6 @@ export class Engine {
3086
2992
  });
3087
2993
  }
3088
2994
 
3089
- // task-704b: pre-flight total-token guard. Even with the per-tool
3090
- // cap (registry.js: 10% of contextWindow per result), N tool
3091
- // results plus history can still breach the wire limit before we
3092
- // ever call adapter.stream(). Estimate the total token cost; if
3093
- // it exceeds PREFLIGHT_RATIO of the live context window, run an
3094
- // emergency archive sweep with `turnAgeMin: 0` so even
3095
- // current-turn-but-not-this-call bulky results get stubbed. The
3096
- // normal sweep above only stubs results older than 5 user turns
3097
- // — that's the wrong cadence when the *current* turn already has
3098
- // 4 large grep results.
3099
- //
3100
- // PREFLIGHT_RATIO = 0.85 leaves ~15% of the window for the model's
3101
- // own output tokens + tools metadata + light future history.
3102
- // The estimator (`estimateMessagesTokens`) is approxTokens
3103
- // (char/4 with CJK weighting) — good enough for a guard rail; a
3104
- // real tokenizer would be exact but adds a heavy dep.
3105
- if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
3106
- const PREFLIGHT_RATIO = 0.85;
3107
- const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
3108
- const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
3109
- if (estimate > threshold) {
3110
- try {
3111
- const sweep = await archiveToolResults({
3112
- root: `${this.#yeaftDir}/memory`,
3113
- scopeDir: 'user',
3114
- messages: wireMessages,
3115
- turnAgeMin: 0,
3116
- lengthMin: this.#config?.archive?.lengthMin ?? 2000,
3117
- });
3118
- wireMessages = sweep.nextMessages;
3119
- if (sweep.archivedCount > 0) {
3120
- this.#trace.log?.('preflight_sweep', {
3121
- archivedCount: sweep.archivedCount,
3122
- archivedBytes: sweep.archivedBytes,
3123
- estimateBefore: estimate,
3124
- threshold,
3125
- contextWindow: currentContextWindow,
3126
- });
3127
- }
3128
- } catch { /* best-effort */ }
3129
- }
3130
- }
3131
-
3132
2995
  // Freeze diagnostics only after every provider-only trim/sweep has
3133
2996
  // completed, so debug describes the exact request copy rather than the
3134
2997
  // durable transcript or an earlier preflight candidate.
@@ -3224,7 +3087,7 @@ export class Engine {
3224
3087
  // is not delivery: fetch may remain pending and then be aborted before
3225
3088
  // the provider processes anything. Ack only after a normal stream end
3226
3089
  // that included the provider's terminal stop event.
3227
- const requestAsyncTaskIds = Array.from(this.#pendingAsyncTaskConfirmIds);
3090
+ const requestAsyncTaskIds = deliveredTaskIds;
3228
3091
  let sawProviderStop = false;
3229
3092
  for await (const event of providerStream) {
3230
3093
  // task-325a (abort-stop fix): per-event abort short-circuit.
@@ -4210,80 +4073,8 @@ export class Engine {
4210
4073
  yield { type: 'turn_end', turnNumber, stopReason, threadId, terminal: true };
4211
4074
 
4212
4075
  // Message durability is handled incrementally before this terminal
4213
- // branch. There is no hidden end-of-turn LLM maintenance step.
4214
-
4215
- // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
4216
- // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
4217
- // (8) AND no T1 has actually rewritten the arc yet. Kicks off the
4218
- // primary-model call without await; the next query()'s
4219
- // `#applyPendingT2Reflections` carries the result forward.
4220
- //
4221
- // Periodic-T1 fix: gate on `t1CollapsesDone === 0`, NOT
4222
- // `lastT1AtLoopCount === 0`. The catch block of T1 advances
4223
- // `lastT1AtLoopCount` after a reflector error to avoid
4224
- // tight-loop retries — but no collapse happened, so T2 should
4225
- // still be allowed to fall back at end_turn. Fowler-review
4226
- // critical finding.
4227
- if (groupReflectionAllowed && queryToolCount > TURN_SUMMARY_THRESHOLD && t1CollapsesDone === 0) {
4228
- const arcStart = turnStartIdx + 1;
4229
- const arcEnd = conversationMessages.length - 1;
4230
- if (arcEnd > arcStart) {
4231
- const { pairs, assistantText } = extractToolPairsFromRange(
4232
- conversationMessages, arcStart, arcEnd,
4233
- );
4234
- yield {
4235
- type: 'reflection',
4236
- turnId: queryTurnId,
4237
- loopNumber: turnNumber,
4238
- trigger: 't2',
4239
- status: 'pending',
4240
- loopRange: [arcStart, arcEnd],
4241
- toolCount: pairs.length,
4242
- };
4243
- const promise = runT2Reflection({
4244
- adapter: this.#adapter,
4245
- model: this.#config.model,
4246
- originalUserMsg: prompt,
4247
- toolPairs: pairs,
4248
- assistantText,
4249
- language: this.#config.language,
4250
- signal,
4251
- onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
4252
- sessionId: runtimeSessionId, turnId: queryTurnId,
4253
- trigger: 't2', model: this.#config.model, ...diagnostic,
4254
- }),
4255
- });
4256
- // Detach: never await. The promise outlives this query() and
4257
- // the next call will pick it up (or use the fallback stub if
4258
- // it hasn't resolved by then).
4259
- // PR-L follow-up: latch a synchronously-readable ready flag
4260
- // and result on the info record so `#applyPendingT2Reflections`
4261
- // can decide ready-vs-pending without racing microtasks.
4262
- const info = {
4263
- promise,
4264
- loopRange: [arcStart, arcEnd],
4265
- // Absolute indexes do not survive history-window reconstruction.
4266
- arcKeys: conversationMessages.slice(arcStart, arcEnd + 1).map(foldingMessageKey),
4267
- arcRows: conversationMessages.slice(arcStart, arcEnd + 1).map(message => ({
4268
- role: message.role, id: message.id, _persistedMessageId: message._persistedMessageId,
4269
- toolCallId: message.toolCallId, toolCalls: message.toolCalls?.map(call => ({ id: call.id })),
4270
- })),
4271
- count: pairs.length,
4272
- originalUserMsg: prompt,
4273
- originatingTurnId: queryTurnId,
4274
- causalRootId,
4275
- executionOrigin,
4276
- ready: false,
4277
- result: null,
4278
- error: null,
4279
- };
4280
- promise.then(
4281
- (v) => { info.ready = true; info.result = v; },
4282
- (err) => { info.ready = true; info.error = err; },
4283
- );
4284
- this.#pendingT2.set(queryNumber, info);
4285
- }
4286
- }
4076
+ // branch. No reflection or durable history fold runs here; the separate
4077
+ // post-response compact is scheduled only after turn_close delivery.
4287
4078
 
4288
4079
  break;
4289
4080
  }
@@ -4301,6 +4092,7 @@ export class Engine {
4301
4092
  effortDecision: snapshotEffortDecision(requestEffortDecision),
4302
4093
  requestIdentity,
4303
4094
  queryTurnId,
4095
+ causalRootId,
4304
4096
  webResearch,
4305
4097
  historyBeforeSeq: Number.isFinite(persistedQueryUser?.seq) ? persistedQueryUser.seq : parseSeqFromId(persistedQueryUser?.id),
4306
4098
  router,
@@ -4413,7 +4205,6 @@ export class Engine {
4413
4205
  // break out of the outer while-loop cleanly once the current
4414
4206
  // tool batch finishes reporting.
4415
4207
  let abortedDuringTools = false;
4416
- let batchHasUnseenImages = false;
4417
4208
  /** @type {string[]} */
4418
4209
  const pendingDupReminders = [];
4419
4210
  let terminateAfterDuplicateBatch = false;
@@ -4946,7 +4737,6 @@ export class Engine {
4946
4737
  toolName: tc.name,
4947
4738
  language: this.#config?.language,
4948
4739
  });
4949
- if (modelImages.length > 0) batchHasUnseenImages = true;
4950
4740
  const toolMessage = {
4951
4741
  role: 'tool',
4952
4742
  toolCallId: tc.id,
@@ -5088,10 +4878,8 @@ export class Engine {
5088
4878
  // clean wire shape. Used by `route_forward` to hand off control
5089
4879
  // to other VPs without continuing to generate.
5090
4880
  //
5091
- // Order matters: this runs BEFORE T1 reflection (which would
5092
- // collapse the arc into a summary that's only valuable across
5093
- // multi-iteration tool loops) and BEFORE the abortedDuringTools
5094
- // check (so a clean handoff doesn't get reported as 'aborted').
4881
+ // A clean handoff must precede the abortedDuringTools check so it
4882
+ // does not get reported as 'aborted'.
5095
4883
  if (endTurnRequested && !toolBatchBarrier) {
5096
4884
  if (pendingSubAgentNotifs.length > 0) {
5097
4885
  acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
@@ -5110,180 +4898,6 @@ export class Engine {
5110
4898
  break;
5111
4899
  }
5112
4900
 
5113
- // PR-L: T1 in-turn (synchronous) reflection. Fires every 30 completed
5114
- // tool loops. A provider response containing many parallel tool calls is
5115
- // one loop, not many. Generates a markdown reflection
5116
- // over the assistant+tool arc since the last T1 firing (or
5117
- // since the user prompt for the first batch) and rewrites that
5118
- // range to a SINGLE synthetic user message before the next
5119
- // adapter.stream() runs.
5120
- //
5121
- // Loop semantics:
5122
- // - First interval: arcStartIdx = turnStartIdx + 1.
5123
- // - Each subsequent interval: arcStartIdx is updated to the slot
5124
- // right after the just-inserted reflection message; fires
5125
- // again whenever 30 more tool loops have completed.
5126
- // - The dedup Set key includes the loop count so each interval
5127
- // within the same query gets a distinct entry — without
5128
- // this the second batch would be silently skipped.
5129
- const completedToolLoops = toolLoopTurns + 1;
5130
- const t1BatchDue = completedToolLoops - lastT1AtLoopCount
5131
- >= TOOL_LOOP_REFLECTION_INTERVAL;
5132
- // Text-only reflection cannot consume visual input. Defer folding until
5133
- // the primary provider has received this image batch at least once.
5134
- if (t1BatchDue && !batchHasUnseenImages && !toolBatchBarrier
5135
- && !abortedDuringTools && !signal?.aborted) {
5136
- const t1DedupKey = `${queryNumber}:t1-loop:${completedToolLoops}`;
5137
- if (this.#reflectedTurns.has(t1DedupKey)) {
5138
- // Defensive: should never hit since t1BatchDue gates re-entry
5139
- // and queryNumber namespaces queries. Kept as belt-and-
5140
- // suspenders against any future external mutation of the
5141
- // cursor (or a re-entrant query() that this code doesn't
5142
- // anticipate).
5143
- } else {
5144
- this.#reflectedTurns.add(t1DedupKey);
5145
- const batchStart = arcStartIdx;
5146
- const batchEnd = conversationMessages.length - 1;
5147
- try {
5148
- const { pairs, assistantText } = extractToolPairsFromRange(
5149
- conversationMessages, batchStart, batchEnd,
5150
- );
5151
- yield {
5152
- type: 'reflection',
5153
- turnId: queryTurnId,
5154
- threadId,
5155
- loopNumber: turnNumber,
5156
- trigger: 't1',
5157
- status: 'pending',
5158
- loopRange: [batchStart, batchEnd],
5159
- toolCount: pairs.length,
5160
- };
5161
- // Observe only this synchronous query-owned call. Do not tap the
5162
- // shared accounting adapter (also used by children), or feed these
5163
- // costs into the main loop's token/dispatch budget. Capture raw usage
5164
- // before reflection diagnostics normalize away cache-inclusion flags.
5165
- const reflectionAdapter = typeof this.#adapter.call === 'function' ? {
5166
- call: async params => {
5167
- let dispatches = 0;
5168
- let result;
5169
- let failure;
5170
- try {
5171
- result = await this.#adapter.call({
5172
- ...params,
5173
- onRequestStart: (...args) => {
5174
- dispatches++;
5175
- queryProgress.requestStarted();
5176
- params.onRequestStart?.(...args);
5177
- },
5178
- });
5179
- return result;
5180
- } catch (error) {
5181
- failure = error;
5182
- throw error;
5183
- } finally {
5184
- // Native adapters report every dispatch, including a refreshed
5185
- // credential retry. Legacy adapters without the hook count once.
5186
- if (dispatches === 0) queryProgress.requestStarted();
5187
- const usage = result?.usage || failure?.usage;
5188
- if (usage) queryProgress.usageObserved(usage);
5189
- }
5190
- },
5191
- } : this.#adapter;
5192
- const { content, durationMs } = await runT1Reflection({
5193
- adapter: reflectionAdapter,
5194
- model: this.#config.model,
5195
- originalUserMsg: prompt,
5196
- toolPairs: pairs,
5197
- assistantText,
5198
- language: this.#config.language,
5199
- signal,
5200
- onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
5201
- sessionId: runtimeSessionId, turnId: queryTurnId,
5202
- trigger: 't1', model: this.#config.model, ...diagnostic,
5203
- }),
5204
- });
5205
- const { messages: next, reflection: reflectionMessage, foldedMessages } = collapseRangeToReflection(
5206
- conversationMessages, batchStart, batchEnd, content,
5207
- );
5208
- const durableRowsInRange = foldedMessages
5209
- .some(message => message?._persistedMessageId || message?.id);
5210
- const persistedReflection = this.#persistFoldedRange(
5211
- foldedMessages,
5212
- reflectionMessage,
5213
- { sessionId: runtimeSessionId, model: currentModel, executionOrigin },
5214
- );
5215
- if (durableRowsInRange && !persistedReflection) {
5216
- throw new Error('T1 reflection could not publish its durable range replacement');
5217
- }
5218
- if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
5219
- // Raw tool rows inside the replacement range are now tombstoned in
5220
- // durable history. Late async completions must follow the folded
5221
- // continuation path rather than appending to those stale rows.
5222
- const foldedToolCallIds = conversationMessages
5223
- .slice(batchStart, batchEnd + 1)
5224
- .filter(message => message?.role === 'tool' && message.toolCallId)
5225
- .map(message => message.toolCallId);
5226
- for (const toolCallId of foldedToolCallIds) {
5227
- this.#persistedToolMessages.delete(toolCallId);
5228
- }
5229
- conversationMessages.length = 0;
5230
- for (const m of next) conversationMessages.push(m);
5231
- // The preserved users precede the reflection. The next arc starts
5232
- // immediately after it, i.e. at conversationMessages.length
5233
- // (the next assistant message will land here).
5234
- arcStartIdx = conversationMessages.length;
5235
- // Read hints must not imply raw ranges remain in model context.
5236
- fileReadObservations.clear();
5237
- lastT1AtLoopCount = completedToolLoops;
5238
- // Bump the success counter — used by the T2 schedule check
5239
- // to decide whether T2 still has work to do at end_turn.
5240
- // Distinct from lastT1AtLoopCount which the catch block
5241
- // also bumps (but without rewriting history).
5242
- t1CollapsesDone += 1;
5243
- yield {
5244
- type: 'reflection',
5245
- turnId: queryTurnId,
5246
- threadId,
5247
- loopNumber: turnNumber,
5248
- trigger: 't1',
5249
- // PR-L bug fix: keep the same loopRange as the `pending` event
5250
- // so the frontend key stays stable across pending → ready and
5251
- // the spinner card is replaced in place (no orphan).
5252
- status: 'ready',
5253
- loopRange: [batchStart, batchEnd],
5254
- toolCount: pairs.length,
5255
- content,
5256
- durationMs,
5257
- };
5258
- } catch (err) {
5259
- // Best-effort. On failure leave history unchanged so the loop
5260
- // continues normally — never block the turn.
5261
- yield {
5262
- type: 'reflection',
5263
- turnId: queryTurnId,
5264
- threadId,
5265
- loopNumber: turnNumber,
5266
- trigger: 't1',
5267
- status: 'error',
5268
- error: err && err.message || String(err),
5269
- };
5270
- // Advance lastT1AtLoopCount past this interval so we don't
5271
- // tight-loop on a hiccuping reflector. The next attempt is
5272
- // another 30 tool loops from now, not immediately. arcStartIdx is
5273
- // left alone because history wasn't rewritten — the tail still
5274
- // begins where it did. The trade-off: the next batch's
5275
- // reflection will cover the tools that just failed too,
5276
- // which is fine (they're still in conversationMessages).
5277
- //
5278
- // We do NOT bump t1CollapsesDone — see the variable's
5279
- // declaration comment. This keeps the T2 fallback path live
5280
- // when every T1 attempt has errored.
5281
- lastT1AtLoopCount = completedToolLoops;
5282
- }
5283
- yield queryProgress.snapshot(progressIdentity);
5284
- }
5285
- }
5286
-
5287
4901
  // task-325a: if abort fired between tools, converge now — emit
5288
4902
  // the typed `aborted` event + a final turn_end with stopReason
5289
4903
  // 'aborted' instead of looping back to a new adapter call.
@@ -5455,109 +5069,6 @@ export class Engine {
5455
5069
  /** @returns {import('./mcp.js').MCPManager|null} */
5456
5070
  get mcpManager() { return this.#mcpManager; }
5457
5071
 
5458
- /**
5459
- * PR-L — V7 Tool History Reflection helpers.
5460
- *
5461
- * `#applyPendingT2Reflections` is called at the start of every
5462
- * `#runQuery` to carry forward any prior turn's async reflection. It is
5463
- * a generator so it can yield reflection events to the engine consumer.
5464
- * Non-blocking: never awaits a pending promise.
5465
- *
5466
- * @param {Array} conversationMessages
5467
- * @param {string} originalUserMsg
5468
- * @param {{sessionId?: string, model?: string}} context
5469
- */
5470
- async *#applyPendingT2Reflections(conversationMessages, originalUserMsg, context = {}) {
5471
- if (this.#pendingT2.size === 0) return;
5472
- // Drain in insertion order (Map preserves it). We process all entries
5473
- // because the user could send multiple prompts back-to-back before
5474
- // the engine resumes — each historical turn gets its rewrite.
5475
- const drained = [...this.#pendingT2.entries()];
5476
- this.#pendingT2.clear();
5477
-
5478
- for (const [turnNumber, info] of drained) {
5479
- const keys = info.arcKeys;
5480
- if (!Array.isArray(keys) || keys.length === 0) continue;
5481
- const currentKeys = conversationMessages.map(foldingMessageKey);
5482
- const startIdx = currentKeys.findIndex((key, index) => key === keys[0]
5483
- && keys.every((expected, offset) => currentKeys[index + offset] === expected));
5484
- const endIdx = startIdx + keys.length - 1;
5485
- const rangePresent = startIdx >= 0;
5486
-
5487
- // PR-L follow-up: deterministic readiness check. The info record
5488
- // carries `ready / result / error` flags that are flipped from the
5489
- // promise's then/catch handler; reading them here is purely
5490
- // synchronous bookkeeping — no microtask race.
5491
- let content;
5492
- let trigger;
5493
- let durationMs = 0;
5494
- if (!info.ready) {
5495
- // Still in flight — fall back to the exec-log stub. Detach the
5496
- // unresolved promise so we don't leak it (handlers above already
5497
- // swallow rejection by routing into info.error).
5498
- const entries = this.#execLog ? this.#execLog.readTurn(turnNumber) : [];
5499
- content = buildFallbackStub({ execLogEntries: entries, originalUserMsg: info.originalUserMsg || originalUserMsg });
5500
- trigger = 't2-fallback';
5501
- } else if (info.error) {
5502
- // Promise rejected — leave history unchanged, no event.
5503
- continue;
5504
- } else if (info.result && typeof info.result.content === 'string' && info.result.content) {
5505
- content = info.result.content;
5506
- trigger = 't2';
5507
- durationMs = info.result.durationMs || 0;
5508
- } else {
5509
- // Resolved but with no usable content — defensively skip.
5510
- continue;
5511
- }
5512
-
5513
- // Rewrite history and publish the same logical replacement to disk.
5514
- const { messages: next, reflection: reflectionMessage, foldedMessages } = rangePresent
5515
- ? collapseRangeToReflection(conversationMessages, startIdx, endIdx, content)
5516
- : collapseRangeToReflection(info.arcRows, 0, info.arcRows.length - 1, content);
5517
- const durableRowsInRange = foldedMessages
5518
- .some(message => message?._persistedMessageId || message?.id);
5519
- const persistedReflection = this.#persistFoldedRange(
5520
- foldedMessages,
5521
- reflectionMessage,
5522
- {
5523
- ...context,
5524
- causalRootId: info.causalRootId || null,
5525
- executionOrigin: info.executionOrigin === 'route_forward' ? 'route_forward' : null,
5526
- },
5527
- );
5528
- if (durableRowsInRange && !persistedReflection) continue;
5529
- if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
5530
- // T2 replaces this arc in durable and in-memory history just like T1.
5531
- // Forget its raw result handles before a late async task can append to a
5532
- // tombstoned tool row at the next provider boundary.
5533
- const foldedToolCallIds = foldedMessages
5534
- .filter(message => message?.role === 'tool' && message.toolCallId)
5535
- .map(message => message.toolCallId);
5536
- for (const toolCallId of foldedToolCallIds) {
5537
- this.#persistedToolMessages.delete(toolCallId);
5538
- }
5539
- // History may have evicted the old arc. Publish only its original
5540
- // durable row IDs; never replace unrelated rows at stale indexes.
5541
- if (rangePresent) {
5542
- conversationMessages.length = 0;
5543
- for (const m of next) conversationMessages.push(m);
5544
- }
5545
-
5546
- yield {
5547
- type: 'reflection',
5548
- turnId: info.originatingTurnId || null,
5549
- trigger,
5550
- status: 'ready',
5551
- // Wire identity must match the original pending card, even if the
5552
- // history window shifted or evicted its internal replacement range.
5553
- loopRange: info.loopRange,
5554
- toolCount: info.count || 0,
5555
- content,
5556
- durationMs,
5557
- };
5558
- }
5559
- }
5560
-
5561
5072
  /**
5562
5073
  * PR-L — read-only accessor for tests.
5563
5074
  */
@@ -5687,7 +5198,7 @@ export class Engine {
5687
5198
  *
5688
5199
  * @param {string} taskId
5689
5200
  * @param {string|Array} content — pre-formatted task result body
5690
- * @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string }} [opts]
5201
+ * @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string, causalRootId?: string }} [opts]
5691
5202
  * @returns {boolean}
5692
5203
  */
5693
5204
  notifyAsyncTaskCompleted(taskId, content, opts = {}) {
@@ -5712,10 +5223,9 @@ export class Engine {
5712
5223
  ? opts.preview
5713
5224
  : (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
5714
5225
  const meta = this.#asyncTaskToolMeta.get(taskId) || {};
5715
- // A completion that lands while its original tool arc is still present
5716
- // can update that tool result in-place. Once T1/T2 folded the arc, the
5717
- // provider must receive only a continuation note; otherwise we recreate
5718
- // a raw tool row after the reflection and invalidate the fold.
5226
+ // A completion updates its original tool row when available. Legacy folded
5227
+ // histories instead receive a continuation note; provider-only compaction
5228
+ // never clears this durable handle or changes result ownership.
5719
5229
  const hasLiveToolRow = typeof meta.toolCallId === 'string'
5720
5230
  && meta.toolCallId
5721
5231
  && Boolean(this.#persistedToolMessages.get(meta.toolCallId));
@@ -5728,6 +5238,11 @@ export class Engine {
5728
5238
  taskKind: opts.taskKind,
5729
5239
  taskStatus: opts.taskStatus,
5730
5240
  turnId: opts.turnId || meta.turnId || null,
5241
+ // Registration owns provenance even when newer human input arrives before
5242
+ // completion. Retain it until provider acceptance or undelivered rescue.
5243
+ causalRootId: meta.causalRootId
5244
+ || (typeof opts.causalRootId === 'string' ? opts.causalRootId.trim() : null)
5245
+ || this.#currentCausalRootId || null,
5731
5246
  };
5732
5247
  this.#acceptedAsyncTaskResults.set(taskId, delivery);
5733
5248
  this.#asyncTaskToolMeta.delete(taskId);
@@ -5741,9 +5256,9 @@ export class Engine {
5741
5256
  sessionId: delivery.sessionId,
5742
5257
  vpId: delivery.vpId,
5743
5258
  turnId: delivery.turnId,
5744
- // #persistedToolMessages is cleared as soon as folding publishes its
5745
- // durable reflection. Capture that boundary at completion time so a
5746
- // later drain cannot infer stale liveness from an unrelated row.
5259
+ causalRootId: delivery.causalRootId,
5260
+ // Capture durable row ownership at completion time, independently of
5261
+ // any provider-only compacted request copy.
5747
5262
  folded: !hasLiveToolRow,
5748
5263
  });
5749
5264
  } else {
@@ -5762,7 +5277,7 @@ export class Engine {
5762
5277
  * Register a result-producing async task as belonging to the current query.
5763
5278
  * Called by tools such as SpawnAgent via `toolCtx.registerAsyncTask`.
5764
5279
  * @param {string} taskId
5765
- * @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string }} [meta]
5280
+ * @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string, causalRootId?: string }} [meta]
5766
5281
  * @returns {void}
5767
5282
  */
5768
5283
  #registerAsyncTask(taskId, meta = {}) {
@@ -5771,23 +5286,23 @@ export class Engine {
5771
5286
  const toolCallId = typeof meta.toolCallId === 'string' && meta.toolCallId
5772
5287
  ? meta.toolCallId
5773
5288
  : (typeof meta.id === 'string' && meta.id ? meta.id : null);
5774
- if (toolCallId) {
5775
- this.#asyncTaskToolMeta.set(taskId, {
5776
- toolCallId,
5777
- toolName: typeof meta.toolName === 'string' && meta.toolName ? meta.toolName : (typeof meta.name === 'string' ? meta.name : undefined),
5778
- threadId: typeof meta.threadId === 'string' && meta.threadId ? meta.threadId : undefined,
5779
- sessionId: typeof meta.sessionId === 'string' && meta.sessionId
5780
- ? meta.sessionId
5781
- : (this.#sessionId || null),
5782
- vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
5783
- // query() exposes the wire turn id while the task is registered.
5784
- // Persist it with a late completion even after T1/T2 removed the
5785
- // original tool row from the in-memory arc.
5786
- turnId: typeof meta.turnId === 'string' && meta.turnId
5787
- ? meta.turnId
5788
- : (this.#currentQueryTurnId || null),
5789
- });
5790
- }
5289
+ this.#asyncTaskToolMeta.set(taskId, {
5290
+ toolCallId,
5291
+ toolName: typeof meta.toolName === 'string' && meta.toolName ? meta.toolName : (typeof meta.name === 'string' ? meta.name : undefined),
5292
+ threadId: typeof meta.threadId === 'string' && meta.threadId ? meta.threadId : undefined,
5293
+ sessionId: typeof meta.sessionId === 'string' && meta.sessionId
5294
+ ? meta.sessionId
5295
+ : (this.#sessionId || null),
5296
+ vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
5297
+ // query() exposes the wire turn id while the task is registered.
5298
+ // Keep provenance even for legacy missing/folded tool rows.
5299
+ turnId: typeof meta.turnId === 'string' && meta.turnId
5300
+ ? meta.turnId
5301
+ : (this.#currentQueryTurnId || null),
5302
+ causalRootId: typeof meta.causalRootId === 'string' && meta.causalRootId.trim()
5303
+ ? meta.causalRootId.trim()
5304
+ : (this.#currentCausalRootId || null),
5305
+ });
5791
5306
  try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
5792
5307
  }
5793
5308