@yeaft/webchat-agent 1.0.640 → 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.
@@ -1 +1 @@
1
- {"version":"1.0.640"}
1
+ {"version":"1.0.641"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.640",
3
+ "version": "1.0.641",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
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. */
@@ -1683,17 +1612,6 @@ export class Engine {
1683
1612
  return this.#conversationStore.append(this.#conversationRecord(message, context), { strict: context.strict === true });
1684
1613
  }
1685
1614
 
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
1615
  #formatTaskResultUpdateContent(content) {
1698
1616
  if (typeof content === 'string') return content;
1699
1617
  try { return JSON.stringify(content); } catch { return String(content); }
@@ -1713,10 +1631,9 @@ export class Engine {
1713
1631
  msg && msg.role === 'tool' && msg.toolCallId === update.toolCallId
1714
1632
  ));
1715
1633
  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
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
1720
1637
  // user-authored message; ConversationStore keeps it off the visible
1721
1638
  // transcript while retaining it for the next provider boundary.
1722
1639
  const contextContent = truncateToolResultIfNeeded(appendText, {
@@ -2535,64 +2452,10 @@ export class Engine {
2535
2452
  },
2536
2453
  ];
2537
2454
 
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").
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.
2591
2457
  const turnStartIdx = conversationMessages.length - 1;
2592
2458
  let queryToolCount = 0;
2593
- let lastT1AtLoopCount = 0;
2594
- let arcStartIdx = turnStartIdx + 1;
2595
- let t1CollapsesDone = 0;
2596
2459
  // Duplicate policy is scoped to one user query. Only successful, real
2597
2460
  // executions increment these counters; errors and cache reuse do not.
2598
2461
  const queryDuplicateCounts = new Map();
@@ -3063,7 +2926,7 @@ export class Engine {
3063
2926
  ));
3064
2927
  const toolSchemaTokens = toolDefs.length > 0
3065
2928
  ? approxTokens(JSON.stringify(toolDefs)) : 0;
3066
- const fittedRequest = fitProviderRequestToContext(wireMessages, {
2929
+ const fitOptions = {
3067
2930
  contextWindow: Math.max(1, Math.floor(currentContextWindow * providerContextScale)),
3068
2931
  systemTokens: estimateMessagesTokens(systemPrompt, []),
3069
2932
  toolSchemaTokens,
@@ -3071,9 +2934,50 @@ export class Engine {
3071
2934
  historyMessageCount,
3072
2935
  historyTokenBudget: historyBudget,
3073
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 }];
3074
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
+ }
3075
2959
  wireMessages = fittedRequest.messages;
2960
+ const deliveredTaskIds = taskDeliveries.filter(({ text }) => includesDelivery(wireMessages, text))
2961
+ .map(({ taskId }) => taskId);
3076
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
+ }
3077
2981
  if (fittedRequest.meta.droppedHistoryMessages > 0
3078
2982
  || fittedRequest.meta.droppedCurrentMessages > 0
3079
2983
  || providerContextScale < 1) {
@@ -3086,49 +2990,6 @@ export class Engine {
3086
2990
  });
3087
2991
  }
3088
2992
 
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
2993
  // Freeze diagnostics only after every provider-only trim/sweep has
3133
2994
  // completed, so debug describes the exact request copy rather than the
3134
2995
  // durable transcript or an earlier preflight candidate.
