mixdog 0.9.40 → 0.9.43

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.
Files changed (66) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +397 -0
  4. package/scripts/arg-guard-test.mjs +16 -0
  5. package/scripts/compact-pressure-test.mjs +383 -0
  6. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  7. package/scripts/internal-comms-bench.mjs +0 -1
  8. package/scripts/internal-comms-smoke.mjs +54 -31
  9. package/scripts/internal-tools-normalization-test.mjs +52 -0
  10. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  11. package/scripts/max-output-recovery-test.mjs +271 -0
  12. package/scripts/mcp-client-normalization-test.mjs +45 -0
  13. package/scripts/provider-toolcall-test.mjs +55 -0
  14. package/scripts/result-classification-test.mjs +75 -0
  15. package/scripts/smoke.mjs +1 -1
  16. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  17. package/scripts/tui-transcript-perf-test.mjs +56 -1
  18. package/src/agents/reviewer/AGENT.md +8 -0
  19. package/src/rules/lead/lead-brief.md +11 -3
  20. package/src/rules/lead/lead-tool.md +0 -2
  21. package/src/rules/shared/01-tool.md +4 -0
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  23. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +193 -13
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  30. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  32. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +72 -12
  33. package/src/runtime/agent/orchestrator/session/context-utils.mjs +144 -84
  34. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +124 -8
  35. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -7
  37. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  38. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +28 -1
  39. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  41. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  44. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  45. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  46. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
  48. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  51. package/src/runtime/memory/tool-defs.mjs +4 -3
  52. package/src/runtime/shared/tool-surface.mjs +1 -2
  53. package/src/session-runtime/context-status.mjs +8 -2
  54. package/src/standalone/agent-tool/render.mjs +2 -0
  55. package/src/standalone/agent-tool.mjs +202 -22
  56. package/src/tui/components/StatusLine.jsx +4 -26
  57. package/src/tui/dist/index.mjs +46 -31
  58. package/src/tui/engine/session-api.mjs +3 -0
  59. package/src/tui/engine/session-flow.mjs +19 -8
  60. package/src/tui/engine/turn.mjs +24 -2
  61. package/src/tui/engine.mjs +0 -1
  62. package/src/ui/statusline-agents.mjs +0 -8
  63. package/src/ui/statusline.mjs +2 -4
  64. package/src/workflows/default/WORKFLOW.md +38 -24
  65. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  66. package/src/workflows/bench/WORKFLOW.md +0 -36
@@ -83,7 +83,7 @@ import {
83
83
  restoreToolCallBodyForId,
84
84
  } from './loop/stored-tool-args.mjs';
85
85
  import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
86
- import { classifyTerminationReason, INCOMPLETE_STOP_REASONS } from './loop/termination.mjs';
86
+ import { classifyTerminationReason, INCOMPLETE_STOP_REASONS, isOutputLimitStopReason } from './loop/termination.mjs';
87
87
  import { createSteeringLadder } from './loop/steering-ladder.mjs';
88
88
  import { runPreSendCompactPass } from './pre-send-compact.mjs';
89
89
  import { createEagerDispatcher } from './eager-dispatch.mjs';
