Skip to content
4 changes: 4 additions & 0 deletions src/@types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ export interface ActiveSession {
skillState: SkillFireState;
lastUsedAt: number;
lastUrl?: string;
lastElements?: Map<string, SnapshotElement>;
lastSnapshotElements?: SnapshotElement[];
// Active tab the cache above belongs to; a change means re-baseline (new tab).
lastActiveTargetId?: string | null;
}

/* ------------------------------------------------------------------ */
Expand Down
195 changes: 177 additions & 18 deletions src/lib/agent-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,24 +176,8 @@ export const formatSnapshot = (snapshot: SnapshotResult): string => {
}
}

// Label cross-origin iframes (frame#1, …) and list them so the agent knows
// which elements live in a frame and that their deep-ref selectors pierce it.
const frameLabels = new Map<string, string>();
if (snapshot.frames?.length) {
snapshot.frames.forEach((frame, i) =>
frameLabels.set(frame.frameId, `frame#${i + 1}`),
);
lines.push(`Frames (${snapshot.frames.length} iframes):`);
for (const frame of snapshot.frames) {
const origin = frame.crossOrigin ? 'cross-origin' : 'same-origin';
lines.push(
` ${frameLabels.get(frame.frameId)} ${frame.url} (${origin})`,
);
}
lines.push(
'Elements tagged [frame#N] live in that iframe; their deep-ref selectors pierce it — pass as-is to click/type/hover.',
);
}
const frameLabels = frameLabelsOf(snapshot);
lines.push(...frameLegend(snapshot, frameLabels));

lines.push('');

Expand All @@ -204,3 +188,178 @@ export const formatSnapshot = (snapshot: SnapshotResult): string => {
lines.push('--- END SNAPSHOT ---');
return lines.join('\n');
};

const SIGNATURE_FIELDS: Array<keyof SnapshotElement> = [
'role',
'name',
'text',
'value',
'type',
'placeholder',
'href',
'disabled',
'checked',
'focused',
'required',
'ariaLabel',
'tag',
'frameId',
];

const elementSignature = (el: SnapshotElement): string =>
JSON.stringify(SIGNATURE_FIELDS.map((f) => el[f]));

// Framework-generated ids (Radix/useId/Headless/MUI) churn every render, so a
// selector or id built from one is not a stable diff key — excluded below.
const FRAMEWORK_ID = /(radix-|headlessui-|mui-[a-z]|«r|:r[0-9a-z]|_r_)/i;

// Stable cross-snapshot identity: clean id → clean selector → semantic composite
// (never the positional ref). Degrades gracefully when selectors churn on SPAs.
export const elementKey = (el: SnapshotElement): string => {
if (el.id && !FRAMEWORK_ID.test(el.id)) return `#${el.id}`;
if (el.selector && !FRAMEWORK_ID.test(el.selector))
return `sel:${el.selector}`;
// name often holds the volatile value (a stat number) and would churn the key;
// aria-label is the stable descriptor, so the value shows as `changed` instead.
const label = el.ariaLabel || el.name;
return `sem:${el.tag}|${el.role}|${label}|${el.href ?? ''}`;
};

// Occurrence-suffix the identity key so duplicate-keyed elements (two unlabeled
// buttons, repeated links) are each kept instead of collapsing to last-wins.
const identityEntries = (
elements: SnapshotElement[],
): Array<[string, SnapshotElement]> => {
const seen = new Map<string, number>();
return elements.map((el) => {
const base = elementKey(el);
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return [`${base}${n}`, el];
});
};

export const indexByIdentity = (
snapshot: SnapshotResult,
): Map<string, SnapshotElement> => new Map(identityEntries(snapshot.elements));

const frameLabelsOf = (snapshot: SnapshotResult): Map<string, string> => {
const labels = new Map<string, string>();
snapshot.frames?.forEach((f, i) => labels.set(f.frameId, `frame#${i + 1}`));
return labels;
};

