Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 56 additions & 21 deletions apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) => {
Expand Down
42 changes: 42 additions & 0 deletions apps/headless/Tests/agent-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<p>Budget prefix should survive.</p>
<p>${'🚧'.repeat(300)}</p>
<p>Budget suffix should survive.</p>
`;
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),
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down