@@ -116,6 +116,11 @@ export {
116
116
  // this catches tight deterministic-failure loops (e.g. a command that errors
117
117
  // the same way every time) far earlier than 100 iterations.
118
118
  const REPEAT_FAIL_LIMIT = 3;
119
+ // A provider max-output stop is not a completed assistant turn, even when it
120
+ // contains useful text. Preserve each partial in the provider transcript and
121
+ // grant at most three direct continuations before surfacing a hard truncation.
122
+ const MAX_OUTPUT_RECOVERY_LIMIT = 3;
123
+ const MAX_OUTPUT_EXHAUSTED_NOTICE = '[mixdog-runtime] Output remained truncated after 3 continuation attempts.';
119
124
  // _scopedCacheOutcomeForCall and executeTool moved to ./loop/tool-exec.mjs
120
125
  // (imported above).
121
126
  /**
@@ -140,6 +145,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
140
145
  let lastUsage;
141
146
  let firstTurnUsage;
142
147
  let response;
148
+ let lastSendTools = tools;
143
149
  let contextOverflowRetryUsed = false;
144
150
  // Set when the hard iteration-cap break below fires. Consumed at the final
145
151
  // return to tag terminationReason='iteration_cap' so a worker that exhausts
@@ -233,10 +239,16 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
233
239
  const reasoningItems = Array.isArray(resp.reasoningItems) && resp.reasoningItems.length
234
240
  ? resp.reasoningItems
235
241
  : null;
236
- if (!content && !reasoningContent && !reasoningItems) return false;
242
+ const thinkingBlocks = Array.isArray(resp.thinkingBlocks) && resp.thinkingBlocks.length
243
+ ? resp.thinkingBlocks
244
+ : null;
245
+ if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks) return false;
237
246
  messages.push({
238
247
  role: 'assistant',
239
248
  content,
249
+ // Anthropic adaptive-thinking signatures must be replayed verbatim
250
+ // before the continuation turn, just like tool-call trajectories.
251
+ ...(thinkingBlocks ? { thinkingBlocks } : {}),
240
252
  ...(reasoningItems ? { reasoningItems } : {}),
241
253
  ...(reasoningContent ? { reasoningContent } : {}),
242
254
  });
@@ -270,6 +282,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
270
282
  // the loop as an explicit empty termination instead.
271
283
  let _emptyNudgeStreak = 0;
272
284
  const EMPTY_NUDGE_MAX = 3;
285
+ let _refusalRetryUsed = false;
286
+ let _maxOutputRecoveryCount = 0;
287
+ const _maxOutputContentParts = [];
273
288
  // Claude Code parity: queued prompt/task notifications are attached after a
274
289
  // tool batch, before the continuation provider send. Normal batches drain
275
290
  // up to 'next'; a Sleep-like tool grants a 'later' flush.
@@ -422,6 +437,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
422
437
  const sendTools = _capFinalToolsDisabled
423
438
  ? tools
424
439
  : (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
440
+ lastSendTools = sendTools;
425
441
  // Eager-dispatch queue: when the provider streams a tool-call event,
426
442
  // start read-only tools immediately so execution overlaps with the
427
443
  // remaining SSE parse. Writes and unknown tools wait until send()
@@ -517,22 +533,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
517
533
  // Provider may have returned despite an abort (SDKs that don't honour
518
534
  // signal) — bail before processing any of its output.
519
535
  throwIfAborted();
520
- // P1 audit fix (Step4): a text-only turn truncated by the provider's
521
- // max-output limit (response.truncated, set by the provider layer
522
- // when stopReason==='length' AND content is non-empty) used to look
523
- // identical to a clean completion — the model's answer could be
524
- // silently cut mid-sentence with zero signal to the operator. Surface
525
- // it as a one-line stderr warning + trace event WITHOUT failing the
526
- // turn (the partial content is still usable and the loop's own
527
- // isIncompleteStop nudge below already re-prompts when content is
528
- // empty).
536
+ // Keep a diagnostic for every provider-declared truncation. Eligible
537
+ // no-tool text turns are recovered below rather than accepted as final.
529
538
  if (response?.truncated === true) {
530
539
  try {
531
540
  process.stderr.write(
532
541
  `[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
533
542
  + `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
534
543
  + `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
535
- + `answer may be cut off mid-sentence.\n`,
544
+ + `continuation recovery will be attempted when eligible.\n`,
536
545
  );
537
546
  } catch { /* best-effort */ }
538
547
  try {
@@ -566,6 +575,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
566
575
  // additive — callers that ignore these fields keep working.
567
576
  deltaCachedRead: response.usage.cachedTokens || 0,
568
577
  deltaCacheWrite: response.usage.cacheWriteTokens || 0,
578
+ sendTools,
569
579
  ts: Date.now(),
570
580
  });
