@yeaft/webchat-agent 1.0.639 → 1.0.641

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,8 +17,9 @@
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
+ import { createWebResearchState } from './web-research.js';
22
23
  import { promises as fsp } from 'fs';
23
24
  import { join, resolve as resolvePath } from 'path';
24
25
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
@@ -30,6 +31,7 @@ import {
30
31
  pickProjectDocFile,
31
32
  selectProjectDocContext,
32
33
  projectDocPathHintsFromToolCall,
34
+ projectDocToolCallNeedsWriteReview,
33
35
  projectDocWriteScopesNeedingReload,
34
36
  DEFAULT_PROJECT_DOC_MAX_BYTES,
35
37
  } from './sessions/project-doc.js';
@@ -53,12 +55,6 @@ import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
53
55
  // pass a real threadId per (sessionId, vpId, threadId) engine instance.
54
56
  const MAIN_THREAD_ID = 'main';
55
57
 
56
- function foldingMessageKey(message) {
57
- return message?._persistedMessageId || message?.id || createHash('sha256').update(JSON.stringify([
58
- message?.role, message?.toolCallId, message?.toolCalls, message?.content,
59
- ])).digest('hex');
60
- }
61
-
62
58
  import { pickEffort, parseEffortPrefix, snapshotEffortDecision } from './effort.js';
63
59
  import { bindProviderState } from './llm/provider-state.js';
