Skip to content
Open
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
77 changes: 71 additions & 6 deletions src/tools/browser/aria-compressor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export interface AriaCompressionOptions {
* failure modes.
*/
maxLines?: number;
/**
* Soft character budget applied AFTER noise removal and BEFORE the
* line cap. Dense SPAs can emit short lines that still blow small
* local-model context windows (8k–16k). Default ~24k chars ≈ a few
* thousand tokens of ARIA text — enough for useful refs, small enough
* that a 9B/ local chat model still has room for the stable prefix.
* Set `0` / Infinity to disable. Related: #21.
*/
maxChars?: number;
/**
* Drop pure container lines (`generic`, `group`, `none`, ...) that
* carry no name and no inline text. Default true; tests set false to
Expand All @@ -25,6 +34,9 @@ export interface AriaCompressionOptions {
dropNoise?: boolean;
}

/** Default char budget when callers omit `maxChars`. */
export const DEFAULT_ARIA_MAX_CHARS = 24_000;

/**
* Roles that convey no semantic signal to the model on their own: empty
* wrappers that exist only to group children. Playwright's AI-mode
Expand Down Expand Up @@ -101,6 +113,12 @@ export function summariseAriaSnapshot(
options: AriaCompressionOptions = {},
): AriaSnapshotSummary {
const maxLines = options.maxLines ?? 800;
const maxChars =
options.maxChars === undefined
? DEFAULT_ARIA_MAX_CHARS
: options.maxChars <= 0 || !Number.isFinite(options.maxChars)
? Number.POSITIVE_INFINITY
: Math.trunc(options.maxChars);
const dropNoise = options.dropNoise ?? true;

const rawLines = rawText.split(/\r?\n/);
Expand All @@ -117,11 +135,19 @@ export function summariseAriaSnapshot(
kept.push(line);
}

const truncatedByLimit = kept.length > maxLines;
const finalLines = truncatedByLimit ? kept.slice(0, maxLines) : kept;
const omittedByLimit = kept.length - finalLines.length;
const omittedChars = truncatedByLimit
? kept.slice(maxLines).reduce((sum, line) => sum + line.length + 1, 0)
// Prefer keeping interactive / named nodes when we must cut by chars:
// score lines with refs or quoted names higher and pack greedily in
// original order until the budget fills (order-preserving pack).
const charPacked = packLinesToCharBudget(kept, maxChars);
const afterChars = charPacked.lines;
const truncatedByChars = charPacked.truncated;
const omittedCharsByBudget = charPacked.omittedChars;

const truncatedByLimit = afterChars.length > maxLines;
const finalLines = truncatedByLimit ? afterChars.slice(0, maxLines) : afterChars;
const omittedByLimit = afterChars.length - finalLines.length;
const omittedCharsByLines = truncatedByLimit
? afterChars.slice(maxLines).reduce((sum, line) => sum + line.length + 1, 0)
: 0;

const body = finalLines.join("\n");
Expand All @@ -131,9 +157,14 @@ export function summariseAriaSnapshot(
`… [collapsed ${droppedNoise} empty container lines]`,
);
}
if (truncatedByChars) {
footerParts.push(
`… [truncated ARIA tree by size; ~${omittedCharsByBudget} chars over budget — scroll or navigate to see more]`,
);
}
if (truncatedByLimit) {
footerParts.push(
`… [truncated ARIA tree; ${omittedByLimit} lines / ~${omittedChars} chars omitted — scroll or navigate to see more]`,
`… [truncated ARIA tree; ${omittedByLimit} lines / ~${omittedCharsByLines} chars omitted — scroll or navigate to see more]`,
);
}
const footer = footerParts.length > 0 ? `\n${footerParts.join("\n")}` : "";
Expand All @@ -155,6 +186,40 @@ export function summariseAriaSnapshot(
return { text, digest, refs };
}

/**
* Order-preserving pack: include lines until `maxChars` would be exceeded.
* Prefer dropping pure noise-adjacent empty lines already removed upstream;
* here we simply cut the tail so refs near the top of the page survive —
* which is what the model almost always acts on first.
*/
export function packLinesToCharBudget(
lines: readonly string[],
maxChars: number,
): { lines: string[]; truncated: boolean; omittedChars: number } {
if (!Number.isFinite(maxChars) || maxChars === Number.POSITIVE_INFINITY) {
return { lines: [...lines], truncated: false, omittedChars: 0 };
}
if (maxChars <= 0) {
return { lines: [], truncated: lines.length > 0, omittedChars: lines.join("\n").length };
}
const out: string[] = [];
let used = 0;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]!;
// +1 for the join newline, except before the first line.
const add = line.length + (out.length > 0 ? 1 : 0);
if (used + add > maxChars) {
const omittedChars = lines
.slice(i)
.reduce((sum, l, idx) => sum + l.length + (idx > 0 || out.length > 0 ? 1 : 0), 0);
return { lines: out, truncated: true, omittedChars };
}
out.push(line);
used += add;
}
return { lines: out, truncated: false, omittedChars: 0 };
}

function extractRefs(rawText: string): string[] {
const pattern = /\[ref=([a-zA-Z0-9]+)\]/g;
const seen = new Set<string>();
Expand Down
15 changes: 15 additions & 0 deletions src/tools/browser/browser-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,21 @@ describe("browser tools on a fake backend", () => {
});

describe("summariseAriaSnapshot", () => {

it("applies maxChars budget and reports size truncation footer", () => {
const lines = Array.from({ length: 200 }, (_, i) => `- link "Item ${i}" [ref=e${i}]`);
const raw = lines.join("\n");
const out = summariseAriaSnapshot(
raw,
{ url: "https://example.com", title: "t" },
{ maxChars: 400, dropNoise: false },
);
expect(out.text.length).toBeLessThan(raw.length);
expect(out.text).toMatch(/truncated ARIA tree by size/);
expect(out.refs.length).toBeGreaterThan(0);
expect(out.refs.length).toBeLessThan(200);
});

it("extracts refs and emits a header", () => {
const raw = [
"- banner",
Expand Down
6 changes: 5 additions & 1 deletion src/tools/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ export {
stopChrome,
} from "./spawn-chrome.js";
export type { RunningChrome, SpawnChromeInput } from "./spawn-chrome.js";
export { summariseAriaSnapshot } from "./aria-compressor.js";
export {
DEFAULT_ARIA_MAX_CHARS,
packLinesToCharBudget,
summariseAriaSnapshot,
} from "./aria-compressor.js";
export type {
AriaCompressionOptions,
AriaSnapshotSummary,
Expand Down