571
581
  } catch { /* best-effort — never break the loop */ }
@@ -600,6 +610,46 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
600
610
  const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
601
611
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
602
612
  const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
613
+ const isOutputLimitStop = isOutputLimitStopReason(stopReason);
614
+ if (hasContent && isOutputLimitStop) {
615
+ _maxOutputContentParts.push(response.content);
616
+ if (_maxOutputRecoveryCount < MAX_OUTPUT_RECOVERY_LIMIT) {
617
+ // The partial assistant turn must be visible to the model so
618
+ // it can resume at the exact cutoff instead of reconstructing
619
+ // or repeating it. askSession persists this natural recovery
620
+ // chain; historyContent below prevents the aggregate returned
621
+ // to callers from being persisted a second time.
622
+ pushIntermediateAssistantResponse(response);
623
+ _maxOutputRecoveryCount += 1;
624
+ messages.push({
625
+ role: 'user',
626
+ content: 'Output token limit hit. Resume directly — no apology, no recap. Pick up exactly where the previous text stopped.',
627
+ meta: { source: 'max-output-recovery', attempt: _maxOutputRecoveryCount },
628
+ });
629
+ continue;
630
+ }
631
+ const terminalSegment = `${response.content}\n\n${MAX_OUTPUT_EXHAUSTED_NOTICE}`;
632
+ response = {
633
+ ...response,
634
+ content: `${_maxOutputContentParts.slice(0, -1).join('')}${terminalSegment}`,
635
+ historyContent: terminalSegment,
636
+ maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
637
+ };
638
+ break;
639
+ }
640
+ if (!hasContent && stopReason === 'refusal') {
641
+ if (_refusalRetryUsed) {
642
+ process.stderr.write(`[loop] safety-classifier refusal persisted after one context-changing retry (sess=${sessionId || 'unknown'}); ending loop as refusal termination.\n`);
643
+ break;
644
+ }
645
+ _refusalRetryUsed = true;
646
+ messages.push({
647
+ role: 'user',
648
+ content: '[mixdog-runtime] The previous completion was refused by the provider safety classifier (stopReason=refusal). Do not repeat it. Complete your assigned output within policy by omitting or reframing disallowed content; if no compliant output is possible, briefly state the refusal.',
649
+ meta: { source: 'refusal-recovery', attempt: 1 },
650
+ });
651
+ continue;
652
+ }
603
653
  if (!hasContent && !isHidden) {
604
654
  _emptyNudgeStreak += 1;
605
655
  if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
@@ -622,6 +672,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
622
672
  messages.push({ role: 'user', content: nudgeMsg });
623
673
  continue;
624
674
  }
675
+ if (_maxOutputContentParts.length > 0) {
676
+ const terminalSegment = typeof response.content === 'string' ? response.content : '';
677
+ response = {
678
+ ...response,
679
+ content: `${_maxOutputContentParts.join('')}${terminalSegment}`,
680
+ historyContent: terminalSegment,
681
+ maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
682
+ };
683
+ }
625
684
  break;
626
685
  }
627
686
  _emptyNudgeStreak = 0;
@@ -709,6 +768,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
709
768
  ...response,
710
769
  usage: lastUsage || response.usage,
711
770
  lastTurnUsage: response.usage,
771
+ lastSendTools,
712
772
  firstTurnUsage: firstTurnUsage || response.usage,
713
773
  iterations,
714
774
  toolCallsTotal,
@@ -1,6 +1,7 @@
1
1
  import { isOffloadedToolResultText } from './tool-result-offload.mjs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { isAgentOwner } from '../agent-owner.mjs';
4
+ import { contentImageDescriptors, contentToText } from '../providers/media-normalization.mjs';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Conservative, Unicode-aware token estimator.
@@ -26,6 +27,8 @@ function readSafetyMultiplier() {
26
27
  return 1.1;
27
28
  }
28
29
  const TOKEN_ESTIMATE_SAFETY_MULTIPLIER = readSafetyMultiplier();