64
60
  import {
@@ -69,8 +65,7 @@ import {
69
65
  removePostCompactIfSource,
70
66
  savePostCompact,
71
67
  } from './post-compact.js';
72
- import { DEFAULT_CONTEXT_WINDOW, getModelInfo, normalizeEffort, parseModelRef, resolveContextWindow, resolveMaxOutputTokens, resolveModel } from './models.js';
73
- import { lookupModelLimitSync } from './llm/models-dev.js';
68
+ import { getModelInfo, normalizeEffort, parseModelRef, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
74
69
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
75
70
  import { resolveThinking } from './router/thinking.js';
76
71
  import { approxTokens, computeBudget } from './memory/budget.js';
@@ -82,18 +77,11 @@ import { createPluginSkillManager } from './plugins.js';
82
77
  import { extractDisplayImages, stripDisplayImageData } from './image-assets.js';
83
78
  import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
84
79
  import {
85
- TOOL_LOOP_REFLECTION_INTERVAL,
86
- TURN_SUMMARY_THRESHOLD,
87
80
  DUP_TOOL_THRESHOLD,
88
81
  ExecLog,
89
82
  buildEntry as buildExecLogEntry,
90
83
  argsHashOf,
91
- runT1Reflection,
92
- runT2Reflection,
93
- buildFallbackStub,
94
- collapseRangeToReflection,
95
84
  buildDuplicateReminder,
96
- extractToolPairsFromRange,
97
85
  } from './tool-folding/index.js';
98
86
 
99
87
  /**
@@ -463,47 +451,6 @@ export function estimateProviderInputBreakdown({
463
451
  };
464
452
  }
465
453
 
466
- export const GROUP_CONTEXT_PRESSURE_RATIO = 0.8;
467
-
468
- export function shouldAllowGroupReflection({
469
- system = '',
470
- messages = [],
471
- model = null,
472
- config = {},
473
- sessionId = null,
474
- } = {}) {
475
- if (!sessionId) {
476
- return {
477
- allowed: true,
478
- tokenEstimate: estimateMessagesTokens(system, messages),
479
- threshold: 0,
480
- contextWindow: null,
481
- ratio: GROUP_CONTEXT_PRESSURE_RATIO,
482
- usedFallbackContextWindow: false,
483
- };
484
- }
485
- const contextWindow = resolveContextWindow(model, config);
486
- // Telemetry: did the resolver hit either of its top non-default rungs?
487
- // Used by `usedFallbackContextWindow` below — if neither models.dev nor
488
- // the global config provided a number, we fell through to DEFAULT and
489
- // callers may want to surface that to the user.
490
- const hasModelsDevContext = !!lookupModelLimitSync(model, resolveModel(model)?.provider || null)?.context;
491
- const hasConfigContext = Number.isFinite(config?.maxContextTokens) && config.maxContextTokens > 0;
492
- const threshold = Math.floor(contextWindow * GROUP_CONTEXT_PRESSURE_RATIO);
493
- const tokenEstimate = estimateMessagesTokens(system, messages);
494
- const overThreshold = tokenEstimate >= threshold;
495
- return {
496
- // Group send defaults to no reflection. Trust the model until context
497
- // pressure says we are near the model window.
498
- allowed: overThreshold,
499
- tokenEstimate,
500
- threshold,
501
- contextWindow,
502
- ratio: GROUP_CONTEXT_PRESSURE_RATIO,
503
- usedFallbackContextWindow: !hasModelsDevContext && !hasConfigContext && contextWindow === DEFAULT_CONTEXT_WINDOW,
504
- };
505
- }
506
-
507
454
  // ─── Engine Events (superset of adapter events) ──────────────────
508
455
 
509
456
  /**
@@ -767,24 +714,8 @@ export class Engine {
767
714
  */
768
715
  #currentAbortCtrl = null;
769
716
 
770
- /**
771
- * PR-L — V7 Tool History Reflection state. Owned per Engine instance.
772
- *
773
- * • `#execLog` — append-only log of every tool execution, used for
774
- * fallback-stub generation and duplicate-call detection. Persists
775
- * to <yeaftDir>/tool-log/<traceId>/<turnIdx>.jsonl when yeaftDir
776
- * is set; in-memory only otherwise.
777
- * • `#pendingT2` — Map<turnNumber, { promise, loopRange, count, ... }>
778
- * keyed by the turn number that triggered T2. The next query() call
779
- * non-blocking-checks this map; if the promise has resolved, the
780
- * prior turn's history is rewritten with the reflection. If still
781
- * pending, the engine falls back to the exec-log stub.
782
- * • `#reflectedTurns` — Set<turnNumber>; ensures T1 fires at most
783
- * once per reflection interval measured in tool loops.
784
- */
717
+ /** Append-only tool execution diagnostics and query-local duplicate lookup. */
785
718
  #execLog = null;
786
- #pendingT2 = new Map();
787
- #reflectedTurns = new Set();
788
719
  #__queryCounter = 0;
789
720
 
790
721
  /** Derived post-response summaries, keyed by Session/VP/thread scope. */
@@ -1444,6 +1375,7 @@ export class Engine {
1444
1375
  effortDecision: snapshotEffortDecision(vpCtx?.effortDecision),
1445
1376
  requestIdentity: vpCtx?.requestIdentity,
1446
1377
  queryTurnId: vpCtx?.queryTurnId,
1378
+ webResearch: vpCtx?.webResearch,
1447
1379
  historyBeforeSeq: vpCtx?.historyBeforeSeq,
1448
1380
  yeaftDir: this.#yeaftDir,
1449
1381
  managedCliReady: this.#managedCliReady,
@@ -1680,17 +1612,6 @@ export class Engine {
1680
1612
  return this.#conversationStore.append(this.#conversationRecord(message, context), { strict: context.strict === true });
1681
1613
  }
1682
1614
 
1683
- #persistFoldedRange(foldedMessages, reflection, context = {}) {
1684
- if (!this.#canPersistConversation() || typeof this.#conversationStore?.foldMessages !== 'function') return null;
1685
- const persistedRows = (foldedMessages || [])
1686
- .map(message => message?._persistedMessageId || message?.id)
1687
- .filter(id => typeof id === 'string' && id)
1688
- .map(id => ({ id }));
1689
- if (persistedRows.length === 0) return null;
1690
- const record = this.#conversationRecord(reflection, context);
1691
- return this.#conversationStore.foldMessages(persistedRows, record);
1692
- }
1693
-
1694
1615
  #formatTaskResultUpdateContent(content) {
1695
1616
  if (typeof content === 'string') return content;
1696
1617
  try { return JSON.stringify(content); } catch { return String(content); }
@@ -1710,10 +1631,9 @@ export class Engine {
1710
1631
  msg && msg.role === 'tool' && msg.toolCallId === update.toolCallId
1711
1632
  ));
1712
1633
  if (!toolMsg) {
1713
- // T1/T2 may have folded the original tool row before this task
1714
- // completed. The reflection is the canonical history, so a late
1715
- // completion must not recreate the hidden tool arc on disk or in the
1716
- // provider transcript. This is engine control context, not a fresh
1634
+ // Legacy histories may have folded the original tool row before this
1635
+ // task completed. A late completion must not recreate the hidden tool arc
1636
+ // on disk or in the provider transcript. This is engine control context, not a fresh
1717
1637
  // user-authored message; ConversationStore keeps it off the visible
1718
1638
  // transcript while retaining it for the next provider boundary.
1719
1639
  const contextContent = truncateToolResultIfNeeded(appendText, {
@@ -2532,70 +2452,19 @@ export class Engine {
2532
2452
  },
2533
2453
  ];
2534
2454
 
2535
- const groupReflectionGate = shouldAllowGroupReflection({
2536
- system: systemPrompt,
2537
- messages: conversationMessages,
2538
- model: this.#config.model,
2539
- config: this.#config,
2540
- sessionId,
2541
- });
2542
- const groupReflectionAllowed = groupReflectionGate.allowed === true;
2543
- if (sessionId && groupReflectionGate?.usedFallbackContextWindow) {
2544
- this.#trace.log?.('group_context_window_fallback', {
2545
- sessionId,
2546
- model: this.#config.model,
2547
- contextWindow: groupReflectionGate.contextWindow,
2548
- threshold: groupReflectionGate.threshold,
2549
- });
2550
- }
2551
-
2552
- // PR-L: T2 carry-forward. If a previous query()'s end-of-turn
2553
- // reflection has resolved, rewrite that turn's range in
2554
- // `conversationMessages` to a single assistant reflection message.
2555
- // If still pending, fall back to the exec-log stub — non-blocking,
2556
- // never wait. This runs BEFORE the first adapter.stream so the
2557
- // upcoming call sees the rewritten history. Group send defaults to no
2558
- // reflection; only high context pressure (>=80% of model window)
2559
- // enables the carry-forward rewrite.
2560
- if (groupReflectionAllowed) {
2561
- yield* this.#applyPendingT2Reflections(conversationMessages, prompt, {
2562
- sessionId: runtimeSessionId,
2563
- model: this.#config.model,
2564
- });
2565
- }
2566
-
2567
- // PR-L: track this query()'s tool-arc for reflection.
2568
- // `turnStartIdx` is where the current user message lives; the arc
2569
- // we may collapse spans (arcStartIdx .. last assistant/tool).
2570
- //
2571
- // Periodic T1 fires every 30 tool loops, not every 30 calls. A single
2572
- // provider batch can contain many parallel calls but is still one loop.
2573
- // track:
2574
- // • `lastT1AtLoopCount` — tool-loop snapshot at the last T1
2575
- // ATTEMPT (success OR error). Trigger when
2576
- // `completedToolLoops - lastT1AtLoopCount >= interval`.
2577
- // • `arcStartIdx` — first index of the current (uncollapsed)
2578
- // tool arc. Initialised to turnStartIdx + 1; reset after each
2579
- // successful T1 collapse to `conversationMessages.length`
2580
- // (i.e. the slot the next assistant message will land in).
2581
- // • `t1CollapsesDone` — count of T1 firings that ACTUALLY
2582
- // rewrote history. Distinct from `lastT1AtLoopCount` because
2583
- // the catch block bumps the latter to back off after a
2584
- // transient reflector error WITHOUT having collapsed
2585
- // anything. The T2 schedule check below is gated on this
2586
- // counter (==0 means "no T1 ever rewrote the arc, T2 may
2587
- // fall back at end_turn").
2455
+ // Current-turn protocol stays on the live tape. Only the disposable
2456
+ // provider copy is compacted when its actual model-window budget is full.
2588
2457
  const turnStartIdx = conversationMessages.length - 1;