// Frame legend + deep-ref guidance, shared by full and diff snapshots so framed
// elements never arrive without their [frame#N] → url mapping.
const frameLegend = (
snapshot: SnapshotResult,
frameLabels: Map<string, string>,
): string[] => {
if (!snapshot.frames?.length) return [];
const lines = [`Frames (${snapshot.frames.length} iframes):`];
for (const frame of snapshot.frames) {
const origin = frame.crossOrigin ? 'cross-origin' : 'same-origin';
lines.push(` ${frameLabels.get(frame.frameId)} ${frame.url} (${origin})`);
}
lines.push(
'Elements tagged [frame#N] live in that iframe; their deep-ref selectors pierce it — pass as-is to click/type/hover.',
);
return lines;
};

// Shared header/footer for both diffs so the rendered shape stays one thing.
const assembleDiff = (
snapshot: SnapshotResult,
countsLine: string,
changeLines: string[],
unchanged: number,
): string => {
const lines = [
'--- PAGE SNAPSHOT (diff vs previous; unchanged elements omitted) ---',
`${snapshot.url} | ${snapshot.title}`,
countsLine,
...(snapshot.detectedChallenges ?? []).map(
(t) => `! Detected challenge: ${t}`,
),
...frameLegend(snapshot, frameLabelsOf(snapshot)),
];
if (changeLines.length === 0) {
lines.push('', 'No changes since last snapshot.');
} else {
lines.push('', ...changeLines);
if (unchanged > 0)
lines.push(
`… ${unchanged} unchanged elements omitted (still valid from the previous snapshot).`,
);
}
lines.push('--- END SNAPSHOT ---');
return lines.join('\n');
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Delta vs the previous snapshot: +new / ~changed / -removed, unchanged collapsed
// to a count. Assumes the prior snapshot is still in context (caller sends full otherwise).
export const formatSnapshotDiff = (
snapshot: SnapshotResult,
prev: Map<string, SnapshotElement>,
): string => {
const frameLabels = frameLabelsOf(snapshot);
const seen = new Set<string>();
const added: SnapshotElement[] = [];
const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> =
[];
for (const [key, el] of identityEntries(snapshot.elements)) {
seen.add(key);
const before = prev.get(key);
if (!before) added.push(el);
else if (elementSignature(before) !== elementSignature(el))
changed.push({ before, after: el });
}
// Removed elements print by selector (the actionable handle), not the key.
const removed = [...prev.entries()]
.filter(([key]) => !seen.has(key))
.map(([, el]) => el.selector);
const unchanged = snapshot.elements.length - added.length - changed.length;

const changeLines = [
...added.map((el) => `+ ${formatElement(el, frameLabels)}`),
...changed.map(({ before, after }) =>
formatChange(before, after, frameLabels),
),
...removed.map((sel) => `- ref=${sel} (removed)`),
];
const counts = `Changes: ${added.length} new, ${changed.length} changed, ${removed.length} removed, ${unchanged} unchanged (${snapshot.elements.length} total)`;
return assembleDiff(snapshot, counts, changeLines, unchanged);
};

// `~` line with the field-level old→new reason, so a detected change is always
// visible. Reads SIGNATURE_FIELDS — the same fields change detection keys on.
const formatChange = (
before: SnapshotElement,
after: SnapshotElement,
frameLabels?: Map<string, string>,
): string => {
const deltas = SIGNATURE_FIELDS.filter((f) => before[f] !== after[f]).map(
(f) =>
`${f}: ${JSON.stringify(before[f] ?? '')}→${JSON.stringify(after[f] ?? '')}`,
);
const suffix = deltas.length ? ` (${deltas.join(', ')})` : '';
return `~ ${formatElement(after, frameLabels)}${suffix}`;
};

// Diff equal-length snapshots by DOM position — order survives an in-place SPA
// re-render when ids churn, surfacing moved values. Caller gates on equal length + shortest-wins.
export const formatSnapshotDiffPositional = (
prev: SnapshotElement[],
snapshot: SnapshotResult,
): string => {
const frameLabels = frameLabelsOf(snapshot);
const changeLines: string[] = [];
snapshot.elements.forEach((after, i) => {
const before = prev[i];
if (before && elementSignature(before) !== elementSignature(after))
changeLines.push(formatChange(before, after, frameLabels));
});
const unchanged = snapshot.elements.length - changeLines.length;
const counts = `Changes: ${changeLines.length} changed, ${unchanged} unchanged (${snapshot.elements.length} total)`;
return assembleDiff(snapshot, counts, changeLines, unchanged);
};
1 change: 1 addition & 0 deletions src/skills/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Load manually via **browserless_skill** if suspected but not injected:
- Snapshot VALID after: type, hover, scroll, evaluate
- Expect new content? → re-snapshot
- Element roles in snapshot (link, button, textbox, combobox, checkbox, heading) tell you what each does
- Snapshots after the first return a **diff** vs. your previous snapshot: only \`+\` new / \`~\` changed / \`-\` removed elements, plus a count of unchanged ones omitted. Unchanged elements stay valid — keep using their refs from the earlier snapshot. If that earlier snapshot is no longer in your context (summarized/trimmed away), request \`snapshot { full: true }\` to get the complete element list again.

## Selectors
- Use **ref=** (CSS) or **deep-ref=** (starts \`< \`) exactly as shown in snapshot
Expand Down
62 changes: 58 additions & 4 deletions src/tools/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import {
formatConnectError,
formatErrorMessage,
formatSnapshot,
formatSnapshotDiff,
formatSnapshotDiffPositional,
indexByIdentity,
} from '../lib/agent-format.js';

// export schemas, system prompt, and formatters
Expand Down Expand Up @@ -707,14 +710,65 @@ export function registerAgentTools(
);
const noticeBlock = notice ? `${notice}\n\n` : '';
if (lastSnapshot.url) agentSession.lastUrl = lastSnapshot.url;
// A peek (targetId ≠ active tab) reads a different tab; it must not
// diff against or overwrite the active tab's cache.
const targetId = (
lastCmd?.params as { targetId?: string } | undefined
)?.targetId;
const peeking =
!!targetId && targetId !== lastSnapshot.activeTargetId;
// Active tab changed (switch/create/close) → prior cache is a different
// tab; re-baseline with a full snapshot.
const switchedTab =
lastSnapshot.activeTargetId != null &&
agentSession.lastActiveTargetId != null &&
lastSnapshot.activeTargetId !== agentSession.lastActiveTargetId;
// Send full on first snapshot / cross-origin nav / mid-hydration
// baseline / explicit full / peek / tab switch; else diff (shortest-wins).
const prevElements = agentSession.lastElements;
const prevArr = agentSession.lastSnapshotElements;
const hydrating =
!!prevElements &&
lastSnapshot.elements.length > 0 &&
prevElements.size < lastSnapshot.elements.length * 0.25;
const forceFull =
(lastCmd?.params as { full?: boolean } | undefined)?.full === true;
const full = formatSnapshot(lastSnapshot);
let snapshotText = full;
if (
prevElements &&
!notice &&
!hydrating &&
!forceFull &&
!peeking &&
!switchedTab
) {
// Shortest of full / identity-diff / positional-diff wins, so a bad
// positional guess (only tried at equal count) can never cost more.
const candidates = [
full,
formatSnapshotDiff(lastSnapshot, prevElements),
];
if (prevArr && prevArr.length === lastSnapshot.elements.length) {
candidates.push(
formatSnapshotDiffPositional(prevArr, lastSnapshot),
);
}
snapshotText = candidates.reduce((a, b) =>
b.length < a.length ? b : a,
);
}
// Never let a peek's other-tab elements become the active baseline.
if (!peeking) {
agentSession.lastElements = indexByIdentity(lastSnapshot);
agentSession.lastSnapshotElements = lastSnapshot.elements;
agentSession.lastActiveTargetId = lastSnapshot.activeTargetId;
}
baseContent = [
{
type: 'text' as const,
text: appendSkills(
batchPrefix +
noticeBlock +
formatSnapshot(lastSnapshot) +
closedSuffix,
batchPrefix + noticeBlock + snapshotText + closedSuffix,
triggered,
),
},
Expand Down
9 changes: 9 additions & 0 deletions src/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ const SnapshotCommandSchema = z.object({
'Optional tab targetId to peek at without switching the active tab. ' +
'Obtain via getTabs or a prior snapshot response. Omit to snapshot the active tab.',
),
full: z
.boolean()
.optional()
.describe(
'Force a complete snapshot instead of a diff. Snapshots normally return ' +
'only what changed since your previous one; set full:true when you no ' +
'longer have that previous snapshot in context (e.g. it was summarized ' +
'away) and need the entire element list again.',
),
})
.optional()
.default({}),
Expand Down
Loading