diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fedd9d..10882fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ Cutting that release is tracked in ### Changed +- Context-budget pruning now measures each candidate once, removes oversized + entries regardless of array position, and byte-budgets text fallback. - Removed stale screenshot and JSON conversion paths, honored per-operation Linux evaluation timeouts, and stopped advertising the compatibility-only `--json` flag. diff --git a/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift b/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift index 67199f5..f3c5560 100644 --- a/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift +++ b/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift @@ -371,31 +371,66 @@ if (!globalThis.__headlessAgent) { budget, budgetApplied: Boolean(budget) }; - for (let pass = 0; pass < 2; pass += 1) { - const bytes = encodedBytes(result); - result.contextStats.encodedBytes = bytes; - result.contextStats.estimatedTokens = Math.ceil(bytes / 4); + const baseBytes = encodedBytes(result); + let bytes = baseBytes; + let estimatedTokens = Math.ceil(bytes / 4); + while (true) { + const measured = baseBytes + String(bytes).length - 1 + String(estimatedTokens).length - 1; + const measuredTokens = Math.ceil(measured / 4); + if (measured === bytes && measuredTokens === estimatedTokens) break; + bytes = measured; + estimatedTokens = measuredTokens; } + result.contextStats.encodedBytes = bytes; + result.contextStats.estimatedTokens = estimatedTokens; + return bytes; }; - refresh(); - if (budget) { - const maximumBytes = budget * 4; - while (encodedBytes(result) > maximumBytes) { - const populated = arrays.filter(key => result[key].length > 0); - if (populated.length === 0) { - if (typeof result.text === 'string' && result.text.length > 0) { - result.text = result.text.slice(0, Math.max(0, result.text.length - 256)); - refresh(); - continue; - } - break; - } - populated.sort((left, right) => encodedBytes(result[right][result[right].length - 1]) - encodedBytes(result[left][result[left].length - 1])); - result[populated[0]].pop(); - refresh(); + let currentBytes = refresh(); + if (!budget) return result; + + const maximumBytes = budget * 4; + if (currentBytes > maximumBytes) { + const candidates = arrays.flatMap(key => result[key].map((value, index) => ({ + key, index, bytes: encodedBytes(value) + (result[key].length > 1 ? 1 : 0) + }))).sort((left, right) => right.bytes - left.bytes); + const removed = new Map(arrays.map(key => [key, new Set()])); + // Reserve a small allowance for omitted-count digit growth. Each item + // is encoded once; removal then preserves the order of retained items. + const targetBytes = Math.max(0, maximumBytes - 32); + for (const candidate of candidates) { + if (currentBytes <= targetBytes) break; + removed.get(candidate.key).add(candidate.index); + currentBytes -= candidate.bytes; + } + for (const key of arrays) { + const indexes = removed.get(key); + if (indexes.size > 0) result[key] = result[key].filter((_, index) => !indexes.has(index)); } + currentBytes = refresh(); + } + + if (currentBytes > maximumBytes && typeof result.text === 'string' && result.text.length > 0) { + const textBytes = Math.max(0, encodedBytes(result.text) - 2); + const targetTextBytes = Math.max(0, textBytes - (currentBytes - maximumBytes) - 32); + let lower = 0; + let upper = result.text.length; + while (lower < upper) { + const middle = Math.ceil((lower + upper) / 2); + const prefixBytes = Math.max(0, encodedBytes(result.text.slice(0, middle)) - 2); + if (prefixBytes <= targetTextBytes) lower = middle; + else upper = middle - 1; + } + result.text = result.text.slice(0, lower); + currentBytes = refresh(); + } + + // Metadata is deliberately bounded, but keep the budget fail-closed if + // its final digit widths ever outgrow the allowance above. + if (currentBytes > maximumBytes) { + for (const key of arrays) result[key] = []; + if (typeof result.text === 'string') result.text = ''; + refresh(); } - refresh(); return result; }; const snapshot = (interactiveOnly, includeText, options = {}) => { diff --git a/apps/headless/Tests/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index 2064416..af92ec8 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -99,6 +99,44 @@ const scopedActions = agent.snapshot(false, false, { assert(scopedActions.elements.some(element => element.name === 'Copy authentication command')); assert(scopedActions.elements.every(element => element.actions.length > 0)); +// Budget pruning measures every candidate once and removes the largest item +// wherever it appears, without discarding smaller entries that follow it. +const pruningFixture = window.document.createElement('section'); +pruningFixture.innerHTML = ` +