2589
2458
  let queryToolCount = 0;
2590
- let lastT1AtLoopCount = 0;
2591
- let arcStartIdx = turnStartIdx + 1;
2592
- let t1CollapsesDone = 0;
2593
2459
  // Duplicate policy is scoped to one user query. Only successful, real
2594
2460
  // executions increment these counters; errors and cache reuse do not.
2595
2461
  const queryDuplicateCounts = new Map();
2596
2462
  const queryDuplicateSuppressions = new Map();
2597
2463
  const queryValidationFailures = new Map();
2598
2464
  const fileReadObservations = new Map();
2465
+ // Success reuse and in-flight sharing must not cross user turns, Sessions,
2466
+ // or child Engines. Never copy this ledger into parentEngineDeps.
2467
+ const webResearch = createWebResearchState();
2599
2468
  let duplicateReminderAwaitingResponse = false;
2600
2469
  const queryNumber = (this.#__queryCounter = (this.#__queryCounter || 0) + 1);
2601
2470
 
@@ -3057,7 +2926,7 @@ export class Engine {
3057
2926
  ));
3058
2927
  const toolSchemaTokens = toolDefs.length > 0
3059
2928
  ? approxTokens(JSON.stringify(toolDefs)) : 0;
3060
- const fittedRequest = fitProviderRequestToContext(wireMessages, {
2929
+ const fitOptions = {
3061
2930
  contextWindow: Math.max(1, Math.floor(currentContextWindow * providerContextScale)),
3062
2931
  systemTokens: estimateMessagesTokens(systemPrompt, []),
3063
2932
  toolSchemaTokens,
@@ -3065,9 +2934,50 @@ export class Engine {
3065
2934
  historyMessageCount,
3066
2935
  historyTokenBudget: historyBudget,
3067
2936
  language: requestConfig.language,
2937
+ };
2938
+ let fittedRequest = fitProviderRequestToContext(wireMessages, fitOptions);
2939
+ // A task can complete on an old tool row. Sampling or evicting that row
2940
+ // must not acknowledge unseen evidence. Re-inject only missing pending
2941
+ // deliveries as fresh provider-only notes, then check the final copy.
2942
+ const taskDeliveries = Array.from(this.#pendingAsyncTaskConfirmIds).flatMap(taskId => {
2943
+ const delivery = this.#acceptedAsyncTaskResults.get(taskId);
2944
+ if (!delivery) return [];
2945
+ const text = truncateToolResultIfNeeded(this.#formatTaskResultUpdateContent(delivery.content), {
2946
+ toolName: 'async task result', language: requestConfig.language,
2947
+ });
2948
+ return [{ taskId, text }];
3068
2949
  });
2950
+ const includesDelivery = (messages, text) => messages.some(message => (
2951
+ this.#formatTaskResultUpdateContent(message.content).includes(text)
2952
+ ));
2953
+ const missingDeliveries = taskDeliveries.filter(({ text }) => !includesDelivery(fittedRequest.messages, text));
2954
+ if (missingDeliveries.length > 0) {
2955
+ fittedRequest = fitProviderRequestToContext([...wireMessages, ...missingDeliveries.map(({ text }) => ({
2956
+ role: 'user', content: `[system note] Async task completion:\n${text}`,
2957
+ }))], fitOptions);
2958
+ }
3069
2959
  wireMessages = fittedRequest.messages;
2960
+ const deliveredTaskIds = taskDeliveries.filter(({ text }) => includesDelivery(wireMessages, text))
2961
+ .map(({ taskId }) => taskId);
3070
2962
  historyMessageCount = fittedRequest.meta.historyMessagesAfter;
2963
+ if (fittedRequest.meta.compactedToolResults > 0) {
2964
+ // Compaction invalidates file-read hints, not execution/reuse safety
2965
+ // ledgers. The complete result remains in transcript/debug storage.
2966
+ fileReadObservations.clear();
2967
+ recordAgentPerfTrace(requestConfig, {
2968
+ traceId: perfTraceId || queryTurnId,
2969
+ phase: 'tool_result_compact',
2970
+ sessionId: runtimeSessionId,
2971
+ vpId: queryVpId,
2972
+ turnId: queryTurnId,
2973
+ threadId: runtimeThreadId,
2974
+ detail: {
2975
+ model: currentModel,
2976
+ recoveryAttempt: contextOverflowRecoveryAttempts,
2977
+ ...fittedRequest.meta,
2978
+ },
2979
+ });
2980
+ }
3071
2981
  if (fittedRequest.meta.droppedHistoryMessages > 0
3072
2982
  || fittedRequest.meta.droppedCurrentMessages > 0
3073
2983
  || providerContextScale < 1) {
@@ -3080,49 +2990,6 @@ export class Engine {
3080
2990
  });
3081
2991
  }
3082
2992
 
