Browser: cap AI snapshots to avoid context overflow

This commit is contained in:
Sash Catanzarite
2026-01-11 21:44:08 -08:00
committed by Peter Steinberger
parent 097e66391f
commit d5d8c01dc7
5 changed files with 64 additions and 3 deletions

View File

@@ -117,6 +117,7 @@ const BrowserToolSchema = Type.Object({
targetUrl: Type.Optional(Type.String()), targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()), targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()),
format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])), format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])),
fullPage: Type.Optional(Type.Boolean()), fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()), ref: Type.Optional(Type.String()),
@@ -323,10 +324,16 @@ export function createBrowserTool(opts?: {
typeof params.limit === "number" && Number.isFinite(params.limit) typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit ? params.limit
: undefined; : undefined;
const maxChars =
typeof params.maxChars === "number" &&
Number.isFinite(params.maxChars)
? params.maxChars
: undefined;
const snapshot = await browserSnapshot(baseUrl, { const snapshot = await browserSnapshot(baseUrl, {
format, format,
targetId, targetId,
limit, limit,
maxChars,
profile, profile,
}); });
if (snapshot.format === "ai") { if (snapshot.format === "ai") {

View File

@@ -71,6 +71,7 @@ export type SnapshotResult =
targetId: string; targetId: string;
url: string; url: string;
snapshot: string; snapshot: string;
truncated?: boolean;
}; };
export function resolveBrowserControlUrl(overrideUrl?: string) { export function resolveBrowserControlUrl(overrideUrl?: string) {
@@ -248,6 +249,7 @@ export async function browserSnapshot(
format: "aria" | "ai"; format: "aria" | "ai";
targetId?: string; targetId?: string;
limit?: number; limit?: number;
maxChars?: number;
profile?: string; profile?: string;
}, },
): Promise<SnapshotResult> { ): Promise<SnapshotResult> {
@@ -255,6 +257,9 @@ export async function browserSnapshot(
q.set("format", opts.format); q.set("format", opts.format);
if (opts.targetId) q.set("targetId", opts.targetId); if (opts.targetId) q.set("targetId", opts.targetId);
if (typeof opts.limit === "number") q.set("limit", String(opts.limit)); if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
if (typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)) {
q.set("maxChars", String(opts.maxChars));
}
if (opts.profile) q.set("profile", opts.profile); if (opts.profile) q.set("profile", opts.profile);
return await fetchBrowserJson<SnapshotResult>( return await fetchBrowserJson<SnapshotResult>(
`${baseUrl}/snapshot?${q.toString()}`, `${baseUrl}/snapshot?${q.toString()}`,

View File

@@ -92,6 +92,28 @@ describe("pw-ai", () => {
expect(p2.session.detach).toHaveBeenCalledTimes(1); expect(p2.session.detach).toHaveBeenCalledTimes(1);
}); });
it("truncates oversized snapshots", async () => {
const { chromium } = await import("playwright-core");
const longSnapshot = "A".repeat(20);
const p1 = createPage({ targetId: "T1", snapshotFull: longSnapshot });
const browser = createBrowser([p1.page]);
(
chromium.connectOverCDP as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue(browser);
const mod = await importModule();
const res = await mod.snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
maxChars: 10,
});
expect(res.truncated).toBe(true);
expect(res.snapshot.startsWith("AAAAAAAAAA")).toBe(true);
expect(res.snapshot).toContain("TRUNCATED");
});
it("clicks a ref using aria-ref locator", async () => { it("clicks a ref using aria-ref locator", async () => {
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const p1 = createPage({ targetId: "T1" }); const p1 = createPage({ targetId: "T1" });

View File

@@ -10,6 +10,8 @@ import {
let nextUploadArmId = 0; let nextUploadArmId = 0;
let nextDialogArmId = 0; let nextDialogArmId = 0;
const MAX_SNAPSHOT_CHARS = 80_000;
function requireRef(value: unknown): string { function requireRef(value: unknown): string {
const ref = typeof value === "string" ? value.trim() : ""; const ref = typeof value === "string" ? value.trim() : "";
if (!ref) throw new Error("ref is required"); if (!ref) throw new Error("ref is required");
@@ -20,7 +22,8 @@ export async function snapshotAiViaPlaywright(opts: {
cdpUrl: string; cdpUrl: string;
targetId?: string; targetId?: string;
timeoutMs?: number; timeoutMs?: number;
}): Promise<{ snapshot: string }> { maxChars?: number;
}): Promise<{ snapshot: string; truncated?: boolean }> {
const page = await getPageForTargetId({ const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl, cdpUrl: opts.cdpUrl,
targetId: opts.targetId, targetId: opts.targetId,
@@ -41,7 +44,18 @@ export async function snapshotAiViaPlaywright(opts: {
), ),
track: "response", track: "response",
}); });
return { snapshot: String(result?.full ?? "") }; const maxChars = opts.maxChars;
const limit =
typeof maxChars === "number" && Number.isFinite(maxChars) && maxChars > 0
? Math.floor(maxChars)
: MAX_SNAPSHOT_CHARS;
let snapshot = String(result?.full ?? "");
let truncated = false;
if (snapshot.length > limit) {
snapshot = `${snapshot.slice(0, limit)}\n\n[...TRUNCATED - page too large]`;
truncated = true;
}
return truncated ? { snapshot, truncated } : { snapshot };
} }
export async function clickViaPlaywright(opts: { export async function clickViaPlaywright(opts: {

View File

@@ -560,17 +560,30 @@ export function registerBrowserAgentRoutes(
: (await getPwAiModule()) : (await getPwAiModule())
? "ai" ? "ai"
: "aria"; : "aria";
const limit = const limitRaw =
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const maxCharsRaw =
typeof req.query.maxChars === "string"
? Number(req.query.maxChars)
: undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars = Number.isFinite(maxCharsRaw) ? maxCharsRaw : undefined;
try { try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined); const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if (format === "ai") { if (format === "ai") {
const pw = await requirePwAi(res, "ai snapshot"); const pw = await requirePwAi(res, "ai snapshot");
if (!pw) return; if (!pw) return;
const resolvedMaxChars =
typeof maxChars === "number" && maxChars > 0
? maxChars
: typeof limit === "number" && limit > 0
? limit
: undefined;
const snap = await pw.snapshotAiViaPlaywright({ const snap = await pw.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl, cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
...(resolvedMaxChars ? { maxChars: resolvedMaxChars } : {}),
}); });
return res.json({ return res.json({
ok: true, ok: true,