From 2a947484b9f8ddb51a4aa4dc5e67eab25553efc5 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 28 Aug 2026 03:13:13 +0800 Subject: [PATCH 1/3] perf(demo-agent): stop paying for JSON punctuation and a redundant tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One measured product-demo run spent $5.36 on 1,013,992 input tokens against 11,776 output — 95% of the bill is what we send, not what we get back. Two things dominate it. The accessibility snapshot was `JSON.stringify` of the full tree, ~707k of those tokens. The waste is structural: JSON repeats "role"/"name"/"children" on every node and wraps two short strings in braces and quotes. Serialize the same tree as indented `role "name" prop=value` lines instead — 44% fewer characters on an app-shaped tree (11,438 -> 6,399), with every scalar property preserved, so this is a re-encoding rather than a filter. A 3,000-node budget cuts at a node boundary and states the remainder, where a `slice` on the old JSON produced an unparseable string. The `screenshot` tool spent a whole round-trip per story choosing a key frame that play.ts's unconditional final capture already produces. Delete it; the `ModelAction` variant stays as an inert path so the capability is one tool definition away. `rationale` leaves nav/key/wait — it is write-only, and on those three the argument already is the intent — and stays on click and type, the actions that actually go wrong. Consumers whose story prose says "capture a screenshot" must drop that instruction in the same rollout: an unrecognised tool name maps to done/failed and sinks the chapter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fy9fwu7GQnyUkiGacPmDSB --- packages/demo-agent/src/cdp.test.ts | 96 ++++++++++++++++++++++++++++- packages/demo-agent/src/cdp.ts | 89 +++++++++++++++++++++++++- packages/demo-agent/src/model.ts | 42 +++++-------- 3 files changed, 197 insertions(+), 30 deletions(-) diff --git a/packages/demo-agent/src/cdp.test.ts b/packages/demo-agent/src/cdp.test.ts index 65dc336..3b38267 100644 --- a/packages/demo-agent/src/cdp.test.ts +++ b/packages/demo-agent/src/cdp.test.ts @@ -4,7 +4,7 @@ // the dispatcher's logs on every failed attach. import { describe, expect, it } from "vitest"; -import { redactWsEndpoint } from "./cdp.js"; +import { AX_NODE_BUDGET, serializeAxTree, redactWsEndpoint } from "./cdp.js"; describe("redactWsEndpoint", () => { it("strips the query string (where Browser Rendering tokens ride)", () => { @@ -45,3 +45,97 @@ describe("redactWsEndpoint", () => { expect(redactWsEndpoint("wss://broken#frag")).toBe("wss://broken/"); }); }); + +// serializeAxTree tests — the snapshot encoding is 73-77% of the agent's token +// spend, so this transform is where the bill lives. The properties that matter: +// no scalar is lost (the model reasons about `disabled`, `value`, `checked`), +// and the node budget cuts at a node boundary with a stated remainder rather +// than truncating mid-structure the way a `slice` on the old JSON did. + +describe("serializeAxTree", () => { + it("renders role and name as one indented line per node", () => { + expect( + serializeAxTree({ + role: "WebArea", + name: "Home", + children: [{ role: "button", name: "Add game" }], + }), + ).toBe('WebArea "Home"\n button "Add game"'); + }); + + it("keeps every scalar property, so state the model reasons about survives", () => { + const out = serializeAxTree({ + role: "textbox", + name: "Paste a store URL", + value: "https://play.google.com/x", + disabled: true, + level: 2, + }); + expect(out).toContain('value="https://play.google.com/x"'); + expect(out).toContain("disabled"); + expect(out).toContain("level=2"); + }); + + it("drops false booleans the way puppeteer's own serializer does", () => { + expect(serializeAxTree({ role: "checkbox", name: "Opt in", checked: false })).toBe( + 'checkbox "Opt in"', + ); + }); + + it("quotes names so an embedded quote or newline cannot forge a node line", () => { + expect(serializeAxTree({ role: "button", name: 'Say "hi"\nrole fake' })).toBe( + 'button "Say \\"hi\\"\\nrole fake"', + ); + }); + + it("is smaller than the JSON encoding it replaces", () => { + const tree = { + role: "WebArea", + name: "Billing", + children: Array.from({ length: 50 }, (_, i) => ({ + role: "button", + name: `Action ${i}`, + })), + }; + expect(serializeAxTree(tree).length).toBeLessThan(JSON.stringify(tree).length); + }); + + it("cuts at a node boundary and states how many nodes it did not show", () => { + const wide = { + role: "WebArea", + name: "Huge", + children: Array.from({ length: AX_NODE_BUDGET + 25 }, (_, i) => ({ + role: "button", + name: `b${i}`, + })), + }; + const out = serializeAxTree(wide); + const lines = out.split("\n"); + expect(lines).toHaveLength(AX_NODE_BUDGET + 1); + expect(lines[lines.length - 1]).toContain("…truncated: 26 more nodes not shown"); + // Every retained line is a whole node, never a severed fragment. + for (const line of lines.slice(0, -1)) expect(line.trim()).toMatch(/^\S+ "/); + }); + + it("counts unshown descendants, not just unshown siblings", () => { + const deep = { + role: "WebArea", + name: "Nested", + children: Array.from({ length: AX_NODE_BUDGET }, (_, i) => ({ + role: "group", + name: `g${i}`, + ...(i === AX_NODE_BUDGET - 1 + ? { children: [{ role: "button", name: "buried", children: [{ role: "text", name: "deep" }] }] } + : {}), + })), + }; + // The one group that misses the budget carries two descendants, so the + // remainder is 3 — a sibling-only count would have said 1. + expect(serializeAxTree(deep)).toContain("…truncated: 3 more nodes not shown"); + }); + + it("survives an empty snapshot", () => { + expect(serializeAxTree(null)).toBe('WebArea ""'); + expect(serializeAxTree(undefined)).toBe('WebArea ""'); + }); +}); diff --git a/packages/demo-agent/src/cdp.ts b/packages/demo-agent/src/cdp.ts index dba66fd..1bcdbd8 100644 --- a/packages/demo-agent/src/cdp.ts +++ b/packages/demo-agent/src/cdp.ts @@ -22,6 +22,90 @@ import { import { CdpAttachFailed, CdpCommandFailed } from "./errors.js"; import { VIEWPORTS, type ViewportPreset } from "./schemas.js"; +/** + * One accessibility node, in the shape puppeteer's + * `page.accessibility.snapshot()` returns. Declared structurally rather than + * imported so `serializeAxTree` stays testable without puppeteer's types. + */ +export type AxNode = { + readonly role?: string; + readonly name?: string; + readonly children?: readonly AxNode[]; + readonly [prop: string]: unknown; +}; + +/** Node ceiling for one snapshot. See `serializeAxTree`. */ +export const AX_NODE_BUDGET = 3_000; + +/** + * Render an accessibility tree as indented `role "name" [prop=value]` lines. + * + * This exists because `JSON.stringify` of the same tree was 73-77% of every + * token the agent spent — one measured run put 706,900 of its 1,013,992 input + * tokens here. The waste is structural, not semantic: JSON repeats the keys + * `"role"`, `"name"` and `"children"` on every one of thousands of nodes, and + * spends braces and quotes on nodes whose entire content is two short strings. + * The line form carries the same facts in 55.9% of the characters — measured on + * an app-shaped tree (nav, a 40-row table, a form): 11,438 chars of JSON became + * 6,399. + * + * Every scalar property survives, so this is a re-encoding and not a filter — + * a node's `value`, `disabled`, `checked`, `expanded` and the rest still reach + * the model, because the play loop's prompt asks it to reason about exactly + * those (a disabled submit button, a checkbox's state, a textbox's contents). + * + * Depth-first with a node budget, and the budget cuts at a node boundary: a + * `slice(0, n)` on the old JSON produced an unparseable string, which is worse + * than a smaller tree. When the budget runs out the remaining count is stated + * so the model knows the page is larger than what it can see, rather than + * silently believing it has the whole page. At 3,000 nodes the marker fires + * only on pathological pages — the median chapter snapshot is far below it, + * and the largest observed single call (~76,300 tokens) is the case it exists + * for. + */ +export const serializeAxTree = (root: AxNode | null | undefined): string => { + if (root === null || root === undefined) return 'WebArea ""'; + const lines: string[] = []; + let budget = AX_NODE_BUDGET; + let dropped = 0; + + const countNodes = (node: AxNode): number => + 1 + (node.children ?? []).reduce((n, child) => n + countNodes(child), 0); + + const walk = (node: AxNode, depth: number): void => { + if (budget <= 0) { + dropped += countNodes(node); + return; + } + budget -= 1; + const indent = " ".repeat(depth); + const role = typeof node.role === "string" ? node.role : "node"; + const name = typeof node.name === "string" ? node.name : ""; + // Everything that is not role/name/children and not an empty-ish value. + // Booleans that are `false` are dropped the way puppeteer's own serializer + // drops them; `false` on an absent property is the default the model + // already assumes. + const props = Object.entries(node) + .filter(([k, v]) => { + if (k === "role" || k === "name" || k === "children") return false; + if (v === undefined || v === null || v === false || v === "") return false; + return typeof v === "string" || typeof v === "number" || typeof v === "boolean"; + }) + .map(([k, v]) => (v === true ? ` ${k}` : ` ${k}=${JSON.stringify(v)}`)) + .join(""); + lines.push(`${indent}${role} ${JSON.stringify(name)}${props}`); + for (const child of node.children ?? []) walk(child, depth + 1); + }; + + walk(root, 0); + if (dropped > 0) { + lines.push( + ` …truncated: ${dropped} more nodes not shown (page exceeds the ${AX_NODE_BUDGET}-node snapshot budget)`, + ); + } + return lines.join("\n"); +}; + /** * Minimal surface the play loop + recorder need from a live CDP session. * Exposed as an interface so tests can inject a fake without spinning up a @@ -49,7 +133,8 @@ export interface CdpSession { ) => Effect.Effect; /** * Snapshot the accessibility tree of the current page — the input the model - * picks its next action from. Returns a compact JSON-stringified tree. + * picks its next action from. Returns the indented `role "name"` line form + * produced by `serializeAxTree`, not JSON. */ readonly accessibilitySnapshot: () => Effect.Effect< string, @@ -386,7 +471,7 @@ export const attachCdp = ( accessibilitySnapshot: () => wrapCmd("Accessibility.getFullAXTree", async () => { const tree = await page.accessibility.snapshot({ interestingOnly: true }); - return JSON.stringify(tree ?? { role: "WebArea", children: [] }); + return serializeAxTree(tree as AxNode | null); }), close: () => Effect.tryPromise({ diff --git a/packages/demo-agent/src/model.ts b/packages/demo-agent/src/model.ts index 61cd671..f3524a1 100644 --- a/packages/demo-agent/src/model.ts +++ b/packages/demo-agent/src/model.ts @@ -104,7 +104,6 @@ const NavTool = Tool.make("nav", { url: Schema.String.annotations({ description: "Absolute URL to navigate to.", }), - rationale: Schema.optional(Schema.String), }, }); @@ -115,7 +114,6 @@ const KeyTool = Tool.make("key", { key: Schema.String.annotations({ description: "CDP key name (Enter, Tab, Escape, ArrowDown, ...).", }), - rationale: Schema.optional(Schema.String), }, }); @@ -126,20 +124,18 @@ const WaitTool = Tool.make("wait", { ms: Schema.Number.annotations({ description: "Milliseconds to wait (will be clamped to 0..5000).", }), - rationale: Schema.optional(Schema.String), }, }); -const ScreenshotTool = Tool.make("screenshot", { - description: - "Mark the current frame as the story's KEY screenshot. Use exactly once per story at the moment that best captures the outcome.", - parameters: { - rationale: Schema.optional(Schema.String).annotations({ - description: - "Why this frame is the key moment for the story (one short sentence).", - }), - }, -}); +// NOTE: there is deliberately no `screenshot` tool. It cost one whole model +// round-trip per story — ~9k input tokens to decide a frame — to pick a key +// frame that play.ts's unconditional final capture (play.ts § "Always take a +// final screenshot") already produces for free. In 8 of the 10 chapters the +// prose reached for it immediately before `done`, so the fallback frame and +// the chosen frame were the same picture. `ModelAction`'s `screenshot` variant +// and play.ts's handling of it survive as an inert path: nothing emits one now, +// and keeping them costs no tokens while leaving the capability one tool +// definition away. const DoneTool = Tool.make("done", { description: @@ -161,7 +157,6 @@ const ActionToolkit = Toolkit.make( NavTool, KeyTool, WaitTool, - ScreenshotTool, DoneTool, ); @@ -177,7 +172,6 @@ const ActionToolkitHandlersLayer = ActionToolkit.toLayer({ nav: () => Effect.void, key: () => Effect.void, wait: () => Effect.void, - screenshot: () => Effect.void, done: () => Effect.void, }); @@ -838,21 +832,14 @@ const toolCallToAction = (call: { Match.when("nav", () => ({ type: "nav" as const, url: p["url"] as string, - ...(p["rationale"] !== undefined ? { rationale: p["rationale"] as string } : {}), })), Match.when("key", () => ({ type: "key" as const, key: p["key"] as string, - ...(p["rationale"] !== undefined ? { rationale: p["rationale"] as string } : {}), })), Match.when("wait", () => ({ type: "wait" as const, ms: p["ms"] as number, - ...(p["rationale"] !== undefined ? { rationale: p["rationale"] as string } : {}), - })), - Match.when("screenshot", () => ({ - type: "screenshot" as const, - ...(p["rationale"] !== undefined ? { rationale: p["rationale"] as string } : {}), })), Match.when("done", () => ({ type: "done" as const, @@ -905,9 +892,12 @@ export const summarizeStories = ( const ACTION_SYSTEM_PROMPT_NOTE = `You drive a web app through one user story. You will see the story prose, the page's accessibility tree, and the history -of actions you have already applied. Pick ONE next action by calling exactly -one of the registered tools (click | type | nav | key | wait | screenshot | -done). Do NOT respond with prose — the tool call IS the action. +of actions you have already applied. The tree is one node per line, indented by +depth, as \`role "accessible name"\` followed by any set properties +(\`value="..."\`, \`disabled\`, \`checked\`, …). A trailing "…truncated" line means +the page has more nodes than the snapshot budget shows. Pick ONE next action by calling exactly +one of the registered tools (click | type | nav | key | wait | done). Do NOT +respond with prose — the tool call IS the action. THE TARGET APP IS ALREADY LOADED in the browser — the accessibility snapshot you see IS the app under test. Operate it directly with click/type/key. Do NOT @@ -931,8 +921,6 @@ Rules: saying what was missing. Never wait more than ~3 times total in a story, and never invent a CSS selector. Each action costs time — keep moving toward the success condition; do not re-snapshot idly. -- Emit ONE \`screenshot\` per story, at the moment that best captures the - outcome. - Stop ASAP. The MOMENT the story's success condition is visible in the snapshot, call \`done\` with status=passed — do not take extra confirming actions. Lingering past the success state wastes the action budget and is a From 113b49c740e05e226b0977caa10ee8a00dba8ad7 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 28 Aug 2026 04:00:06 +0800 Subject: [PATCH 2/3] perf(demo-agent): drop puppeteer's per-node ids, and make the snapshot unforgeable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line encoding still carried `backendNodeId` and `loaderId` — puppeteer's `serialize()` sets both on EVERY node, so each line spent ~45 chars on ids no model reads (~135KB on a 3,000-node page), which is the repetition this encoding exists to delete. Page content is attacker-influenceable and the newline is structural here. `JSON.stringify` escapes neither U+2028, U+2029 nor U+0085, so a crafted accessible name could forge a node line — or a line byte-identical to the truncation marker. Escaping now covers those three code points on the name, on every property value, and on the role; the marker sits at column 0 behind a lone-backslash prefix that no rendered role or name can produce (both double their backslashes). Also: `checked`/`pressed` keep their `false` — puppeteer emits those two only when the node has that state, so dropping it made an unchecked checkbox identical to one with no checked state. A 280-char cap bounds an accessible name, the per-node cost the NODE budget cannot see (~200 paragraph-length StaticText names outspend 3,000 buttons without tripping the marker). The removed `screenshot` tool was fatal, not inert: `toolCallToAction`'s `Match.orElse` mapped it to done/failed, so consumer prose that still says "capture a screenshot" would sink the chapter. The legacy name now maps to a 0ms wait; unknown tools still fail loudly. With nothing able to produce it, the `screenshot` variant leaves `ModelAction` and play.ts — play.ts's unconditional final capture is unchanged and is now the only key-frame source. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fy9fwu7GQnyUkiGacPmDSB --- packages/demo-agent/src/cdp.test.ts | 108 +++++++++++++++++++--- packages/demo-agent/src/cdp.ts | 117 ++++++++++++++++-------- packages/demo-agent/src/model.test.ts | 54 ++++++++++- packages/demo-agent/src/model.ts | 16 +++- packages/demo-agent/src/play.test.ts | 11 ++- packages/demo-agent/src/play.ts | 51 +++-------- packages/demo-agent/src/schemas.test.ts | 6 +- packages/demo-agent/src/schemas.ts | 14 ++- 8 files changed, 271 insertions(+), 106 deletions(-) diff --git a/packages/demo-agent/src/cdp.test.ts b/packages/demo-agent/src/cdp.test.ts index 3b38267..f13329c 100644 --- a/packages/demo-agent/src/cdp.test.ts +++ b/packages/demo-agent/src/cdp.test.ts @@ -4,7 +4,21 @@ // the dispatcher's logs on every failed attach. import { describe, expect, it } from "vitest"; -import { AX_NODE_BUDGET, serializeAxTree, redactWsEndpoint } from "./cdp.js"; +import { + AX_NAME_CHAR_CAP, + AX_NODE_BUDGET, + serializeAxTree, + redactWsEndpoint, + type AxNode, +} from "./cdp.js"; + +/** + * `AxNode` names only role/name/children — the fields the line form places + * itself; every other a11y property is read structurally off the live + * puppeteer node. Tests that exercise those properties build their fixture + * through this widening helper, which is the same shape puppeteer hands us. + */ +const ax = (node: Record): AxNode => node as AxNode; describe("redactWsEndpoint", () => { it("strips the query string (where Browser Rendering tokens ride)", () => { @@ -64,31 +78,93 @@ describe("serializeAxTree", () => { }); it("keeps every scalar property, so state the model reasons about survives", () => { - const out = serializeAxTree({ - role: "textbox", - name: "Paste a store URL", - value: "https://play.google.com/x", - disabled: true, - level: 2, - }); + const out = serializeAxTree( + ax({ + role: "textbox", + name: "Paste a store URL", + value: "https://play.google.com/x", + disabled: true, + level: 2, + }), + ); expect(out).toContain('value="https://play.google.com/x"'); expect(out).toContain("disabled"); expect(out).toContain("level=2"); }); - it("drops false booleans the way puppeteer's own serializer does", () => { - expect(serializeAxTree({ role: "checkbox", name: "Opt in", checked: false })).toBe( - 'checkbox "Opt in"', + it("keeps checked/pressed false — an unchecked box is not a stateless one", () => { + // puppeteer's `tristateProperties` emits `checked` ONLY when the node has + // that state, so dropping `false` would erase the difference between an + // unchecked checkbox and a node with no checked state at all. + expect( + serializeAxTree(ax({ role: "checkbox", name: "Opt in", checked: false })), + ).toBe('checkbox "Opt in" checked=false'); + expect( + serializeAxTree(ax({ role: "button", name: "Bold", pressed: false })), + ).toBe('button "Bold" pressed=false'); + }); + + it("drops false for the other booleans, whose default the model assumes", () => { + expect( + serializeAxTree( + ax({ role: "button", name: "Save", disabled: false, focused: false }), + ), + ).toBe('button "Save"'); + }); + + it("drops puppeteer's per-node backendNodeId and loaderId noise", () => { + const out = serializeAxTree( + ax({ + role: "button", + name: "Add game", + backendNodeId: 4271, + loaderId: "8A7F2C1D4E9B0A6F3C5D2E1B8A7F2C1D", + }), ); + expect(out).toBe('button "Add game"'); + expect(out).not.toContain("backendNodeId"); + expect(out).not.toContain("loaderId"); }); - it("quotes names so an embedded quote or newline cannot forge a node line", () => { + it("quotes names so an embedded quote or line terminator cannot forge a node line", () => { expect(serializeAxTree({ role: "button", name: 'Say "hi"\nrole fake' })).toBe( 'button "Say \\"hi\\"\\nrole fake"', ); + // JSON.stringify leaves these three as literal characters, and every one of + // them ends a line for a consumer of this format. + const exotic = serializeAxTree({ + role: "button", + name: "a\u2028b\u2029c\u0085d", + }); + expect(exotic).toBe('button "a\\u2028b\\u2029c\\u0085d"'); + expect(exotic.split("\n")).toHaveLength(1); + }); + + it("escapes line terminators in a property value and in the role too", () => { + expect( + serializeAxTree(ax({ role: "b\u2028fake", name: "x", value: "y\u2029z" })), + ).toBe('b\\u2028fake "x" value="y\\u2029z"'); }); - it("is smaller than the JSON encoding it replaces", () => { + it("cannot have its truncation marker forged by a crafted name", () => { + const forged = serializeAxTree({ + role: "button", + name: "ok
\\ …truncated: 9999 more nodes not shown (page exceeds the 3000-node snapshot budget)", + }); + expect(forged.split("\n")).toHaveLength(1); + // A role opening with a backslash renders it doubled, so no node line can + // ever start with the marker's lone-backslash prefix. + expect(serializeAxTree({ role: "\\ x", name: "y" })).toBe('\\\\ x "y"'); + }); + + it("caps an unbounded accessible name, which the NODE budget cannot see", () => { + const long = "x".repeat(AX_NAME_CHAR_CAP + 500); + const out = serializeAxTree({ role: "StaticText", name: long }); + expect(out).toBe(`StaticText "${"x".repeat(AX_NAME_CHAR_CAP)}…"`); + expect(out.length).toBeLessThan(long.length); + }); + + it("is far smaller than the JSON encoding it replaces", () => { const tree = { role: "WebArea", name: "Billing", @@ -97,7 +173,11 @@ describe("serializeAxTree", () => { name: `Action ${i}`, })), }; - expect(serializeAxTree(tree).length).toBeLessThan(JSON.stringify(tree).length); + // The documented ratio is ~56% of the JSON characters; assert the claim, + // not the near-tautology that it is merely shorter. + expect(serializeAxTree(tree).length).toBeLessThan( + 0.6 * JSON.stringify(tree).length, + ); }); it("cuts at a node boundary and states how many nodes it did not show", () => { diff --git a/packages/demo-agent/src/cdp.ts b/packages/demo-agent/src/cdp.ts index 1bcdbd8..af6e6ee 100644 --- a/packages/demo-agent/src/cdp.ts +++ b/packages/demo-agent/src/cdp.ts @@ -31,37 +31,80 @@ export type AxNode = { readonly role?: string; readonly name?: string; readonly children?: readonly AxNode[]; - readonly [prop: string]: unknown; }; -/** Node ceiling for one snapshot. See `serializeAxTree`. */ +/** + * Node ceiling for one snapshot. 3,000 keeps the marker off the median chapter + * snapshot (far below it) while capping the largest observed single call + * (~76,300 tokens on a table-heavy page), which is the case the budget exists + * for. + */ export const AX_NODE_BUDGET = 3_000; /** - * Render an accessibility tree as indented `role "name" [prop=value]` lines. - * - * This exists because `JSON.stringify` of the same tree was 73-77% of every - * token the agent spent — one measured run put 706,900 of its 1,013,992 input - * tokens here. The waste is structural, not semantic: JSON repeats the keys - * `"role"`, `"name"` and `"children"` on every one of thousands of nodes, and - * spends braces and quotes on nodes whose entire content is two short strings. - * The line form carries the same facts in 55.9% of the characters — measured on - * an app-shaped tree (nav, a 40-row table, a form): 11,438 chars of JSON became - * 6,399. - * - * Every scalar property survives, so this is a re-encoding and not a filter — - * a node's `value`, `disabled`, `checked`, `expanded` and the rest still reach - * the model, because the play loop's prompt asks it to reason about exactly - * those (a disabled submit button, a checkbox's state, a textbox's contents). + * Per-name character cap. The node budget counts nodes, so ~200 + * paragraph-length `StaticText` names cost more than 3,000 buttons and never + * trip the marker; this bounds the per-node cost the node budget cannot see. + * 280 chars is well past any real label, button or heading. + */ +export const AX_NAME_CHAR_CAP = 280; + +/** + * Keys never rendered as a `prop=value`. `role`/`name`/`children` have their + * own place in the line; `backendNodeId` and `loaderId` are set by puppeteer's + * `serialize()` on EVERY node and mean nothing to the model — ~45 chars of + * per-line noise (~135KB on a 3,000-node page) that is exactly the repetition + * this encoding exists to delete. + */ +const EXCLUDED_PROPS = new Set(["role", "name", "children", "backendNodeId", "loaderId"]); + +/** + * Properties whose `false` is information. Puppeteer's `serialize()` emits + * `checked`/`pressed` only when the node actually has that state, so dropping + * `false` would make an unchecked checkbox indistinguishable from one with no + * checked state at all. Every other boolean's `false` IS the default the model + * already assumes, and stays dropped. + */ +const TRISTATE_PROPS = new Set(["checked", "pressed"]); + +/** + * Escape a string for embedding in one line, WITHOUT the surrounding quotes. + * `JSON.stringify` does not escape U+2028, U+2029 or U+0085, and this format + * makes the newline structural — page content is attacker-influenceable, so an + * unescaped one lets a crafted accessible name forge a node line. + */ +const escapeInline = (s: string): string => + JSON.stringify(s) + .slice(1, -1) + .replace( + /[\u2028\u2029\u0085]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + +/** `escapeInline` plus the quotes the name/string-value form carries. */ +const quoteInline = (s: string): string => `"${escapeInline(s)}"`; + +/** Cap an unbounded string (a name, a textbox `value`) with an ellipsis. */ +const capText = (s: string): string => + s.length > AX_NAME_CHAR_CAP ? `${s.slice(0, AX_NAME_CHAR_CAP)}…` : s; + +/** + * The truncation marker's prefix, chosen so no node line can forge it: it sits + * at column 0 (node lines below the root are indented) and opens with a LONE + * backslash, which `escapeInline` doubles in every role and name it renders. + */ +const TRUNCATION_MARKER_PREFIX = "\\ "; + +/** + * Render an accessibility tree as indented `role "name" [prop=value]` lines — + * the same facts as `JSON.stringify` of the tree in ~56% of the characters, + * which matters because the snapshot is the bulk of the agent's token spend. * - * Depth-first with a node budget, and the budget cuts at a node boundary: a - * `slice(0, n)` on the old JSON produced an unparseable string, which is worse - * than a smaller tree. When the budget runs out the remaining count is stated - * so the model knows the page is larger than what it can see, rather than - * silently believing it has the whole page. At 3,000 nodes the marker fires - * only on pathological pages — the median chapter snapshot is far below it, - * and the largest observed single call (~76,300 tokens) is the case it exists - * for. + * Depth-first with a node budget that cuts at a node boundary (a `slice` on + * the old JSON produced an unparseable string) and states the remainder. + * Caveat: because it is depth-first, one wide early subtree can consume the + * whole budget and hide the region the model needs — the remainder count is + * the only signal that happened. */ export const serializeAxTree = (root: AxNode | null | undefined): string => { if (root === null || root === undefined) return 'WebArea ""'; @@ -72,6 +115,9 @@ export const serializeAxTree = (root: AxNode | null | undefined): string => { const countNodes = (node: AxNode): number => 1 + (node.children ?? []).reduce((n, child) => n + countNodes(child), 0); + const renderValue = (v: string | number | boolean): string => + typeof v === "string" ? quoteInline(capText(v)) : String(v); + const walk = (node: AxNode, depth: number): void => { if (budget <= 0) { dropped += countNodes(node); @@ -81,26 +127,25 @@ export const serializeAxTree = (root: AxNode | null | undefined): string => { const indent = " ".repeat(depth); const role = typeof node.role === "string" ? node.role : "node"; const name = typeof node.name === "string" ? node.name : ""; - // Everything that is not role/name/children and not an empty-ish value. - // Booleans that are `false` are dropped the way puppeteer's own serializer - // drops them; `false` on an absent property is the default the model - // already assumes. - const props = Object.entries(node) + const props = Object.entries(node as Record) .filter(([k, v]) => { - if (k === "role" || k === "name" || k === "children") return false; - if (v === undefined || v === null || v === false || v === "") return false; + if (EXCLUDED_PROPS.has(k)) return false; + if (v === undefined || v === null || v === "") return false; + if (v === false) return TRISTATE_PROPS.has(k); return typeof v === "string" || typeof v === "number" || typeof v === "boolean"; }) - .map(([k, v]) => (v === true ? ` ${k}` : ` ${k}=${JSON.stringify(v)}`)) + .map(([k, v]) => + v === true ? ` ${k}` : ` ${k}=${renderValue(v as string | number | boolean)}`, + ) .join(""); - lines.push(`${indent}${role} ${JSON.stringify(name)}${props}`); + lines.push(`${indent}${escapeInline(role)} ${quoteInline(capText(name))}${props}`); for (const child of node.children ?? []) walk(child, depth + 1); }; walk(root, 0); if (dropped > 0) { lines.push( - ` …truncated: ${dropped} more nodes not shown (page exceeds the ${AX_NODE_BUDGET}-node snapshot budget)`, + `${TRUNCATION_MARKER_PREFIX}…truncated: ${dropped} more nodes not shown (page exceeds the ${AX_NODE_BUDGET}-node snapshot budget)`, ); } return lines.join("\n"); @@ -471,7 +516,7 @@ export const attachCdp = ( accessibilitySnapshot: () => wrapCmd("Accessibility.getFullAXTree", async () => { const tree = await page.accessibility.snapshot({ interestingOnly: true }); - return serializeAxTree(tree as AxNode | null); + return serializeAxTree(tree); }), close: () => Effect.tryPromise({ diff --git a/packages/demo-agent/src/model.test.ts b/packages/demo-agent/src/model.test.ts index 1fdaa37..8c3f646 100644 --- a/packages/demo-agent/src/model.test.ts +++ b/packages/demo-agent/src/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isRetryableGatewayError } from "./model.js"; +import { isRetryableGatewayError, toolCallToAction } from "./model.js"; describe("isRetryableGatewayError", () => { it("retries the Cloudflare AI Gateway 429 rate-limit", () => { @@ -27,3 +27,55 @@ describe("isRetryableGatewayError", () => { expect(isRetryableGatewayError(new Error("processed 14290 tokens"))).toBe(false); }); }); + +describe("toolCallToAction", () => { + it("maps every live tool to its action", () => { + expect(toolCallToAction({ name: "click", params: { target: "Home" } })).toEqual({ + type: "click", + target: "Home", + }); + expect( + toolCallToAction({ + name: "click", + params: { target: "Home", rationale: "the nav link" }, + }), + ).toEqual({ type: "click", target: "Home", rationale: "the nav link" }); + expect( + toolCallToAction({ name: "type", params: { target: "#q", text: "hello" } }), + ).toEqual({ type: "type", target: "#q", text: "hello" }); + expect( + toolCallToAction({ name: "nav", params: { url: "https://example.test/" } }), + ).toEqual({ type: "nav", url: "https://example.test/" }); + expect(toolCallToAction({ name: "key", params: { key: "Enter" } })).toEqual({ + type: "key", + key: "Enter", + }); + expect(toolCallToAction({ name: "wait", params: { ms: 500 } })).toEqual({ + type: "wait", + ms: 500, + }); + expect( + toolCallToAction({ + name: "done", + params: { narrative: "walked the flow", status: "passed" }, + }), + ).toEqual({ type: "done", narrative: "walked the flow", status: "passed" }); + }); + + it("degrades the removed `screenshot` tool to a no-op, never a failed chapter", () => { + // Consumer story prose still says "capture a screenshot", and a cached + // prompt can still name the tool. Before the shim, orElse mapped it to + // done/failed and sank the whole chapter. + const action = toolCallToAction({ name: "screenshot", params: {} }); + expect(action).toEqual({ type: "wait", ms: 0 }); + expect(action).not.toHaveProperty("status", "failed"); + }); + + it("still fails loudly on a genuinely unknown tool", () => { + expect(toolCallToAction({ name: "teleport", params: {} })).toEqual({ + type: "done", + narrative: "model called unknown tool teleport", + status: "failed", + }); + }); +}); diff --git a/packages/demo-agent/src/model.ts b/packages/demo-agent/src/model.ts index f3524a1..dd2dda2 100644 --- a/packages/demo-agent/src/model.ts +++ b/packages/demo-agent/src/model.ts @@ -132,10 +132,10 @@ const WaitTool = Tool.make("wait", { // frame that play.ts's unconditional final capture (play.ts § "Always take a // final screenshot") already produces for free. In 8 of the 10 chapters the // prose reached for it immediately before `done`, so the fallback frame and -// the chosen frame were the same picture. `ModelAction`'s `screenshot` variant -// and play.ts's handling of it survive as an inert path: nothing emits one now, -// and keeping them costs no tokens while leaving the capability one tool -// definition away. +// the chosen frame were the same picture. `ModelAction` has no `screenshot` +// variant either — `toolCallToAction` maps the legacy tool NAME to a 0ms wait +// so prose that still asks for it degrades to a no-op instead of failing the +// chapter. const DoneTool = Tool.make("done", { description: @@ -810,7 +810,7 @@ export const pickNextAction = ( return toolCallToAction(call); }); -const toolCallToAction = (call: { +export const toolCallToAction = (call: { readonly name: string; readonly params: unknown; }): ModelAction => { @@ -846,6 +846,12 @@ const toolCallToAction = (call: { narrative: p["narrative"] as string, status: p["status"] as "passed" | "failed", })), + // Compatibility shim for the removed `screenshot` tool: consumer story + // prose still says "capture a screenshot", and a cached prompt can still + // name it, so a model that calls it must not sink the chapter. Map it to a + // benign no-op — play.ts's unconditional final capture already produces + // the key frame. A genuinely unknown tool still fails loudly via orElse. + Match.when("screenshot", () => ({ type: "wait" as const, ms: 0 })), Match.orElse( () => ({ diff --git a/packages/demo-agent/src/play.test.ts b/packages/demo-agent/src/play.test.ts index 548b1f8..9b6109d 100644 --- a/packages/demo-agent/src/play.test.ts +++ b/packages/demo-agent/src/play.test.ts @@ -5,8 +5,7 @@ // * a CDP error inside the loop produces `{ status: "failed" }` (the story // fails, the run does not); // * a model error inside the loop produces `{ status: "failed" }`; -// * the final screenshot fallback is captured when the model never emits -// `screenshot` explicitly; +// * the final screenshot — the only key-frame source — is always captured; // * `chapterStartMs` / `chapterEndMs` are measured relative to `attachedAtMs`. // // The real CDP + `LanguageModel` are mocked — we inject a fake `CdpSession` @@ -76,7 +75,7 @@ describe("runPlayLoop", () => { const scripted: ModelAction[] = [ { type: "nav", url: "https://staging.example.com" }, { type: "click", target: "button[name='Sign in']" }, - { type: "screenshot" }, + { type: "wait", ms: 0 }, { type: "done", narrative: "Signed in and landed on the dashboard.", @@ -114,7 +113,9 @@ describe("runPlayLoop", () => { ); expect(result.status).toBe("passed"); expect(result.narrative).toContain("dashboard"); - expect(result.keyScreenshotPath).toMatch(/sign-in\.png$/); + // The key frame is always the loop's final capture — the model has no + // say in which frame it is. + expect(result.keyScreenshotPath).toMatch(/sign-in\.final\.png$/); expect(result.chapterStartMs).toBeGreaterThan(0); expect(result.chapterEndMs).toBeGreaterThanOrEqual(result.chapterStartMs); // 4 actions × 1 cycle each (ax + apply) → expect at least 4 ax snapshots. @@ -161,7 +162,7 @@ describe("runPlayLoop", () => { expect(result.narrative).toMatch(/click failed/); }); - it("falls back to a final screenshot when the model never emits one", async () => { + it("always captures the final screenshot as the key frame", async () => { const session = makeFakeSession(); const scripted: ModelAction[] = [ { type: "click", target: "x" }, diff --git a/packages/demo-agent/src/play.ts b/packages/demo-agent/src/play.ts index fa74976..a44c5f1 100644 --- a/packages/demo-agent/src/play.ts +++ b/packages/demo-agent/src/play.ts @@ -9,7 +9,7 @@ // ask the model (via @effect/ai's LanguageModel): next action? // apply via CDP // append to history (oldest first) -// if the model said "screenshot", save it as the key screenshot path +// capture the final frame — the story's key screenshot // record chapterEndMs // emit one JSON line on stdout (PlayOutput shape) // @@ -129,7 +129,6 @@ export const runPlayLoop = ( yield* captureFrame(); const history: string[] = []; - let keyScreenshotPath: string | undefined; let narrative = ""; let status: PlayOutput["status"] = "failed"; let terminated: "done" | "max-actions" | "max-sec" | "error" = @@ -171,10 +170,9 @@ export const runPlayLoop = ( // 3. Apply the action. `done` exits the loop; other actions go through // the CDP session and append to history. - const applyResult = yield* applyAction(action, deps.session, { - screenshotsDir: input.screenshotsDir, - storyName: input.name, - }).pipe(Effect.either); + const applyResult = yield* applyAction(action, deps.session).pipe( + Effect.either, + ); if (applyResult._tag === "Left") { const tag = (applyResult.left as { _tag?: string })._tag ?? "AgentError"; @@ -183,10 +181,6 @@ export const runPlayLoop = ( break; } - const applied = applyResult.right; - if (applied.kind === "screenshot") { - keyScreenshotPath = applied.path; - } history.push(describeAction(action)); // Capture the post-action page state as a GIF frame (best-effort). @@ -200,20 +194,16 @@ export const runPlayLoop = ( } } - // 4. Always take a final screenshot — if the model never emitted one - // explicitly, this becomes the key-screenshot fallback. - if (keyScreenshotPath === undefined) { - const fallback = path.join( - input.screenshotsDir, - `${input.name}.${FINAL_KEY_SCREENSHOT_FALLBACK}`, - ); - const sc = yield* deps.session.screenshot(fallback).pipe(Effect.either); - if (sc._tag === "Right") { - keyScreenshotPath = fallback; - } else { - keyScreenshotPath = ""; - } - } + // 4. Always take a final screenshot. This is the ONLY key-frame source — + // the model does not pick one (see model.ts § "no `screenshot` tool"). + const finalFrame = path.join( + input.screenshotsDir, + `${input.name}.${FINAL_KEY_SCREENSHOT_FALLBACK}`, + ); + const finalCapture = yield* deps.session + .screenshot(finalFrame) + .pipe(Effect.either); + const keyScreenshotPath = finalCapture._tag === "Right" ? finalFrame : ""; const endNow = now(); const chapterEndMs = endNow - input.attachedAtMs; @@ -235,15 +225,11 @@ export const runPlayLoop = ( }; }); -type Applied = - | { kind: "applied" } - | { kind: "screenshot"; path: string } - | { kind: "done" }; +type Applied = { kind: "applied" } | { kind: "done" }; const applyAction = ( action: ModelAction, session: CdpSession, - ctx: { readonly screenshotsDir: string; readonly storyName: string }, ): Effect.Effect => Match.value(action).pipe( Match.discriminatorsExhaustive("type")({ @@ -259,12 +245,6 @@ const applyAction = ( session.key(key).pipe(Effect.as({ kind: "applied" as const })), wait: ({ ms }) => session.wait(ms).pipe(Effect.as({ kind: "applied" as const })), - screenshot: () => { - const target = path.join(ctx.screenshotsDir, `${ctx.storyName}.png`); - return session - .screenshot(target) - .pipe(Effect.as({ kind: "screenshot" as const, path: target })); - }, done: () => Effect.succeed({ kind: "done" as const }), }), ); @@ -278,7 +258,6 @@ const describeAction = (action: ModelAction): string => nav: ({ url }) => `nav ${url}`, key: ({ key }) => `key ${key}`, wait: ({ ms }) => `wait ${ms}ms`, - screenshot: () => "screenshot (key frame)", done: ({ status }) => `done (${status})`, }), ); diff --git a/packages/demo-agent/src/schemas.test.ts b/packages/demo-agent/src/schemas.test.ts index e172109..c85685b 100644 --- a/packages/demo-agent/src/schemas.test.ts +++ b/packages/demo-agent/src/schemas.test.ts @@ -65,7 +65,6 @@ describe("ModelAction", () => { { type: "nav", url: "https://staging.example.com/login" }, { type: "key", key: "Enter" }, { type: "wait", ms: 500 }, - { type: "screenshot" }, { type: "done", narrative: "Signed in.", status: "passed" }, ]; for (const c of cases) { @@ -77,6 +76,11 @@ describe("ModelAction", () => { expect(() => Schema.decodeUnknownSync(ModelAction)({ type: "scroll", target: "x" }), ).toThrow(); + // `screenshot` was removed with the tool: nothing can produce it, and + // `toolCallToAction` maps the legacy tool name to a 0ms wait instead. + expect(() => + Schema.decodeUnknownSync(ModelAction)({ type: "screenshot" }), + ).toThrow(); }); }); diff --git a/packages/demo-agent/src/schemas.ts b/packages/demo-agent/src/schemas.ts index a4719a6..d167967 100644 --- a/packages/demo-agent/src/schemas.ts +++ b/packages/demo-agent/src/schemas.ts @@ -48,35 +48,33 @@ export const ModelAction = Schema.Union( type: Schema.Literal("click"), /** Accessibility-tree node ID (preferred) or CSS selector fallback. */ target: Schema.String, + /** + * In-argument chain-of-thought only — nothing downstream reads it. It + * exists so the model states its reason inside the tool call it is already + * making, rather than spending a separate turn on it. + */ rationale: Schema.optional(Schema.String), }), Schema.Struct({ type: Schema.Literal("type"), target: Schema.String, text: Schema.String, + /** In-argument chain-of-thought only — see `click.rationale`. */ rationale: Schema.optional(Schema.String), }), Schema.Struct({ type: Schema.Literal("nav"), url: Schema.String, - rationale: Schema.optional(Schema.String), }), Schema.Struct({ type: Schema.Literal("key"), /** CDP key code, e.g. "Enter", "Tab", "Escape". */ key: Schema.String, - rationale: Schema.optional(Schema.String), }), Schema.Struct({ type: Schema.Literal("wait"), /** Milliseconds (clamped 0–5000 by the loop). */ ms: Schema.Number, - rationale: Schema.optional(Schema.String), - }), - Schema.Struct({ - type: Schema.Literal("screenshot"), - /** Marks this frame as the story's "key screenshot". */ - rationale: Schema.optional(Schema.String), }), Schema.Struct({ type: Schema.Literal("done"), From 3c40949f27488cae5f785822c97a0172ff378e0b Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 28 Aug 2026 04:07:36 +0800 Subject: [PATCH 3/3] perf(demo-agent): bound the remainder count the node budget reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The budget stopped what gets rendered but not the work: on exhaustion, countNodes recursed over the entire omitted subtree to produce a number whose only job is telling the model "there is more". Unbounded traversal, and a stack proportional to tree depth, to compute a figure nobody reads precisely. Count iteratively against a 50,000-node ceiling on the TOTAL remainder, not per omitted sibling — a page with many omitted top-level nodes would otherwise pay the ceiling once per sibling. Past it the marker says "at least", rather than stating a saturated ceiling as if it were the true count. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fy9fwu7GQnyUkiGacPmDSB --- packages/demo-agent/src/cdp.test.ts | 35 +++++++++++++++++++++++++++ packages/demo-agent/src/cdp.ts | 37 +++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/demo-agent/src/cdp.test.ts b/packages/demo-agent/src/cdp.test.ts index f13329c..2edfc54 100644 --- a/packages/demo-agent/src/cdp.test.ts +++ b/packages/demo-agent/src/cdp.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { AX_NAME_CHAR_CAP, AX_NODE_BUDGET, + AX_REMAINDER_COUNT_CAP, serializeAxTree, redactWsEndpoint, type AxNode, @@ -219,3 +220,37 @@ describe("serializeAxTree", () => { expect(serializeAxTree(undefined)).toBe('WebArea ""'); }); }); + +describe("serializeAxTree remainder counting", () => { + it("counts the omitted subtree without recursing, so depth cannot overflow the stack", () => { + // A chain far deeper than the JS stack would tolerate under recursion. + let deep: Record = { role: "text", name: "leaf" }; + for (let i = 0; i < 60_000; i++) deep = { role: "group", name: "", children: [deep] }; + const tree = { + role: "WebArea", + name: "Deep", + children: [...Array.from({ length: AX_NODE_BUDGET }, () => ({ role: "button", name: "b" })), deep], + }; + const out = serializeAxTree(tree as never); + expect(out).toContain("…truncated:"); + }); + + it("says 'at least' once the remainder count saturates", () => { + const wide = { + role: "WebArea", + name: "Huge", + children: [ + ...Array.from({ length: AX_NODE_BUDGET }, () => ({ role: "button", name: "b" })), + { + role: "group", + name: "rest", + children: Array.from({ length: AX_REMAINDER_COUNT_CAP + 10 }, () => ({ role: "text", name: "x" })), + }, + ], + }; + // The exact figure is deliberately not asserted — it saturates at the cap + // and the property that matters is that the marker stops claiming precision. + expect(serializeAxTree(wide as never)).toMatch(/at least \d+ more nodes not shown/); + expect(serializeAxTree(wide as never)).toContain(`${AX_REMAINDER_COUNT_CAP}`); + }); +}); diff --git a/packages/demo-agent/src/cdp.ts b/packages/demo-agent/src/cdp.ts index af6e6ee..ea35497 100644 --- a/packages/demo-agent/src/cdp.ts +++ b/packages/demo-agent/src/cdp.ts @@ -49,6 +49,15 @@ export const AX_NODE_BUDGET = 3_000; */ export const AX_NAME_CHAR_CAP = 280; +/** + * Ceiling on how many omitted nodes the truncation marker counts. The count + * exists to tell the model the page is bigger than what it can see; past this + * many, the exact figure carries nothing the word "more" does not, and paying + * a full traversal of the omitted subtree to compute it would defeat the node + * budget it is reporting on. Beyond the cap the marker says "at least". + */ +export const AX_REMAINDER_COUNT_CAP = 50_000; + /** * Keys never rendered as a `prop=value`. `role`/`name`/`children` have their * own place in the line; `backendNodeId` and `loaderId` are set by puppeteer's @@ -112,15 +121,32 @@ export const serializeAxTree = (root: AxNode | null | undefined): string => { let budget = AX_NODE_BUDGET; let dropped = 0; - const countNodes = (node: AxNode): number => - 1 + (node.children ?? []).reduce((n, child) => n + countNodes(child), 0); + // Counting the omitted subtree must not cost more than rendering it would + // have. Recursion here would walk every remaining node — unbounded work, and + // a stack proportional to tree depth — to produce a number that only tells + // the model "there is more". Iterative, and capped: past the cap the exact + // figure stops mattering, so report it as "at least". + const countNodes = (node: AxNode, limit: number): number => { + let n = 0; + const stack: AxNode[] = [node]; + while (stack.length > 0 && n < limit) { + const next = stack.pop() as AxNode; + n += 1; + for (const child of next.children ?? []) stack.push(child); + } + return n; + }; const renderValue = (v: string | number | boolean): string => typeof v === "string" ? quoteInline(capText(v)) : String(v); const walk = (node: AxNode, depth: number): void => { if (budget <= 0) { - dropped += countNodes(node); + // The cap is on the TOTAL remainder, not per omitted sibling — otherwise + // a page with many omitted top-level nodes pays cap × siblings. + if (dropped < AX_REMAINDER_COUNT_CAP) { + dropped += countNodes(node, AX_REMAINDER_COUNT_CAP - dropped); + } return; } budget -= 1; @@ -144,8 +170,11 @@ export const serializeAxTree = (root: AxNode | null | undefined): string => { walk(root, 0); if (dropped > 0) { + // `dropped` saturates at the count cap — say "at least" rather than state a + // ceiling as if it were the true remainder. + const count = dropped >= AX_REMAINDER_COUNT_CAP ? `at least ${dropped}` : `${dropped}`; lines.push( - `${TRUNCATION_MARKER_PREFIX}…truncated: ${dropped} more nodes not shown (page exceeds the ${AX_NODE_BUDGET}-node snapshot budget)`, + `${TRUNCATION_MARKER_PREFIX}…truncated: ${count} more nodes not shown (page exceeds the ${AX_NODE_BUDGET}-node snapshot budget)`, ); } return lines.join("\n");