Skip to content
Merged
127 changes: 124 additions & 3 deletions src/dom/dom-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface DOMSerializerOptions {
// light (default): sibling dedup threshold=4, container collapse enabled
// aggressive: sibling dedup threshold=3
includeUserAgentShadowDOM?: boolean; // default: false
planningProfile?: 'default' | 'stable';
}

export interface PageStats {
Expand Down Expand Up @@ -137,6 +138,33 @@ function escapeAttributeValue(value: string): string {
});
}

const ID_REFERENCE_ATTRS = new Set([
'for',
'aria-labelledby',
'aria-describedby',
'aria-activedescendant',
'aria-controls',
'aria-owns',
'aria-flowto',
'aria-details',
]);
Comment on lines +141 to +150
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve volatile IDs referenced by ARIA IDREF attrs

The stable profile now drops IDs that look generated unless they appear in ID_REFERENCE_ATTRS, but this allowlist omits ARIA IDREF attributes like aria-activedescendant (and similar relationships). In widgets such as comboboxes/listboxes, the active option ID is often generated (react-aria-*), so the serializer can emit the relationship attribute while stripping the referenced element's id, breaking the reference the model needs to reason about focus/selection state.

Useful? React with 👍 / 👎.


function collectReferencedIds(node: DOMNode, referencedIds: Set<string>): void {
if (node.nodeType === NODE_TYPE_ELEMENT) {
const attrMap = parseAttributes(node.attributes);
for (const attr of ID_REFERENCE_ATTRS) {
const value = attrMap.get(attr);
if (!value) continue;
for (const id of value.split(/\s+/).filter(Boolean)) {
referencedIds.add(id);
}
}
}

for (const child of node.children || []) collectReferencedIds(child, referencedIds);
if (node.contentDocument) collectReferencedIds(node.contentDocument, referencedIds);
for (const shadowRoot of node.shadowRoots || []) collectReferencedIds(shadowRoot, referencedIds);
}
/**
* Check if a node is interactive
*/
Expand Down Expand Up @@ -188,20 +216,75 @@ function getDirectTextContent(node: DOMNode): string {
/**
* Format a single element node as a line
*/
function isVolatileStableAttr(name: string, value: string): boolean {
if (name === 'id') {
const hasRandomKeyword = /(?:^|[-_])(uuid|random|nonce|session|generated|ember|react-aria)[-_]?[a-z0-9]*$/i.test(value);
const hasUuidShape = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i.test(value);
const longHex = value.match(/[0-9a-f]{16,}/i)?.[0];
const hasMixedLongHex = !!longHex && /[a-f]/i.test(longHex) && /\d/.test(longHex);
return hasRandomKeyword || hasUuidShape || hasMixedLongHex;
}
if (name === 'class') {
return false;
}
return false;
}

function hasMeaningfulStableDescendant(node: DOMNode): boolean {
for (const child of node.children || []) {
if (child.nodeType !== NODE_TYPE_ELEMENT) continue;
const childTag = child.localName || child.nodeName.toLowerCase();
const childAttrs = parseAttributes(child.attributes);
if (!isDecorativeMedia(childTag, childAttrs, isInteractive(childTag, childAttrs))) return true;
if (hasMeaningfulStableDescendant(child)) return true;
}
return false;
}

function isDecorativeMedia(tagName: string, attrMap: Map<string, string>, interactive: boolean): boolean {
if (interactive) return false;
if (!['img', 'picture', 'source', 'video', 'canvas'].includes(tagName)) return false;
if (
attrMap.has('alt') ||
attrMap.has('title') ||
attrMap.has('aria-label') ||
attrMap.has('role') ||
attrMap.has('data-testid') ||
attrMap.has('controls') ||
attrMap.has('tabindex')
Comment on lines +250 to +254
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat aria-labelledby media as meaningful in stable mode

The new stable-profile decorative check only preserves media when alt, title, aria-label, etc. are present, but it ignores aria-labelledby/aria-describedby. In pages that label icon/media-only controls via referenced ARIA labels, this marks those nodes as decorative and drops them from stable DOM output, removing the only usable label context for planning/action selection.

Useful? React with 👍 / 👎.

) return false;
Comment on lines +248 to +255
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve title-labeled media from decorative pruning

In planningProfile === "stable", isDecorativeMedia treats an <img>/<video>/<canvas> as decorative unless it has one of a small set of attributes, but title is not among them. This means patterns like an icon-only link (<a><img title="Download" ...></a>) lose their only descriptive label in DOM output, because the media node is dropped while the parent link has no text; that degrades action selection and page understanding on real pages that rely on title tooltips for labeling.

Useful? React with 👍 / 👎.

return true;
}

function isDecorativeMediaNode(node: DOMNode): boolean {
if (node.nodeType !== NODE_TYPE_ELEMENT) return false;
const tagName = node.localName || node.nodeName.toLowerCase();
const attrMap = parseAttributes(node.attributes);
return isDecorativeMedia(tagName, attrMap, isInteractive(tagName, attrMap))
&& !hasMeaningfulStableDescendant(node);
}

function formatElement(
node: DOMNode,
attrMap: Map<string, string>,
indent: string,
textContent: string,
interactive: boolean,
hints?: string,
planningProfile: 'default' | 'stable' = 'default',
referencedIds: Set<string> = new Set(),
): string {
const tagName = node.localName || node.nodeName.toLowerCase();

// Build attribute string with only kept attrs
const attrParts: string[] = [];
for (const [k, v] of attrMap) {
if (KEEP_ATTRS.has(k)) {
if (KEEP_ATTRS.has(k) || (planningProfile === 'stable' && k === 'controls')) {
if (
planningProfile === 'stable'
&& isVolatileStableAttr(k, v)
&& !(k === 'id' && referencedIds.has(v))
) continue;
attrParts.push(`${k}="${escapeAttributeValue(v)}"`);
}
}
Expand Down Expand Up @@ -323,6 +406,8 @@ interface SerializeContext {
interactiveOnly: boolean;
compression: 'none' | 'light' | 'aggressive';
includeUserAgentShadowDOM: boolean;
planningProfile: 'default' | 'stable';
referencedIds: Set<string>;
nodesVisited: number;
maxNodes: number;
customInteractiveHints: Map<string, string>;
Expand Down Expand Up @@ -411,6 +496,23 @@ function serializeNode(
const customHints = ctx.customInteractiveHints.get(path);
const interactive = isInteractive(tagName, attrMap, customHints);

if (ctx.planningProfile === 'stable' && isDecorativeMedia(tagName, attrMap, interactive)) {
const fallbackText = getDirectTextContent(node);
const indent = ' '.repeat(depth);
if (fallbackText) {
const line = formatElement(node, attrMap, indent, fallbackText, interactive, customHints, ctx.planningProfile, ctx.referencedIds);
if (!appendBoundedLine(ctx, line + '\n')) return;
ctx.emittedBackendNodeIds.add(node.backendNodeId);
Comment on lines +502 to +505
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor interactiveOnly before emitting fallback media lines

In planningProfile === 'stable', decorative media with fallback text is emitted before the normal interactiveOnly gate runs, so filter: 'interactive' responses can include non-interactive <video>/<canvas> lines whenever they contain direct text. This is a behavioral regression from the previous path (which only emitted when interactive) and can pollute interactive-only planning output with non-actionable nodes.

Useful? React with 👍 / 👎.

}
// Omit decorative media wrappers without fallback text, but still inspect
// descendants so meaningful labels inside <picture> survive.
for (const child of node.children || []) {
serializeNode(child, depth + 1, ctx);
Comment on lines +499 to +510
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fallback text when pruning decorative media

When planningProfile === 'stable', this branch drops the media element line and only recurses into children; however, text children are ignored later because non-element nodes return early in serializeNode. As a result, fallback labels like <video>Download demo</video> or <canvas>Chart unavailable</canvas> disappear entirely in stable output, even though default serialization would keep that text via getDirectTextContent, which can remove the only human-readable label for that content.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve child paths when recursing decorative media

Pass the child path into this recursive call; otherwise it falls back to the default path = 'd', so descendants of dropped decorative nodes lose their true DOM path context. In stable mode with filter: 'interactive', non-native interactive descendants (detected via customInteractiveHints) under <video>/<canvas> fallback content can be misclassified as non-interactive and omitted, because hint lookup is path-based and no longer matches.

Useful? React with 👍 / 👎.

if (ctx.truncated) return;
}
return;
}

const indent = ' '.repeat(depth);

// Container chain collapse (only in non-'none' compression mode, non-interactive containers)
Expand All @@ -429,7 +531,7 @@ function serializeNode(
const leafHints = ctx.customInteractiveHints.get(leafPath);
const leafInteractive = isInteractive(leafTag, leafAttrMap, leafHints);
const leafText = getDirectTextContent(leaf);
const leafLine = formatElement(leaf, leafAttrMap, '', leafText, leafInteractive, leafHints);
const leafLine = formatElement(leaf, leafAttrMap, '', leafText, leafInteractive, leafHints, ctx.planningProfile, ctx.referencedIds);
const fullLine = `${indent}${chainPrefix}${leafLine}\n`;

if (ctx.totalChars + fullLine.length > ctx.maxOutputChars) {
Expand Down Expand Up @@ -459,7 +561,7 @@ function serializeNode(

if (!ctx.interactiveOnly || interactive) {
const textContent = getDirectTextContent(node);
const line = formatElement(node, attrMap, indent, textContent, interactive, customHints);
const line = formatElement(node, attrMap, indent, textContent, interactive, customHints, ctx.planningProfile, ctx.referencedIds);
const lineWithNewline = line + '\n';

if (ctx.totalChars + lineWithNewline.length > ctx.maxOutputChars) {
Expand Down Expand Up @@ -530,6 +632,13 @@ function serializeNode(
for (const group of groups) {
if (ctx.truncated) return;

if (ctx.planningProfile === 'stable' && group.nodes.every(isDecorativeMediaNode)) {
// A purely decorative media run contributes no planning signal. Skip it
// as a group instead of visiting every omitted leaf and exhausting the
// serializer node budget on ad/image-heavy pages.
continue;
Comment on lines +635 to +639
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fallback-text media when skipping decorative runs

The stable-mode sibling fast-path skips an entire group when group.nodes.every(isDecorativeMediaNode), but that predicate only treats element descendants as meaningful. As a result, consecutive fallback-text media (for example several <video>Download</video> siblings) are classified as decorative and removed wholesale under light/aggressive compression, even though single-node traversal now preserves that fallback text. This introduces content loss that depends on sibling count.

Useful? React with 👍 / 👎.

}

// Skip dedup for groups containing interactive elements to avoid
// hiding clickable buttons/links/inputs from the LLM
const groupHasInteractive = group.nodes.some(n => containsInteractive(n, childPaths.get(n) ?? path, ctx));
Expand Down Expand Up @@ -715,6 +824,7 @@ export async function serializeDOM(
const interactiveOnly = (options?.interactiveOnly ?? false) || options?.filter === 'interactive';
const compression = options?.compression ?? 'light'; // default to 'light'
const includeUserAgentShadowDOM = options?.includeUserAgentShadowDOM ?? false;
const planningProfile = options?.planningProfile ?? 'default';

// Get page stats via page.evaluate
const pageStats = await withTimeout(
Expand Down Expand Up @@ -765,6 +875,11 @@ export async function serializeDOM(
{ depth: documentDepth, pierce: true },
);

const referencedIds = new Set<string>();
if (planningProfile === 'stable') {
collectReferencedIds(root, referencedIds);
Comment on lines +879 to +880
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound stable ID pre-scan to serializer limits

When planningProfile === 'stable', collectReferencedIds(root, referencedIds) performs a full recursive walk of the CDP DOM before serialization starts, but this pre-scan is not constrained by maxNodes, maxDepth, or output truncation. On very large/deep pages, that means read_page can spend most of its time in this extra traversal (or overflow the JS call stack) even though serializeNode would otherwise stop early via DEFAULT_MAX_SERIALIZER_NODES; this can turn stable mode into a timeout path on noisy production pages.

Useful? React with 👍 / 👎.

}

const ctx: SerializeContext = {
lines: [],
totalChars: 0,
Expand All @@ -775,6 +890,8 @@ export async function serializeDOM(
interactiveOnly,
compression,
includeUserAgentShadowDOM,
planningProfile,
referencedIds,
nodesVisited: 0,
maxNodes: DEFAULT_MAX_SERIALIZER_NODES,
customInteractiveHints,
Expand All @@ -787,6 +904,10 @@ export async function serializeDOM(
appendBoundedLine(ctx, statsLine);
}

if (includePageStats && planningProfile === 'stable' && !ctx.truncated) {
appendBoundedLine(ctx, '[planning_profile] stable\n\n');
}

// Serialize from root
if (!ctx.truncated) {
serializeNode(root, 0, ctx);
Expand Down
7 changes: 7 additions & 0 deletions src/tools/read-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ const definition: MCPToolDefinition = {
enum: ['none', 'delta'],
description: 'Compression mode. "delta" returns only changes since last read.',
},
planningProfile: {
type: 'string',
enum: ['default', 'stable'],
description: 'DOM mode only: stable omits decorative/noisy serialization details without mutating the live page. Default: default.',
},
fallback: {
type: 'string',
enum: ['none', 'dom'],
Expand Down Expand Up @@ -680,10 +685,12 @@ const handler: ToolHandler = async (
try {
const refId = args.ref_id as string | undefined;
const depth = args.depth as number | undefined;
const planningProfile = (args.planningProfile as 'default' | 'stable' | undefined) ?? 'default';
const result = await measure('domGetDocumentMs', () => serializeDOM(page, cdpClient, {
maxDepth: depth ?? -1,
filter: filter,
interactiveOnly: filter === 'interactive',
planningProfile,
Comment on lines +688 to +693
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve stable-profile marker in delta DOM responses

This introduces planningProfile for DOM reads, but the compression: "delta" return path still reconstructs output with only a [page_stats] header, so a planningProfile: "stable" request can return delta output without the [planning_profile] stable marker. That makes profile-aware consumers unable to distinguish stable vs default snapshots when deltas are emitted, leading to inconsistent downstream parsing/caching for the same tab.

Useful? React with 👍 / 👎.

}));
diagnostics.formatMs = diagnostics.domGetDocumentMs;

Expand Down
1 change: 1 addition & 0 deletions tests/cli/admin-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ describe('admin keys CLI', () => {
const stdoutTokens = stdout.match(/oc_live_acme_[A-Za-z0-9]+/g) ?? [];
expect(stdoutTokens).toHaveLength(1);
const plaintext = stdoutTokens[0];
expect(plaintext).toMatch(/^oc_live_acme_[A-Za-z0-9]+$/);

// Warning routed to stderr.
expect(stderr).toContain('SAVE THIS KEY NOW');
Expand Down
Loading
Loading