3083
- // task-704b: pre-flight total-token guard. Even with the per-tool
3084
- // cap (registry.js: 10% of contextWindow per result), N tool
3085
- // results plus history can still breach the wire limit before we
3086
- // ever call adapter.stream(). Estimate the total token cost; if
3087
- // it exceeds PREFLIGHT_RATIO of the live context window, run an
3088
- // emergency archive sweep with `turnAgeMin: 0` so even
3089
- // current-turn-but-not-this-call bulky results get stubbed. The
3090
- // normal sweep above only stubs results older than 5 user turns
3091
- // — that's the wrong cadence when the *current* turn already has
3092
- // 4 large grep results.
3093
- //
3094
- // PREFLIGHT_RATIO = 0.85 leaves ~15% of the window for the model's
3095
- // own output tokens + tools metadata + light future history.
3096
- // The estimator (`estimateMessagesTokens`) is approxTokens
3097
- // (char/4 with CJK weighting) — good enough for a guard rail; a
3098
- // real tokenizer would be exact but adds a heavy dep.
3099
- if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
3100
- const PREFLIGHT_RATIO = 0.85;
3101
- const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
3102
- const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
3103
- if (estimate > threshold) {
3104
- try {
3105
- const sweep = await archiveToolResults({
3106
- root: `${this.#yeaftDir}/memory`,
3107
- scopeDir: 'user',
3108
- messages: wireMessages,
3109
- turnAgeMin: 0,
3110
- lengthMin: this.#config?.archive?.lengthMin ?? 2000,
3111
- });
3112
- wireMessages = sweep.nextMessages;
3113
- if (sweep.archivedCount > 0) {
3114
- this.#trace.log?.('preflight_sweep', {
3115
- archivedCount: sweep.archivedCount,
3116
- archivedBytes: sweep.archivedBytes,
3117
- estimateBefore: estimate,
3118
- threshold,
3119
- contextWindow: currentContextWindow,
3120
- });
3121
- }
3122
- } catch { /* best-effort */ }
3123
- }
3124
- }
3125
-
3126
2993
  // Freeze diagnostics only after every provider-only trim/sweep has
3127
2994
  // completed, so debug describes the exact request copy rather than the
3128
2995
  // durable transcript or an earlier preflight candidate.
