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.
- package/package.json +2 -1
- package/scripts/agent-tag-reuse-smoke.mjs +124 -10
- package/scripts/anthropic-oauth-refresh-race-test.mjs +397 -0
- package/scripts/arg-guard-test.mjs +16 -0
- package/scripts/compact-pressure-test.mjs +383 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/internal-comms-bench.mjs +0 -1
- package/scripts/internal-comms-smoke.mjs +54 -31
- package/scripts/internal-tools-normalization-test.mjs +52 -0
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +271 -0
- package/scripts/mcp-client-normalization-test.mjs +45 -0
- package/scripts/provider-toolcall-test.mjs +55 -0
- package/scripts/result-classification-test.mjs +75 -0
- package/scripts/smoke.mjs +1 -1
- package/scripts/submit-commandbusy-race-test.mjs +99 -7
- package/scripts/tui-transcript-perf-test.mjs +56 -1
- package/src/agents/reviewer/AGENT.md +8 -0
- package/src/rules/lead/lead-brief.md +11 -3
- package/src/rules/lead/lead-tool.md +0 -2
- package/src/rules/shared/01-tool.md +4 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
- package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +193 -13
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +72 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +144 -84
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +124 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -7
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +28 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/runtime/memory/tool-defs.mjs +4 -3
- package/src/runtime/shared/tool-surface.mjs +1 -2
- package/src/session-runtime/context-status.mjs +8 -2
- package/src/standalone/agent-tool/render.mjs +2 -0
- package/src/standalone/agent-tool.mjs +202 -22
- package/src/tui/components/StatusLine.jsx +4 -26
- package/src/tui/dist/index.mjs +46 -31
- package/src/tui/engine/session-api.mjs +3 -0
- package/src/tui/engine/session-flow.mjs +19 -8
- package/src/tui/engine/turn.mjs +24 -2
- package/src/tui/engine.mjs +0 -1
- package/src/ui/statusline-agents.mjs +0 -8
- package/src/ui/statusline.mjs +2 -4
- package/src/workflows/default/WORKFLOW.md +38 -24
- package/src/workflows/solo-review/WORKFLOW.md +47 -0
- 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
|
-
|
|
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
|
-
//
|
|
521
|
-
//
|
|
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
|
-
+ `
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
447
|
-
// the boundary
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
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 (
|
|
580
|
-
|
|
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,
|
|
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 (
|
|
657
|
+
if (cached?.signature === signature) return cached.value;
|
|
598
658
|
const reserve = estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
|
|
599
|
-
requestReserveTokenMemo.set(tools,
|
|
659
|
+
requestReserveTokenMemo.set(tools, { signature, value: reserve });
|
|
600
660
|
return reserve;
|
|
601
661
|
}
|
|
602
662
|
|