@@ -3224,7 +3085,7 @@ export class Engine {
3224
3085
  // is not delivery: fetch may remain pending and then be aborted before
3225
3086
  // the provider processes anything. Ack only after a normal stream end
3226
3087
  // that included the provider's terminal stop event.
3227
- const requestAsyncTaskIds = Array.from(this.#pendingAsyncTaskConfirmIds);
3088
+ const requestAsyncTaskIds = deliveredTaskIds;
3228
3089
  let sawProviderStop = false;
3229
3090
  for await (const event of providerStream) {
3230
3091
  // task-325a (abort-stop fix): per-event abort short-circuit.
@@ -4210,80 +4071,8 @@ export class Engine {
4210
4071
  yield { type: 'turn_end', turnNumber, stopReason, threadId, terminal: true };
4211
4072
 
4212
4073
  // 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
- }
4074
+ // branch. No reflection or durable history fold runs here; the separate
4075
+ // post-response compact is scheduled only after turn_close delivery.
4287
4076
 
4288
4077
  break;
4289
4078
  }
@@ -4413,7 +4202,6 @@ export class Engine {
4413
4202
  // break out of the outer while-loop cleanly once the current
4414
4203
  // tool batch finishes reporting.
4415
4204
  let abortedDuringTools = false;
4416
- let batchHasUnseenImages = false;
4417
4205
  /** @type {string[]} */
4418
4206
  const pendingDupReminders = [];
4419
4207
  let terminateAfterDuplicateBatch = false;
@@ -4946,7 +4734,6 @@ export class Engine {
4946
4734
  toolName: tc.name,
4947
4735
  language: this.#config?.language,
4948
4736
  });
4949
- if (modelImages.length > 0) batchHasUnseenImages = true;
4950
4737
  const toolMessage = {
4951
4738
  role: 'tool',
4952
4739
  toolCallId: tc.id,
@@ -5088,10 +4875,8 @@ export class Engine {
5088
4875
  // clean wire shape. Used by `route_forward` to hand off control
5089
4876
  // to other VPs without continuing to generate.
5090
4877
  //
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').
4878
+ // A clean handoff must precede the abortedDuringTools check so it
4879
+ // does not get reported as 'aborted'.
5095
4880
  if (endTurnRequested && !toolBatchBarrier) {
5096
4881
  if (pendingSubAgentNotifs.length > 0) {
5097
4882
  acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
@@ -5110,180 +4895,6 @@ export class Engine {
5110
4895
  break;
5111
4896
  }
5112
4897
 
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
4898
  // task-325a: if abort fired between tools, converge now — emit
5288
4899
  // the typed `aborted` event + a final turn_end with stopReason
5289
4900
  // 'aborted' instead of looping back to a new adapter call.
@@ -5455,109 +5066,6 @@ export class Engine {
5455
5066
  /** @returns {import('./mcp.js').MCPManager|null} */
5456
5067
  get mcpManager() { return this.#mcpManager; }
5457
5068
 
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
5069
  /**
5562
5070
  * PR-L — read-only accessor for tests.
5563
5071
  */
@@ -5712,10 +5220,9 @@ export class Engine {
5712
5220
  ? opts.preview
5713
5221
  : (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
5714
5222
  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.
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.
5719
5226
  const hasLiveToolRow = typeof meta.toolCallId === 'string'
5720
5227
  && meta.toolCallId
5721
5228
  && Boolean(this.#persistedToolMessages.get(meta.toolCallId));
@@ -5741,9 +5248,8 @@ export class Engine {
5741
5248
  sessionId: delivery.sessionId,
5742
5249
  vpId: delivery.vpId,
5743
5250
  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.
5251
+ // Capture durable row ownership at completion time, independently of
5252
+ // any provider-only compacted request copy.
5747
5253
  folded: !hasLiveToolRow,
5748
5254
  });
5749
5255
  } else {
@@ -5781,8 +5287,7 @@ export class Engine {
5781
5287
  : (this.#sessionId || null),
5782
5288
  vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
5783
5289
  // 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.
5290
+ // Keep the originating turn even for legacy missing/folded tool rows.
5786
5291
  turnId: typeof meta.turnId === 'string' && meta.turnId
5787
5292
  ? meta.turnId
5788
5293
  : (this.#currentQueryTurnId || null),
@@ -565,6 +565,130 @@ function truncateToolResultsForModel(messages, options = {}) {
565
565
  });
566
566
  }
567
567
 
568
+ const TOOL_COMPACT_MARKER = '\n[tool result compacted for context budget; middle omitted]\n';
569
+ const MIN_TOOL_SAMPLE_BYTES = 64;
570
+ const TOOL_ERROR_FIELDS = new Set(['error', 'code', 'errorEffect', 'failureType', 'status']);
571
+ const BINARY_PART_TYPES = new Set(['image', 'input_image', 'document', 'input_file']);
572
+
573
+ function sampleToolText(text, retainedBytes) {
574
+ const bytes = Buffer.from(text, 'utf8');
575
+ if (bytes.length <= retainedBytes + Buffer.byteLength(TOOL_COMPACT_MARKER)) return text;
576
+ let headEnd = Math.floor(retainedBytes / 2);
577
+ let tailStart = bytes.length - (retainedBytes - headEnd);
578
+ // UTF-8 boundaries also keep CJK and astral characters intact. The marker is
579
+ // charged together with both samples by estimateContentTokens below.
580
+ while (headEnd > 0 && (bytes[headEnd] & 0xc0) === 0x80) headEnd -= 1;
581
+ while (tailStart < bytes.length && (bytes[tailStart] & 0xc0) === 0x80) tailStart += 1;
582
+ return bytes.subarray(0, headEnd).toString('utf8') + TOOL_COMPACT_MARKER
583
+ + bytes.subarray(tailStart).toString('utf8');
584
+ }
585
+
586
+ function sampleToolJson(value, retainedBytes) {
587
+ if (typeof value === 'string') return sampleToolText(value, retainedBytes);
588
+ if (!value || typeof value !== 'object' || BINARY_PART_TYPES.has(value.type)) return value;
589
+ if (Array.isArray(value)) return value.map(item => sampleToolJson(item, retainedBytes));
590
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
591
+ key, TOOL_ERROR_FIELDS.has(key) ? item : sampleToolJson(item, retainedBytes),
592
+ ]));
593
+ }
594
+
595
+ function toolTextSampler(text) {
596
+ // Keep structured output parseable, including JSON inside text content parts
597
+ // and JSON string scalars. Only string leaves are sampled; flags, exit codes
598
+ // and Bash error markers stay whole. Unsafe numeric round trips and deeply
599
+ // nested structures stay intact for the existing protocol-unit budget fitter.
600
+ if (/^\s*[\[{"]/.test(text)) {
601
+ try {
602
+ const value = JSON.parse(text, (_key, item, context) => {
603
+ if (typeof item === 'number' && context?.source !== JSON.stringify(item)) {
604
+ throw new RangeError('JSON numeric representation would change');
605
+ }
606
+ return item;
607
+ });
608
+ const pending = [{ value, depth: 0 }];
609
+ while (pending.length > 0) {
610
+ const next = pending.pop();
611
+ if (!next.value || typeof next.value !== 'object') continue;
612
+ if (next.depth >= 64) return () => text;
613
+ for (const item of Object.values(next.value)) pending.push({ value: item, depth: next.depth + 1 });
614
+ }
615
+ return bytes => JSON.stringify(sampleToolJson(value, bytes));
616
+ } catch (error) {
617
+ // A valid but unsafe/deep JSON value must not crash sampling or silently
618
+ // change facts. Only invalid JSON falls through to ordinary text sampling.
619
+ if (!(error instanceof SyntaxError)) return () => text;
620
+ }
621
+ }
622
+ return bytes => sampleToolText(text, bytes);
623
+ }
624
+
625
+ function toolContentSampler(content) {
626
+ // Content parts keep their ordering, metadata and binary payloads. In
627
+ // particular, do not collapse mixed text/image/document output into JSON.
628
+ if (Array.isArray(content) && content.some(part => part && typeof part === 'object'
629
+ && (['text', 'input_text', 'output_text'].includes(part.type) || BINARY_PART_TYPES.has(part.type)))) {
630
+ const samplers = content.map(part => {
631
+ if (typeof part === 'string') return toolTextSampler(part);
632
+ if (part && ['text', 'input_text', 'output_text'].includes(part.type)
633
+ && typeof part.text === 'string') {
634
+ const sample = toolTextSampler(part.text);
635
+ return bytes => ({ ...part, text: sample(bytes) });
636
+ }
637
+ return () => part;
638
+ });
639
+ return bytes => samplers.map(sample => sample(bytes));
640
+ }
641
+ return toolTextSampler(serializeJsonValue(content));
642
+ }
643
+
644
+ function compactToolContentToBudget(content, tokenBudget) {
645
+ const sample = toolContentSampler(content);
646
+ const minimum = sample(MIN_TOOL_SAMPLE_BYTES);
647
+ if (estimateContentTokens(minimum) > tokenBudget) return minimum;
648
+ // Search measured content cost, not tokenBudget * chars/bytes. JSON escaping,
649
+ // CJK and multipart framing otherwise make small budgets easy to overshoot.
650
+ let low = MIN_TOOL_SAMPLE_BYTES;
651
+ let high = Math.max(low, Buffer.byteLength(serializeJsonValue(content), 'utf8'));
652
+ let fitted = minimum;
653
+ while (low < high) {
654
+ const mid = Math.ceil((low + high) / 2);
655
+ const candidate = sample(mid);
656
+ if (estimateContentTokens(candidate) <= tokenBudget) {
657
+ low = mid;
658
+ fitted = candidate;
659
+ } else {
660
+ high = mid - 1;
661
+ }
662
+ }
663
+ return fitted;
664
+ }
665
+
666
+ function compactCurrentToolResults(messages, messageBudget) {
667
+ const out = messages.slice();
668
+ let excess = estimateMessagesTokens(out) - messageBudget;
669
+ let compactedToolResults = 0;
670
+ let compactedToolResultTokensSaved = 0;
671
+ // Protect the complete newest tool batch, not merely its last result. All
672
+ // parallel results are evidence for the next assistant continuation.
673
+ let latestEnd = out.length - 1;
674
+ while (latestEnd >= 0 && out[latestEnd]?.role !== 'tool') latestEnd -= 1;
675
+ let latestStart = latestEnd;
676
+ while (latestStart > 0 && out[latestStart - 1]?.role === 'tool') latestStart -= 1;
677
+ for (let index = 0; index < latestStart && excess > 0; index += 1) {
678
+ const message = out[index];
679
+ if (message?.role !== 'tool') continue;
680
+ const before = estimateContentTokens(message.content);
681
+ const content = compactToolContentToBudget(message.content, Math.max(0, before - excess));
682
+ const saved = before - estimateContentTokens(content);
683
+ if (saved <= 0) continue;
684
+ out[index] = { ...message, content };
685
+ excess -= saved;
686
+ compactedToolResults += 1;
687
+ compactedToolResultTokensSaved += saved;
688
+ }
689
+ return { messages: out, compactedToolResults, compactedToolResultTokensSaved };
690
+ }
691
+
568
692
  const HISTORY_SOURCE_INDEX = Symbol('historySourceIndex');
569
693
 
570
694
  function withHistorySourceIndexes(messages, offset = 0) {
@@ -1139,8 +1263,9 @@ export function buildHistoryBuckets(snapshot, options = {}) {
1139
1263
  * Fit one provider-request copy to the actual model window. The caller tells
1140
1264
  * us where current-turn rows begin; only the prefix is subject to the history
1141
1265
  * budget. If the complete request is still too large, old history disappears
1142
- * first, followed by the oldest disposable current-turn protocol units. The
1143
- * source array and durable transcript are never mutated.
1266
+ * first. Under current-turn pressure, sample old tool-result text before
1267
+ * evicting disposable protocol units; the latest tool batch stays lossless
1268
+ * until the final budget fallback. The source and transcript are never mutated.
1144
1269
  *
1145
1270
  * @param {Array<object>} messages
1146
1271
  * @param {{ contextWindow:number, systemTokens?:number, toolSchemaTokens?:number,
@@ -1167,9 +1292,9 @@ export function fitProviderRequestToContext(messages, options = {}) {
1167
1292
  const historySource = source.slice(0, split);
1168
1293
  const currentSource = source.slice(split);
1169
1294
 
1170
- let current = pairSanitize(truncateToolResultsForModel(
1171
- currentSource.map(message => ({ ...message })), { language: options.language },
1172
- ));
1295
+ let current = pairSanitize(currentSource.map(message => ({ ...message })));
1296
+ const compact = compactCurrentToolResults(current, messageBudget);
1297
+ current = compact.messages;
1173
1298
  if (estimateMessagesTokens(current) > messageBudget || current.length > messageCap) {
1174
1299
  const fitted = [];
1175
1300
  if (current.length > 0 && messageBudget >= 2 && messageCap > 0) {
@@ -1222,6 +1347,8 @@ export function fitProviderRequestToContext(messages, options = {}) {
1222
1347
  currentMessagesAfter: current.length,
1223
1348
  droppedHistoryMessages: historySource.length - history.length,
1224
1349
  droppedCurrentMessages: currentSource.length - current.length,
1350
+ compactedToolResults: compact.compactedToolResults,
1351
+ compactedToolResultTokensSaved: compact.compactedToolResultTokensSaved,
1225
1352
  },
1226
1353
  };
1227
1354
  }