@@ -3218,7 +3085,7 @@ export class Engine {
3218
3085
  // is not delivery: fetch may remain pending and then be aborted before
3219
3086
  // the provider processes anything. Ack only after a normal stream end
3220
3087
  // that included the provider's terminal stop event.
3221
- const requestAsyncTaskIds = Array.from(this.#pendingAsyncTaskConfirmIds);
3088
+ const requestAsyncTaskIds = deliveredTaskIds;
3222
3089
  let sawProviderStop = false;
3223
3090
  for await (const event of providerStream) {
3224
3091
  // task-325a (abort-stop fix): per-event abort short-circuit.
@@ -4204,80 +4071,8 @@ export class Engine {
4204
4071
  yield { type: 'turn_end', turnNumber, stopReason, threadId, terminal: true };
4205
4072
 
4206
4073
  // Message durability is handled incrementally before this terminal
4207
- // branch. There is no hidden end-of-turn LLM maintenance step.
4208
-
4209
- // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
4210
- // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
4211
- // (8) AND no T1 has actually rewritten the arc yet. Kicks off the
4212
- // primary-model call without await; the next query()'s
4213
- // `#applyPendingT2Reflections` carries the result forward.
4214
- //
4215
- // Periodic-T1 fix: gate on `t1CollapsesDone === 0`, NOT
4216
- // `lastT1AtLoopCount === 0`. The catch block of T1 advances
4217
- // `lastT1AtLoopCount` after a reflector error to avoid
4218
- // tight-loop retries — but no collapse happened, so T2 should
4219
- // still be allowed to fall back at end_turn. Fowler-review
4220
- // critical finding.
4221
- if (groupReflectionAllowed && queryToolCount > TURN_SUMMARY_THRESHOLD && t1CollapsesDone === 0) {
4222
- const arcStart = turnStartIdx + 1;
4223
- const arcEnd = conversationMessages.length - 1;
4224
- if (arcEnd > arcStart) {
4225
- const { pairs, assistantText } = extractToolPairsFromRange(
4226
- conversationMessages, arcStart, arcEnd,
4227
- );
4228
- yield {
4229
- type: 'reflection',
4230
- turnId: queryTurnId,
4231
- loopNumber: turnNumber,
4232
- trigger: 't2',
4233
- status: 'pending',
4234
- loopRange: [arcStart, arcEnd],
4235
- toolCount: pairs.length,
4236
- };
4237
- const promise = runT2Reflection({
4238
- adapter: this.#adapter,
4239
- model: this.#config.model,
4240
- originalUserMsg: prompt,
4241
- toolPairs: pairs,
4242
- assistantText,
4243
- language: this.#config.language,
4244
- signal,
4245
- onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
4246
- sessionId: runtimeSessionId, turnId: queryTurnId,
4247
- trigger: 't2', model: this.#config.model, ...diagnostic,
4248
- }),
4249
- });
4250
- // Detach: never await. The promise outlives this query() and
4251
- // the next call will pick it up (or use the fallback stub if
4252
- // it hasn't resolved by then).
4253
- // PR-L follow-up: latch a synchronously-readable ready flag
4254
- // and result on the info record so `#applyPendingT2Reflections`
4255
- // can decide ready-vs-pending without racing microtasks.
4256
- const info = {
4257
- promise,
4258
- loopRange: [arcStart, arcEnd],
4259
- // Absolute indexes do not survive history-window reconstruction.
4260
- arcKeys: conversationMessages.slice(arcStart, arcEnd + 1).map(foldingMessageKey),
4261
- arcRows: conversationMessages.slice(arcStart, arcEnd + 1).map(message => ({
4262
- role: message.role, id: message.id, _persistedMessageId: message._persistedMessageId,
4263
- toolCallId: message.toolCallId, toolCalls: message.toolCalls?.map(call => ({ id: call.id })),
4264
- })),
4265
- count: pairs.length,
4266
- originalUserMsg: prompt,
4267
- originatingTurnId: queryTurnId,
4268
- causalRootId,
4269
- executionOrigin,
4270
- ready: false,
4271
- result: null,
4272
- error: null,
4273
- };
4274
- promise.then(
4275
- (v) => { info.ready = true; info.result = v; },
4276
- (err) => { info.ready = true; info.error = err; },
4277
- );
4278
- this.#pendingT2.set(queryNumber, info);
4279
- }
4280
- }
4074
+ // branch. No reflection or durable history fold runs here; the separate
4075
+ // post-response compact is scheduled only after turn_close delivery.
4281
4076
 
4282
4077
  break;
4283
4078
  }
@@ -4295,6 +4090,7 @@ export class Engine {
4295
4090
  effortDecision: snapshotEffortDecision(requestEffortDecision),
4296
4091
  requestIdentity,
4297
4092
  queryTurnId,
4093
+ webResearch,
4298
4094
  historyBeforeSeq: Number.isFinite(persistedQueryUser?.seq) ? persistedQueryUser.seq : parseSeqFromId(persistedQueryUser?.id),
4299
4095
  router,
4300
4096
  senderVpId,
@@ -4406,7 +4202,6 @@ export class Engine {
4406
4202
  // break out of the outer while-loop cleanly once the current
4407
4203
  // tool batch finishes reporting.
4408
4204
  let abortedDuringTools = false;
4409
- let batchHasUnseenImages = false;
4410
4205
  /** @type {string[]} */
4411
4206
  const pendingDupReminders = [];
4412
4207
  let terminateAfterDuplicateBatch = false;
@@ -4602,15 +4397,31 @@ export class Engine {
4602
4397
  : this.#tools.has(tc.name);
4603
4398
  const toolProjectDocPathHints = projectDocPathHintsFromToolCall(tc.name, tc.input);
4604
4399
  const readOnlyTool = hasTool ? isReadOnlyTool(this, tc.name, tc.input) : false;
4605
- const missingProjectDocScopes = hasTool && !readOnlyTool
4606
- ? projectDocWriteScopesNeedingReload(projectDocContext, toolProjectDocPathHints)
4400
+ const projectDocWrite = hasTool
4401
+ && projectDocToolCallNeedsWriteReview(tc.name, tc.input, readOnlyTool);
4402
+ const missingProjectDocScopes = projectDocWrite
4403
+ // Shell code is opaque: cwd alone cannot identify its write targets.
4404
+ // Load all omitted rules rather than interpreting command words as paths.
4405
+ ? projectDocWriteScopesNeedingReload(projectDocContext, tc.name === 'Bash' ? [] : toolProjectDocPathHints)
4607
4406
  : new Set();
4608
4407
  // A changed rule source was not present in the request that generated
4609
4408
  // this write, even when its scope label is unchanged. Never replay it.
4610
- const freshProjectDocSource = hasTool && !readOnlyTool
4409
+ const freshProjectDocSource = projectDocWrite
4611
4410
  ? this.#getProjectDocBlock(workDir) : projectDocSource;
4612
4411
  const projectDocSourceChanged = freshProjectDocSource !== projectDocSource;
4613
- const needsProjectDocReload = missingProjectDocScopes.size > 0 || projectDocSourceChanged;
4412
+ const nextProjectDocPathHints = [...new Set([
4413
+ ...projectDocLoadedPathHints, ...toolProjectDocPathHints,
4414
+ ])];
4415
+ const reloadedProjectDocContext = missingProjectDocScopes.size > 0 || projectDocSourceChanged
4416
+ ? selectProjectDocContext(freshProjectDocSource, {
4417
+ prompt, messages, pathHints: nextProjectDocPathHints,
4418
+ language: this.#config.language || 'en',
4419
+ forcedScopes: [...projectDocContext.selectedScopes, ...missingProjectDocScopes],
4420
+ })
4421
+ : projectDocContext;
4422
+ // Scope bookkeeping (including core sections already shown) is not a
4423
+ // new instruction. Only stop a write when its model-visible rules change.
4424
+ const needsProjectDocReload = reloadedProjectDocContext.text !== projectDocContext.text;
4614
4425
 
4615
4426
  if (abortSkipped) {
4616
4427
  output = `Skipped ${tc.name} because the turn was aborted before this tool started.`;
@@ -4659,22 +4470,15 @@ export class Engine {
4659
4470
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, suppressed: true, threadId: this.currentThreadId };
4660
4471
  } else if (needsProjectDocReload) {
4661
4472
  projectDocSource = freshProjectDocSource;
4662
- projectDocLoadedPathHints = [...new Set([
4663
- ...projectDocLoadedPathHints,
4664
- ...toolProjectDocPathHints,
4665
- ])];
4666
- const previouslySelectedProjectDocScopes = new Set(projectDocContext.selectedScopes);
4667
- projectDocContext = selectProjectDocContext(projectDocSource, {
4668
- prompt,
4669
- messages,
4670
- pathHints: projectDocLoadedPathHints,
4671
- language: this.#config.language || 'en',
4672
- forcedScopes: [...previouslySelectedProjectDocScopes, ...missingProjectDocScopes],
4673
- });
4473
+ projectDocLoadedPathHints = nextProjectDocPathHints;
4474
+ projectDocContext = reloadedProjectDocContext;
4674
4475
  const zh = String(this.#config?.language || '').toLowerCase().startsWith('zh');
4476
+ const target = toolProjectDocPathHints.length > 0
4477
+ ? (zh ? `路径 ${toolProjectDocPathHints.join(', ')}` : toolProjectDocPathHints.join(', '))
4478
+ : (zh ? `${tc.name} 写操作` : `the ${tc.name} write`);
4675
4479
  const notice = zh
4676
- ? `项目规则已针对路径 ${toolProjectDocPathHints.join(', ')} 重新加载。先复核新载入的规则,再重新提交写操作;本次调用未执行。`
4677
- : `Project rules were reloaded for ${toolProjectDocPathHints.join(', ')}. Review the newly loaded rules before resubmitting the write; this call was not executed.`;
4480
+ ? `项目规则已针对${target}重新加载。先复核新载入的规则,再重新提交写操作;本次调用未执行。`
4481
+ : `Project rules were reloaded for ${target}. Review the newly loaded rules before resubmitting the write; this call was not executed.`;
4678
4482
  promptNotices = [notice];
4679
4483
  systemPrompt = buildCurrentSystemPrompt();
4680
4484
  output = notice;
@@ -4930,7 +4734,6 @@ export class Engine {
4930
4734
  toolName: tc.name,
4931
4735
  language: this.#config?.language,
4932
4736
  });
4933
- if (modelImages.length > 0) batchHasUnseenImages = true;
4934
4737
  const toolMessage = {
4935
4738
  role: 'tool',
4936
4739
  toolCallId: tc.id,
@@ -5072,10 +4875,8 @@ export class Engine {
5072
4875
  // clean wire shape. Used by `route_forward` to hand off control
5073
4876
  // to other VPs without continuing to generate.
5074
4877
  //
5075
- // Order matters: this runs BEFORE T1 reflection (which would
5076
- // collapse the arc into a summary that's only valuable across
5077
- // multi-iteration tool loops) and BEFORE the abortedDuringTools
5078
- // check (so a clean handoff doesn't get reported as 'aborted').
4878
+ // A clean handoff must precede the abortedDuringTools check so it
4879
+ // does not get reported as 'aborted'.
5079
4880
  if (endTurnRequested && !toolBatchBarrier) {
5080
4881
  if (pendingSubAgentNotifs.length > 0) {
5081
4882
  acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
@@ -5094,180 +4895,6 @@ export class Engine {
5094
4895
  break;
5095
4896
  }
5096
4897
 
5097
- // PR-L: T1 in-turn (synchronous) reflection. Fires every 30 completed
5098
- // tool loops. A provider response containing many parallel tool calls is
5099
- // one loop, not many. Generates a markdown reflection
5100
- // over the assistant+tool arc since the last T1 firing (or
5101
- // since the user prompt for the first batch) and rewrites that
5102
- // range to a SINGLE synthetic user message before the next
5103
- // adapter.stream() runs.
5104
- //
5105
- // Loop semantics:
5106
- // - First interval: arcStartIdx = turnStartIdx + 1.
5107
- // - Each subsequent interval: arcStartIdx is updated to the slot
5108
- // right after the just-inserted reflection message; fires
5109
- // again whenever 30 more tool loops have completed.
5110
- // - The dedup Set key includes the loop count so each interval
5111
- // within the same query gets a distinct entry — without
5112
- // this the second batch would be silently skipped.
5113
- const completedToolLoops = toolLoopTurns + 1;
5114
- const t1BatchDue = completedToolLoops - lastT1AtLoopCount
5115
- >= TOOL_LOOP_REFLECTION_INTERVAL;
5116
- // Text-only reflection cannot consume visual input. Defer folding until
5117
- // the primary provider has received this image batch at least once.
5118
- if (t1BatchDue && !batchHasUnseenImages && !toolBatchBarrier
5119
- && !abortedDuringTools && !signal?.aborted) {
5120
- const t1DedupKey = `${queryNumber}:t1-loop:${completedToolLoops}`;
5121
- if (this.#reflectedTurns.has(t1DedupKey)) {
5122
- // Defensive: should never hit since t1BatchDue gates re-entry
5123
- // and queryNumber namespaces queries. Kept as belt-and-
5124
- // suspenders against any future external mutation of the
5125
- // cursor (or a re-entrant query() that this code doesn't
5126
- // anticipate).
5127
- } else {
5128
- this.#reflectedTurns.add(t1DedupKey);
5129
- const batchStart = arcStartIdx;
5130
- const batchEnd = conversationMessages.length - 1;
5131
- try {
5132
- const { pairs, assistantText } = extractToolPairsFromRange(
5133
- conversationMessages, batchStart, batchEnd,
5134
- );
5135
- yield {
5136
- type: 'reflection',
5137
- turnId: queryTurnId,
5138
- threadId,
5139
- loopNumber: turnNumber,
5140
- trigger: 't1',
5141
- status: 'pending',
5142
- loopRange: [batchStart, batchEnd],
5143
- toolCount: pairs.length,
5144
- };
5145
- // Observe only this synchronous query-owned call. Do not tap the
5146
- // shared accounting adapter (also used by children), or feed these
5147
- // costs into the main loop's token/dispatch budget. Capture raw usage
5148
- // before reflection diagnostics normalize away cache-inclusion flags.
5149
- const reflectionAdapter = typeof this.#adapter.call === 'function' ? {
5150
- call: async params => {
5151
- let dispatches = 0;
5152
- let result;
5153
- let failure;
5154
- try {
5155
- result = await this.#adapter.call({
5156
- ...params,
5157
- onRequestStart: (...args) => {
5158
- dispatches++;
5159
- queryProgress.requestStarted();
5160
- params.onRequestStart?.(...args);
5161
- },
5162
- });
5163
- return result;
5164
- } catch (error) {
5165
- failure = error;
5166
- throw error;
5167
- } finally {
5168
- // Native adapters report every dispatch, including a refreshed
5169
- // credential retry. Legacy adapters without the hook count once.
5170
- if (dispatches === 0) queryProgress.requestStarted();
5171
- const usage = result?.usage || failure?.usage;
5172
- if (usage) queryProgress.usageObserved(usage);
5173
- }
5174
- },
5175
- } : this.#adapter;
5176
- const { content, durationMs } = await runT1Reflection({
5177
- adapter: reflectionAdapter,
5178
- model: this.#config.model,
5179
- originalUserMsg: prompt,
5180
- toolPairs: pairs,
5181
- assistantText,
5182
- language: this.#config.language,
5183
- signal,
5184
- onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
5185
- sessionId: runtimeSessionId, turnId: queryTurnId,
5186
- trigger: 't1', model: this.#config.model, ...diagnostic,
5187
- }),
5188
- });
5189
- const { messages: next, reflection: reflectionMessage, foldedMessages } = collapseRangeToReflection(
5190
- conversationMessages, batchStart, batchEnd, content,
5191
- );
5192
- const durableRowsInRange = foldedMessages
5193
- .some(message => message?._persistedMessageId || message?.id);
5194
- const persistedReflection = this.#persistFoldedRange(
5195
- foldedMessages,
5196
- reflectionMessage,
5197
- { sessionId: runtimeSessionId, model: currentModel, executionOrigin },
5198
- );
5199
- if (durableRowsInRange && !persistedReflection) {
5200
- throw new Error('T1 reflection could not publish its durable range replacement');
5201
- }
5202
- if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
5203
- // Raw tool rows inside the replacement range are now tombstoned in
5204
- // durable history. Late async completions must follow the folded
5205
- // continuation path rather than appending to those stale rows.
5206
- const foldedToolCallIds = conversationMessages
5207
- .slice(batchStart, batchEnd + 1)
5208
- .filter(message => message?.role === 'tool' && message.toolCallId)
5209
- .map(message => message.toolCallId);
5210
- for (const toolCallId of foldedToolCallIds) {
5211
- this.#persistedToolMessages.delete(toolCallId);
5212
- }
5213
- conversationMessages.length = 0;
5214
- for (const m of next) conversationMessages.push(m);
5215
- // The preserved users precede the reflection. The next arc starts
5216
- // immediately after it, i.e. at conversationMessages.length
5217
- // (the next assistant message will land here).
5218
- arcStartIdx = conversationMessages.length;
5219
- // Read hints must not imply raw ranges remain in model context.
5220
- fileReadObservations.clear();
5221
- lastT1AtLoopCount = completedToolLoops;
5222
- // Bump the success counter — used by the T2 schedule check
5223
- // to decide whether T2 still has work to do at end_turn.
5224
- // Distinct from lastT1AtLoopCount which the catch block
5225
- // also bumps (but without rewriting history).
5226
- t1CollapsesDone += 1;
5227
- yield {
5228
- type: 'reflection',
5229
- turnId: queryTurnId,
5230
- threadId,
5231
- loopNumber: turnNumber,
5232
- trigger: 't1',
5233
- // PR-L bug fix: keep the same loopRange as the `pending` event
5234
- // so the frontend key stays stable across pending → ready and
5235
- // the spinner card is replaced in place (no orphan).
5236
- status: 'ready',
5237
- loopRange: [batchStart, batchEnd],
5238
- toolCount: pairs.length,
5239
- content,
5240
- durationMs,
5241
- };
5242
- } catch (err) {
5243
- // Best-effort. On failure leave history unchanged so the loop
5244
- // continues normally — never block the turn.
5245
- yield {
5246
- type: 'reflection',
5247
- turnId: queryTurnId,
5248
- threadId,
5249
- loopNumber: turnNumber,
5250
- trigger: 't1',
5251
- status: 'error',
5252
- error: err && err.message || String(err),
5253
- };
5254
- // Advance lastT1AtLoopCount past this interval so we don't
5255
- // tight-loop on a hiccuping reflector. The next attempt is
5256
- // another 30 tool loops from now, not immediately. arcStartIdx is
5257
- // left alone because history wasn't rewritten — the tail still
5258
- // begins where it did. The trade-off: the next batch's
5259
- // reflection will cover the tools that just failed too,
5260
- // which is fine (they're still in conversationMessages).
5261
- //
5262
- // We do NOT bump t1CollapsesDone — see the variable's
5263
- // declaration comment. This keeps the T2 fallback path live
5264
- // when every T1 attempt has errored.
5265
- lastT1AtLoopCount = completedToolLoops;
5266
- }
5267
- yield queryProgress.snapshot(progressIdentity);
5268
- }
5269
- }
5270
-
5271
4898
  // task-325a: if abort fired between tools, converge now — emit
5272
4899
  // the typed `aborted` event + a final turn_end with stopReason
5273
4900
  // 'aborted' instead of looping back to a new adapter call.
@@ -5439,109 +5066,6 @@ export class Engine {
5439
5066
  /** @returns {import('./mcp.js').MCPManager|null} */
5440
5067
  get mcpManager() { return this.#mcpManager; }
5441
5068
 
5442
- /**
5443
- * PR-L — V7 Tool History Reflection helpers.
5444
- *
5445
- * `#applyPendingT2Reflections` is called at the start of every
5446
- * `#runQuery` to carry forward any prior turn's async reflection. It is
5447
- * a generator so it can yield reflection events to the engine consumer.
5448
- * Non-blocking: never awaits a pending promise.
5449
- *
5450
- * @param {Array} conversationMessages
5451
- * @param {string} originalUserMsg
5452
- * @param {{sessionId?: string, model?: string}} context
5453
- */
5454
- async *#applyPendingT2Reflections(conversationMessages, originalUserMsg, context = {}) {
5455
- if (this.#pendingT2.size === 0) return;
5456
- // Drain in insertion order (Map preserves it). We process all entries
5457
- // because the user could send multiple prompts back-to-back before
5458
- // the engine resumes — each historical turn gets its rewrite.
5459
- const drained = [...this.#pendingT2.entries()];
5460
- this.#pendingT2.clear();
5461
-
5462
- for (const [turnNumber, info] of drained) {
5463
- const keys = info.arcKeys;
5464
- if (!Array.isArray(keys) || keys.length === 0) continue;
5465
- const currentKeys = conversationMessages.map(foldingMessageKey);
5466
- const startIdx = currentKeys.findIndex((key, index) => key === keys[0]
5467
- && keys.every((expected, offset) => currentKeys[index + offset] === expected));
5468
- const endIdx = startIdx + keys.length - 1;
5469
- const rangePresent = startIdx >= 0;
5470
-
5471
- // PR-L follow-up: deterministic readiness check. The info record
5472
- // carries `ready / result / error` flags that are flipped from the
5473
- // promise's then/catch handler; reading them here is purely
5474
- // synchronous bookkeeping — no microtask race.
5475
- let content;
5476
- let trigger;
5477
- let durationMs = 0;
5478
- if (!info.ready) {
5479
- // Still in flight — fall back to the exec-log stub. Detach the
5480
- // unresolved promise so we don't leak it (handlers above already
5481
- // swallow rejection by routing into info.error).
5482
- const entries = this.#execLog ? this.#execLog.readTurn(turnNumber) : [];
5483
- content = buildFallbackStub({ execLogEntries: entries, originalUserMsg: info.originalUserMsg || originalUserMsg });
5484
- trigger = 't2-fallback';
5485
- } else if (info.error) {
5486
- // Promise rejected — leave history unchanged, no event.
5487
- continue;
5488
- } else if (info.result && typeof info.result.content === 'string' && info.result.content) {
5489
- content = info.result.content;
5490
- trigger = 't2';
5491
- durationMs = info.result.durationMs || 0;
5492
- } else {
5493
- // Resolved but with no usable content — defensively skip.
5494
- continue;
5495
- }
5496
-
5497
- // Rewrite history and publish the same logical replacement to disk.
5498
- const { messages: next, reflection: reflectionMessage, foldedMessages } = rangePresent
5499
- ? collapseRangeToReflection(conversationMessages, startIdx, endIdx, content)
5500
- : collapseRangeToReflection(info.arcRows, 0, info.arcRows.length - 1, content);
5501
- const durableRowsInRange = foldedMessages
5502
- .some(message => message?._persistedMessageId || message?.id);
5503
- const persistedReflection = this.#persistFoldedRange(
5504
- foldedMessages,
5505
- reflectionMessage,
5506
- {
5507
- ...context,
5508
- causalRootId: info.causalRootId || null,
5509
- executionOrigin: info.executionOrigin === 'route_forward' ? 'route_forward' : null,
5510
- },
5511
- );
5512
- if (durableRowsInRange && !persistedReflection) continue;
5513
- if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
5514
- // T2 replaces this arc in durable and in-memory history just like T1.
5515
- // Forget its raw result handles before a late async task can append to a
5516
- // tombstoned tool row at the next provider boundary.
5517
- const foldedToolCallIds = foldedMessages
5518
- .filter(message => message?.role === 'tool' && message.toolCallId)
5519
- .map(message => message.toolCallId);
5520
- for (const toolCallId of foldedToolCallIds) {
5521
- this.#persistedToolMessages.delete(toolCallId);
5522
- }
5523
- // History may have evicted the old arc. Publish only its original
5524
- // durable row IDs; never replace unrelated rows at stale indexes.
5525
- if (rangePresent) {
5526
- conversationMessages.length = 0;
5527
- for (const m of next) conversationMessages.push(m);
5528
- }
5529
-
5530
- yield {
5531
- type: 'reflection',
5532
- turnId: info.originatingTurnId || null,
5533
- trigger,
5534
- status: 'ready',
5535
- // Wire identity must match the original pending card, even if the
5536
- // history window shifted or evicted its internal replacement range.
5537
- loopRange: info.loopRange,
5538
- toolCount: info.count || 0,
5539
- content,
5540
- durationMs,
5541
- };
5542
- }
5543
- }
5544
-
5545
5069
  /**
5546
5070
  * PR-L — read-only accessor for tests.
5547
5071
  */
@@ -5696,10 +5220,9 @@ export class Engine {
5696
5220
  ? opts.preview
5697
5221
  : (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
5698
5222
  const meta = this.#asyncTaskToolMeta.get(taskId) || {};
5699
- // A completion that lands while its original tool arc is still present
5700
- // can update that tool result in-place. Once T1/T2 folded the arc, the
5701
- // provider must receive only a continuation note; otherwise we recreate
5702
- // a raw tool row after the reflection and invalidate the fold.
5223
+ // A completion updates its original tool row when available. Legacy folded
5224
+ // histories instead receive a continuation note; provider-only compaction
5225
+ // never clears this durable handle or changes result ownership.
5703
5226
  const hasLiveToolRow = typeof meta.toolCallId === 'string'
5704
5227
  && meta.toolCallId
5705
5228
  && Boolean(this.#persistedToolMessages.get(meta.toolCallId));
@@ -5725,9 +5248,8 @@ export class Engine {
5725
5248
  sessionId: delivery.sessionId,
5726
5249
  vpId: delivery.vpId,
5727
5250
  turnId: delivery.turnId,
5728
- // #persistedToolMessages is cleared as soon as folding publishes its
5729
- // durable reflection. Capture that boundary at completion time so a
5730
- // later drain cannot infer stale liveness from an unrelated row.
5251
+ // Capture durable row ownership at completion time, independently of
5252
+ // any provider-only compacted request copy.
5731
5253
  folded: !hasLiveToolRow,
5732
5254
  });
5733
5255
  } else {
@@ -5765,8 +5287,7 @@ export class Engine {
5765
5287
  : (this.#sessionId || null),
5766
5288
  vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
5767
5289
  // query() exposes the wire turn id while the task is registered.
5768
- // Persist it with a late completion even after T1/T2 removed the
5769
- // original tool row from the in-memory arc.
5290
+ // Keep the originating turn even for legacy missing/folded tool rows.
5770
5291
  turnId: typeof meta.turnId === 'string' && meta.turnId
5771
5292
  ? meta.turnId
5772
5293
  : (this.#currentQueryTurnId || null),