Budget prefix should survive.

+

${'🚧'.repeat(300)}

+

Budget suffix should survive.

+`; +for (const [index, paragraph] of [...pruningFixture.querySelectorAll('p')].entries()) { + paragraph.getBoundingClientRect = () => ({ + x: 24, y: 100 + index * 50, top: 100 + index * 50, left: 24, + right: 824, bottom: 148 + index * 50, width: 800, height: 48, + }); +} +window.document.getElementById('content').prepend(pruningFixture); +let encodeCalls = 0; +const NativeTextEncoder = window.TextEncoder; +window.TextEncoder = class CountingTextEncoder extends NativeTextEncoder { + encode(value) { + encodeCalls += 1; + return super.encode(value); + } +}; +const middlePruned = agent.snapshot(false, false, {context: 'text', limit: 4, budget: 256}); +window.TextEncoder = NativeTextEncoder; +assert( + middlePruned.snippets.some(item => item.text === 'Budget suffix should survive.'), + JSON.stringify(middlePruned), +); +assert(!middlePruned.snippets.some(item => item.text.includes('🚧'))); +assert(encodeCalls <= 8, 'budget pruning should encode the result and each candidate only once'); +assert(middlePruned.contextStats.encodedBytes <= 1024); +assert.equal( + new TextEncoder().encode(JSON.stringify(middlePruned)).length, + middlePruned.contextStats.encodedBytes, + 'reported bytes should include the finalized context statistics', +); + assert.throws( () => agent.snapshot(false, false, {context: 'text', within: '@r999999'}), error => error.headlessCode === 'REGION_NOT_FOUND' && /REGION_NOT_FOUND/.test(error.message), @@ -256,6 +294,10 @@ const budgetedText = agent.snapshot(false, true, {context: 'full', limit: 1, bud assert(budgetedText.contextStats.encodedBytes <= 1024); assert(budgetedText.text.length < 30000, 'text fallback should be shortened to fit the budget'); assert.equal(budgetedText.contextStats.budgetApplied, true); +assert.equal( + new TextEncoder().encode(JSON.stringify(budgetedText)).length, + budgetedText.contextStats.encodedBytes, +); console.log(JSON.stringify({ selectedRegion: targetRegion.ref, diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 12718d8..cd602bc 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -191,11 +191,14 @@ Foundation conversion paths, made both hosts share `JSONValue.foundationValue`, honored the bounded Linux evaluation timeout, deleted the unreachable PDF branch, and hid the backward-compatible no-op `--json` parser flag from help. -**B5. `pruneToBudget` quality.** ([#25](https://github.com/LockInTime/headless/issues/25)) Hand-rolled 2-pass fixed point +**B5. `pruneToBudget` quality.** ([#25](https://github.com/LockInTime/headless/issues/25)) ~~Hand-rolled 2-pass fixed point (`HP/AgentRuntime.swift:348-352`), O(n²) re-encoding per trim, pop-largest- *last*-element heuristic misses large mid-array items (`AgentRuntime.swift:367-369`), text-chop fallback untested. Rework with a -size-estimating single pass; add unit tests in the jsdom suite. +size-estimating single pass; add unit tests in the jsdom suite.~~ **Done:** +each candidate is measured once, largest entries are pruned regardless of +position while retained order is stable, text uses a byte-budgeted prefix +search, and jsdom locks the mid-array and text-fallback cases. **B6. Declared capability matrix.** ([#26](https://github.com/LockInTime/headless/issues/26)) Silent per-platform divergences to either fix or promote to declared differences asserted in tests: PDF raster (macOS,