30
+ export const IMAGE_VISUAL_TOKEN_ALLOWANCE = 4_096;
31
+ const IMAGE_TILE_TOKEN_ALLOWANCE = 512;
29
32
 
30
33
  // Per-code-point token-cost weight. Tuned to overcount, not match exactly.
31
34
  function codePointTokenWeight(cp) {
@@ -72,12 +75,59 @@ export function estimateTokens(text) {
72
75
  if (s.length === 0) return 0;
73
76
  let weighted = 0;
74
77
  for (const ch of s) weighted += codePointTokenWeight(ch.codePointAt(0));
78
+ // Encoded blobs, minified JSON and generated identifiers do not get the
79
+ // word/whitespace merges that make prose approach chars/4. Long printable
80
+ // ASCII runs are commonly 0.5-0.8 tokens/byte; retain a conservative floor
81
+ // for those runs without penalizing ordinary spaced prose.
82
+ let denseAsciiFloor = 0;
83
+ for (const match of s.matchAll(/[\x21-\x7e]{16,}/g)) {
84
+ denseAsciiFloor += match[0].length * 0.75;
85
+ }
86
+ const encodedWords = s.match(/\b(?=[A-Za-z0-9]{8,}\b)(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]+\b/g) || [];
87
+ if (encodedWords.length >= 3) {
88
+ // Encoded/generated identifiers are often wrapped at short columns or
89
+ // separated by spaces. Their individual runs can stay below the long-
90
+ // run threshold while still receiving almost no prose-style BPE merges.
91
+ const encodedChars = encodedWords.reduce((sum, word) => sum + word.length, 0);
92
+ denseAsciiFloor = Math.max(
93
+ denseAsciiFloor,
94
+ (encodedChars * 0.75) + ((s.length - encodedChars) * 0.25),
95
+ );
96
+ }
97
+ const lines = s.split(/\r?\n/).filter(line => line.trim());
98
+ const nonWhitespace = s.match(/\S/g)?.length || 0;
99
+ const structural = s.match(/[\[\]{}":,=<>|\\]/g)?.length || 0;
100
+ const jsonLikeLines = lines.filter(line => /^\s*[\[{].*[\]}],?\s*$/.test(line)).length;
101
+ if (lines.length >= 3 && nonWhitespace > 0
102
+ && (jsonLikeLines >= Math.ceil(lines.length / 2) || structural / nonWhitespace >= 0.12)) {
103
+ // JSONL, compact tables and generated line protocols can consist
104
+ // entirely of short runs while still tokenizing like minified data.
105
+ denseAsciiFloor = Math.max(denseAsciiFloor, (nonWhitespace * 0.7) + ((s.length - nonWhitespace) * 0.25));
106
+ }
75
107
  const asciiFloor = s.length / 4; // never below the legacy chars/4 lower bound
76
- return Math.ceil(Math.max(weighted, asciiFloor) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
108
+ return Math.ceil(Math.max(weighted, asciiFloor, denseAsciiFloor) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
109
+ }
110
+
111
+ function nativeBlocksEstimateText(value) {
112
+ const list = Array.isArray(value) ? value : [value];
113
+ return list.map((block) => {
114
+ const images = contentImageDescriptors(block);
115
+ if (images.length) {
116
+ return JSON.stringify(images.map(({ width, height, detail }) => ({
117
+ type: 'image', width, height, detail,
118
+ })));
119
+ }
120
+ try { return JSON.stringify(block); }
121
+ catch { return String(block ?? ''); }
122
+ }).join('\n');
77
123
  }
78
124
  export function messageEstimateText(m) {
79
125
  if (!m || typeof m !== 'object') return '';
80
- let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '');
126
+ // Multimodal image payloads remain on the live message for provider sends,
127
+ // but their base64/data-url JSON is not text and must not dominate local
128
+ // context estimates. Use the same media-aware text projection for every
129
+ // estimate consumer (live gauge, compaction fallback, and summaries).
130
+ let text = contentToText(m.content, '');
81
131
  if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
82
132
  try { text += `\n${JSON.stringify(m.toolCalls)}`; }
83
133
  catch { text += `\n[${m.toolCalls.length} tool calls]`; }
@@ -86,14 +136,47 @@ export function messageEstimateText(m) {
86
136
  // signature / redacted data) and are re-sent on tool-continuation turns, so
87
137
  // they consume real input tokens. Count them or trim/compact undercounts.
88
138
  if (m.role === 'assistant' && Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length) {
89
- try { text += `\n${JSON.stringify(m.thinkingBlocks)}`; }
90
- catch { text += `\n[${m.thinkingBlocks.length} thinking blocks]`; }
139
+ text += `\n${nativeBlocksEstimateText(m.thinkingBlocks)}`;
140
+ }
141
+ // Some provider adapters replay their native assistant representation
142
+ // instead of content/toolCalls. Project it through the media normalizer so
143
+ // text/tool metadata and opaque reasoning are counted without base64 image
144
+ // bytes dominating the estimate.
145
+ if (m.role === 'assistant' && Array.isArray(m.assistantBlocks) && m.assistantBlocks.length) {
146
+ text += `\n${nativeBlocksEstimateText(m.assistantBlocks)}`;
147
+ }
148
+ if (m.role === 'assistant' && Array.isArray(m.reasoningItems) && m.reasoningItems.length) {
149
+ text += `\n${nativeBlocksEstimateText(m.reasoningItems)}`;
91
150
  }
92
151
  if (m.role === 'tool' && m.toolCallId) text += `\n${m.toolCallId}`;
93
152
  return text;
94
153
  }
154
+ function imageDescriptorAllowance(descriptor) {
155
+ if (descriptor.width && descriptor.height) {
156
+ const tiles = Math.ceil(descriptor.width / 512) * Math.ceil(descriptor.height / 512);
157
+ // Caller-supplied dimensions/detail are not uniformly preserved by all
158
+ // provider normalizers, so they may raise the allowance for a known
159
+ // multi-tile image but can never lower the conservative unknown-image
160
+ // fallback. This avoids trusting metadata the provider never sees.
161
+ return Math.max(IMAGE_VISUAL_TOKEN_ALLOWANCE, IMAGE_TILE_TOKEN_ALLOWANCE * (tiles + 1));
162
+ }
163
+ // Unknown-size auto/high images may be tiled by the provider. A single
164
+ // 1k allowance is optimistic for common screenshots and documents.
165
+ return IMAGE_VISUAL_TOKEN_ALLOWANCE;
166
+ }
167
+ function messageImageDescriptors(m) {
168
+ if (!m || typeof m !== 'object') return [];
169
+ return [
170
+ ...contentImageDescriptors(m.content),
171
+ ...(m.role === 'assistant' ? contentImageDescriptors(m.assistantBlocks) : []),
172
+ ];
173
+ }
174
+ function messageImageAllowance(m) {
175
+ if (!m || typeof m !== 'object') return 0;
176
+ return messageImageDescriptors(m).reduce((sum, descriptor) => sum + imageDescriptorAllowance(descriptor), 0);
177
+ }
95
178
  export function estimateMessageTokens(m) {
96
- return estimateTokens(messageEstimateText(m)) + 4;
179
+ return estimateTokens(messageEstimateText(m)) + messageImageAllowance(m) + 4;
97
180
  }
98
181
  export function estimateMessagesTokens(messages) {
99
182
  return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
@@ -112,28 +195,11 @@ const contextMessageMemo = new WeakMap();
112
195
  const contextTranscriptMemo = new WeakMap();
113
196
 
114
197
  function contextValueFingerprint(value) {
115
- if (typeof value === 'string') return { value, entries: null };
116
- if (Array.isArray(value)) return {
117
- value,
118
- entries: value.map(contextBlockFingerprint),
119
- };
120
- return value && typeof value === 'object'
121
- ? { value, entries: [contextBlockFingerprint(value)] }
122
- : { value, entries: null };
123
- }
124
-
125
- function contextBlockFingerprint(block) {
126
- if (!block || typeof block !== 'object') return { value: block };
127
- const fn = block.function;
128
198
  return {
129
- value: block,
130
- text: typeof block.text === 'string' ? block.text : null,
131
- content: typeof block.content === 'string' ? block.content : null,
132
- args: typeof block.args === 'string' ? block.args : null,
133
- arguments: block.arguments,
134
- input: typeof block.input === 'string' ? block.input : null,
135
- function: fn,
136
- functionArguments: typeof fn?.arguments === 'string' ? fn.arguments : null,
199
+ value,
200
+ snapshot: typeof value === 'string'
201
+ ? value
202
+ : `${nativeBlocksEstimateText(value)}\0${JSON.stringify(contentImageDescriptors(value))}`,
137
203
  };
138
204
  }
139
205
 
@@ -145,6 +211,7 @@ function contextMessageFingerprint(message) {
145
211
  toolCalls: contextValueFingerprint(null),
146
212
  thinkingBlocks: contextValueFingerprint(null),
147
213
  assistantBlocks: contextValueFingerprint(null),
214
+ reasoningItems: contextValueFingerprint(null),
148
215
  toolCallId: null,
149
216
  };
150
217
  }
@@ -154,30 +221,13 @@ function contextMessageFingerprint(message) {
154
221
  toolCalls: contextValueFingerprint(message.toolCalls),
155
222
  thinkingBlocks: contextValueFingerprint(message.thinkingBlocks),
156
223
  assistantBlocks: contextValueFingerprint(message.assistantBlocks),
224
+ reasoningItems: contextValueFingerprint(message.reasoningItems),
157
225
  toolCallId: message?.toolCallId || null,
158
226
  };
159
227
  }
160
228
 
161
229
  function sameContextValueFingerprint(a, b) {
162
- if (!a || !b || a.value !== b.value) return false;
163
- if (a.entries === null || b.entries === null) return a.entries === b.entries;
164
- if (a.entries.length !== b.entries.length) return false;
165
- for (let index = 0; index < a.entries.length; index += 1) {
166
- if (!sameContextBlockFingerprint(a.entries[index], b.entries[index])) return false;
167
- }
168
- return true;
169
- }
170
-
171
- function sameContextBlockFingerprint(a, b) {
172
- return !!a && !!b
173
- && a.value === b.value
174
- && a.text === b.text
175
- && a.content === b.content
176
- && a.args === b.args
177
- && Object.is(a.arguments, b.arguments)
178
- && a.input === b.input
179
- && a.function === b.function
180
- && a.functionArguments === b.functionArguments;
230
+ return !!a && !!b && a.value === b.value && a.snapshot === b.snapshot;
181
231
  }
182
232
 
183
233
  function sameContextMessageFingerprint(a, b) {
@@ -186,6 +236,7 @@ function sameContextMessageFingerprint(a, b) {
186
236
  && sameContextValueFingerprint(a.toolCalls, b.toolCalls)
187
237
  && sameContextValueFingerprint(a.thinkingBlocks, b.thinkingBlocks)
188
238
  && sameContextValueFingerprint(a.assistantBlocks, b.assistantBlocks)
239
+ && sameContextValueFingerprint(a.reasoningItems, b.reasoningItems)
189
240
  && a.toolCallId === b.toolCallId;
190
241
  }
191
242
 
@@ -197,7 +248,7 @@ function contextMessageContribution(message) {
197
248
  }
198
249
  const role = ['system', 'user', 'assistant', 'tool'].includes(fingerprint.role) ? fingerprint.role : 'other';
199
250
  const text = messageEstimateText(message);
200
- const tokens = estimateTokens(text) + 4;
251
+ const tokens = estimateTokens(text) + messageImageAllowance(message) + 4;
201
252
  const contribution = {
202
253
  role,
203
254
  tokens,
@@ -307,6 +358,7 @@ function contextSummaryResult(state, count) {
307
358
 
308
359
  export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
309
360
  export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
361
+ export const MAIN_COMPACTION_TRIGGER_RATIO = 0.95;
310
362
  export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
311
363
  export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
312
364
  const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
@@ -443,8 +495,8 @@ export function resolveCompactTriggerTokens(sessionOrConfig = {}, boundaryTokens
443
495
  // (trigger = limit) for every session type;
444
496
  // - agent-owned semantic sessions otherwise keep the default early-trigger
445
497
  // buffer (config-driven, default 10% -> compact at 90% of the boundary);
446
- // - main/user recall-fasttrack sessions have NO default buffer and compact on
447
- // the boundary itself (100%).
498
+ // - main/user recall-fasttrack sessions keep 5% headroom and compact at 95%
499
+ // of the effective boundary.
448
500
  // Returns the sanitized explicit limit (null when absent/legacy full-window)
449
501
  // plus triggerTokens / bufferTokens / bufferRatio for the given boundary.
450
502
  export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens = 0) {
@@ -466,7 +518,7 @@ export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens
466
518
  } else if (isAgentOwner(sessionOrConfig)) {
467
519
  triggerTokens = Math.max(1, boundary - resolveCompactBufferTokens(boundary, cfg));
468
520
  } else {
469
- triggerTokens = boundary;
521
+ triggerTokens = Math.max(1, Math.floor(boundary * MAIN_COMPACTION_TRIGGER_RATIO));
470
522
  }
471
523
  const bufferTokens = Math.max(0, boundary - triggerTokens);
472
524
  const bufferRatio = bufferTokens / boundary;
@@ -510,7 +562,7 @@ export function summarizeContextMessages(messages) {
510
562
  if (!Array.isArray(messages)) return contextSummaryResult(emptyContextSummaryState(), 0);
511
563
  let cached = contextTranscriptMemo.get(messages);
512
564
  if (!cached || messages.length < cached.count) {
513
- cached = { count: 0, contributions: [], state: emptyContextSummaryState() };
565
+ cached = { count: 0, contributions: [], state: emptyContextSummaryState(), revision: 0 };
514
566
  contextTranscriptMemo.set(messages, cached);
515
567
  }
516
568
  for (let index = 0; index < messages.length; index += 1) {
@@ -520,12 +572,42 @@ export function summarizeContextMessages(messages) {
520
572
  if (previous) applyContextMessageContribution(cached.state, previous, -1);
521
573
  cached.contributions[index] = contribution;
522
574
  applyContextMessageContribution(cached.state, contribution, 1);
575
+ cached.revision += 1;
523
576
  }
524
577
  cached.contributions.length = messages.length;
525
578
  cached.count = messages.length;
526
579
  return contextSummaryResult(cached.state, messages.length);
527
580
  }
528
581
 
582
+ // A stable warm-cache generation for consumers that cache a derived view of
583
+ // the whole transcript. summarizeContextMessages() must run first so mutations
584
+ // to any entry, not merely the tail, advance the generation.
585
+ export function contextMessagesRevision(messages) {
586
+ if (!Array.isArray(messages)) return 0;
587
+ summarizeContextMessages(messages);
588
+ return contextTranscriptMemo.get(messages)?.revision || 0;
589
+ }
590
+
591
+ // Hash only estimator/provider-visible projections. In particular, images
592
+ // contribute their visual count but never their raw data-url/base64 bytes.
593
+ export function contextMessagesSignature(messages, count = messages?.length) {
594
+ const list = Array.isArray(messages) ? messages : [];
595
+ const end = Math.max(0, Math.min(list.length, Number.isInteger(count) ? count : list.length));
596
+ const hash = createHash('sha256');
597
+ for (let index = 0; index < end; index += 1) {
598
+ const message = list[index];
599
+ hash.update(JSON.stringify([
600
+ message?.role || '',
601
+ message?.toolCallId || '',
602
+ messageEstimateText(message),
603
+ messageImageAllowance(message),
604
+ messageImageDescriptors(message),
605
+ ]));
606
+ hash.update('\0');
607
+ }
608
+ return hash.digest('hex');
609
+ }
610
+
529
611
  // Per-request overhead the provider injects that never appears in the
530
612
  // `messages` array: function-calling preamble + system-prompt framing the
531
613
  // provider wraps around the request. The chars/4 message estimate misses all
@@ -534,35 +616,13 @@ const REQUEST_OVERHEAD_TOKENS = 512;
534
616
  const toolSchemaTokenMemo = new WeakMap();
535
617
  const requestReserveTokenMemo = new WeakMap();
536
618
 
537
- function sameToolArrayEntries(cached, tools) {
538
- if (!cached || cached.entries.length !== tools.length) return false;
539
- for (let index = 0; index < tools.length; index += 1) {
540
- const entry = cached.entries[index];
541
- const tool = tools[index];
542
- if (entry.tool !== tool
543
- || entry.name !== tool?.name
544
- || entry.description !== tool?.description
545
- || entry.inputSchema !== tool?.inputSchema
546
- || entry.input_schema !== tool?.input_schema
547
- || entry.parameters !== tool?.parameters
548
- || entry.schema !== tool?.schema) return false;
549
- }
550
- return true;
619
+ function serializeToolSchemas(tools) {
620
+ try { return JSON.stringify(Array.isArray(tools) ? tools : []); }
621
+ catch { return (Array.isArray(tools) ? tools : []).map(t => String(t?.name ?? '')).join(''); }
551
622
  }
552
623
 
553
- function cacheToolArrayValue(tools, value) {
554
- return {
555
- entries: tools.map((tool) => ({
556
- tool,
557
- name: tool?.name,
558
- description: tool?.description,
559
- inputSchema: tool?.inputSchema,
560
- input_schema: tool?.input_schema,
561
- parameters: tool?.parameters,
562
- schema: tool?.schema,
563
- })),
564
- value,
565
- };
624
+ export function toolSchemaSignature(tools) {
625
+ return createHash('sha256').update(serializeToolSchemas(tools)).digest('hex');
566
626
  }
567
627
 
568
628
  /**
@@ -575,13 +635,12 @@ function cacheToolArrayValue(tools, value) {
575
635
  */
576
636
  export function estimateToolSchemaTokens(tools) {
577
637
  if (!Array.isArray(tools) || tools.length === 0) return 0;
638
+ const signature = toolSchemaSignature(tools);
578
639
  const cached = toolSchemaTokenMemo.get(tools);
579
- if (sameToolArrayEntries(cached, tools)) return cached.value;
580
- let text = '';
581
- try { text = JSON.stringify(tools); }
582
- catch { text = tools.map(t => String(t?.name ?? '')).join(''); }
640
+ if (cached?.signature === signature) return cached.value;
641
+ const text = serializeToolSchemas(tools);
583
642
  const tokens = estimateTokens(text);
584
- toolSchemaTokenMemo.set(tools, cacheToolArrayValue(tools, tokens));
643
+ toolSchemaTokenMemo.set(tools, { signature, value: tokens });
585
644
  return tokens;
586
645
  }
587
646
 
@@ -593,10 +652,11 @@ export function estimateToolSchemaTokens(tools) {
593
652
  */
594
653
  export function estimateRequestReserveTokens(tools) {
595
654
  if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
655
+ const signature = toolSchemaSignature(tools);
596
656
  const cached = requestReserveTokenMemo.get(tools);
597
- if (sameToolArrayEntries(cached, tools)) return cached.value;
657
+ if (cached?.signature === signature) return cached.value;
598
658
  const reserve = estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
599
- requestReserveTokenMemo.set(tools, cacheToolArrayValue(tools, reserve));
659
+ requestReserveTokenMemo.set(tools, { signature, value: reserve });
600
660
  return reserve;
601
661
  }
602
662