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
5 changes: 5 additions & 0 deletions .changeset/eval-capture-deadlines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

Bound experimental batch and RPC deadlines so callers can stop waiting without replaying actions or accepting late capture state.
5 changes: 5 additions & 0 deletions packages/evals/core/contracts/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ export interface BrowserSessionLoss {
cause: string;
tool?: string;
at?: string;
provider?: "local" | "browserbase";
sessionId?: string;
/** Elapsed time since the facade started browser launch, including initialization. */
sessionAgeMs?: number;
sessionTimeoutMs?: number;
}

/** MCP content returned unchanged by a runner call into its existing surface. */
Expand Down
16 changes: 16 additions & 0 deletions packages/evals/core/tools/browserSessionLoss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ export function parseSessionLossTelemetry(line: string): BrowserSessionLoss | un
cause: sanitizeErrorMessage(parsed.cause),
...(typeof parsed.tool === "string" && { tool: parsed.tool }),
...(typeof parsed.at === "string" && { at: parsed.at }),
...((parsed.provider === "local" || parsed.provider === "browserbase") && {
provider: parsed.provider,
}),
...(typeof parsed.sessionId === "string" && {
sessionId: sanitizeErrorMessage(parsed.sessionId),
}),
...(typeof parsed.sessionAgeMs === "number" &&
Number.isFinite(parsed.sessionAgeMs) &&
parsed.sessionAgeMs >= 0 && {
sessionAgeMs: parsed.sessionAgeMs,
}),
...(typeof parsed.sessionTimeoutMs === "number" &&
Number.isFinite(parsed.sessionTimeoutMs) &&
parsed.sessionTimeoutMs >= 0 && {
sessionTimeoutMs: parsed.sessionTimeoutMs,
}),
};
} catch {
return undefined;
Expand Down
70 changes: 70 additions & 0 deletions packages/evals/tests/core/browserSessionLossTelemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
parseSessionLossTelemetry,
SESSION_LOST_TELEMETRY_PREFIX,
} from "../../core/tools/browserSessionLoss.js";

function line(fields: Record<string, unknown>) {
return (
SESSION_LOST_TELEMETRY_PREFIX + JSON.stringify({ cause: "CDP connection closed", ...fields })
);
}

describe("facade session loss diagnostics", () => {
it("retains browser identity, measured age and configured timeout", () => {
expect(
parseSessionLossTelemetry(
line({
tool: "snapshot",
at: "2026-09-08T00:00:00.000Z",
provider: "browserbase",
sessionId: "session-123",
sessionAgeMs: 12_500,
sessionTimeoutMs: 3_600_000,
}),
),
).toEqual({
cause: "CDP connection closed",
tool: "snapshot",
at: "2026-09-08T00:00:00.000Z",
provider: "browserbase",
sessionId: "session-123",
sessionAgeMs: 12_500,
sessionTimeoutMs: 3_600_000,
});
});

it.each([-1, "12000", null, {}, 1e309])("drops invalid diagnostic durations: %j", (value) => {
expect(
parseSessionLossTelemetry(line({ sessionAgeMs: value, sessionTimeoutMs: value })),
).toEqual({ cause: "CDP connection closed" });
});

it("accepts zero age and omits invalid identity metadata", () => {
expect(
parseSessionLossTelemetry(line({ provider: "other", sessionId: 42, sessionAgeMs: 0 })),
).toEqual({ cause: "CDP connection closed", sessionAgeMs: 0 });
});

it("drops JSON numeric overflow without losing the terminal cause", () => {
expect(
parseSessionLossTelemetry(
SESSION_LOST_TELEMETRY_PREFIX +
'{"cause":"CDP connection closed","sessionAgeMs":1e309,"sessionTimeoutMs":1e309}',
),
).toEqual({ cause: "CDP connection closed" });
});

it("sanitizes string diagnostics while retaining valid numeric metadata", () => {
const parsed = parseSessionLossTelemetry(
line({
provider: "local",
sessionId: "wss://example.test/?apiKey=synthetic-key",
sessionAgeMs: 1,
}),
);
expect(parsed?.provider).toBe("local");
expect(parsed?.sessionAgeMs).toBe(1);
expect(JSON.stringify(parsed)).not.toContain("synthetic-key");
});
});
1 change: 1 addition & 0 deletions packages/integrations/core/src/facade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {
} from "./contract.js";
export {
StagehandFacadeTools,
StagehandFacadeSessionLostError,
type StagehandFacadeRunReport,
type StagehandFacadeToolsOptions,
} from "./tools.js";
Expand Down
45 changes: 43 additions & 2 deletions packages/integrations/core/src/facade/screenshot-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,65 @@ export function screenshotBase64BudgetFromArgs(args: string[]): number | undefin
return budget;
}

/**
* Model APIs reject images with a side longer than this. Anthropic allows
* 8000 px for a lone image but only 2000 px once a request carries many
* images, which every multi-step agent conversation does. Full-page captures
* of long pages exceed both and killed whole runs with a 400, so oversized
* captures fall back to the viewport like over-budget ones do.
*/
export const MAX_SCREENSHOT_SIDE_PX = 2000;

export async function captureScreenshotWithinBase64Budget(
capture: CaptureScreenshot,
requested: ScreenshotOptions,
maxBase64Bytes: number,
maxSidePx = MAX_SCREENSHOT_SIDE_PX,
): Promise<TransportSafeScreenshot> {
const attempts = screenshotAttempts(requested);
for (const [index, options] of attempts.entries()) {
const image = await capture(options);
if (Buffer.byteLength(image.data, "utf8") <= maxBase64Bytes) {
if (Buffer.byteLength(image.data, "utf8") > maxBase64Bytes) continue;
const size = imageDimensions(image);
const tooLarge = size !== undefined && Math.max(size.width, size.height) > maxSidePx;
if (!tooLarge) {
return { image, options, adjusted: index > 0 || !sameOptions(options, requested) };
}
}

throw new Error(
`Screenshot exceeds the ${maxBase64Bytes}-byte MCP transport budget after compressed viewport retries.`,
`Screenshot exceeds the ${maxBase64Bytes}-byte MCP transport budget or the ${maxSidePx}px side limit after compressed viewport retries.`,
);
}

/** Reads width/height from a PNG or JPEG header; undefined when unparseable. */
export function imageDimensions(
image: StagehandFacadeScreenshot,
): { width: number; height: number } | undefined {
const bytes = Buffer.from(image.data, "base64");
if (image.mimeType === "image/png") {
if (bytes.length < 24 || bytes.toString("ascii", 1, 4) !== "PNG") return undefined;
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
// JPEG: walk the marker segments to the first SOFn (C0–CF except C4, C8, CC).
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined;
let offset = 2;
while (offset + 9 <= bytes.length) {
if (bytes[offset] !== 0xff) return undefined;
const marker = bytes[offset + 1]!;
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
offset += 2;
continue;
}
const length = bytes.readUInt16BE(offset + 2);
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) };
}
offset += 2 + length;
}
return undefined;
}

function screenshotAttempts(requested: ScreenshotOptions): ScreenshotOptions[] {
const initial: ScreenshotOptions = {
fullPage: requested.fullPage ?? false,
Expand Down
21 changes: 21 additions & 0 deletions packages/integrations/core/src/facade/stdio-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
facadeSurfaceFromArgs,
facadeToolsFor,
SESSION_INFO_TOOL_NAME,
SESSION_LOST_TELEMETRY_PREFIX,
ScreenshotInputSchema,
SnapshotInputSchema,
} from "./contract.js";
Expand Down Expand Up @@ -132,6 +133,7 @@ async function ensureResources(): Promise<FacadeResources> {

async function createResources(): Promise<FacadeResources> {
const config = stagehandFacadeConfigFromEnv();
const launchedAt = Date.now();
const browser =
config.browser.type === "browserbase"
? await browserbase.launch(config.browser.launchOptions)
Expand All @@ -141,6 +143,25 @@ async function createResources(): Promise<FacadeResources> {
const tools = new StagehandFacadeTools(stagehand, {
onRunReport: (report) =>
process.stderr.write(`stagehand_playwright_compat ${JSON.stringify(report)}\n`),
// The browser is not recreated on purpose: a fresh session would silently
// change the evidence trail mid-task. Tools keep answering with the
// terminal error and the host decides what to do with the run.
// Age includes launch and initialization time. Compare it with configured
// timeout and remote session status when diagnosing a disconnect.
onSessionLost: (loss) =>
process.stderr.write(
`${SESSION_LOST_TELEMETRY_PREFIX}${JSON.stringify({
...loss,
cause: sanitizeErrorMessage(loss.cause),
provider: browser.provider,
...(browser.sessionId && { sessionId: browser.sessionId }),
sessionAgeMs: Date.now() - launchedAt,
Comment thread
miguelg719 marked this conversation as resolved.
...(config.browser.type === "browserbase" &&
typeof config.browser.launchOptions.timeout === "number" && {
sessionTimeoutMs: config.browser.launchOptions.timeout * 1000,
}),
})}\n`,
),
});
return { browser, stagehand, tools };
} catch (error) {
Expand Down
Loading
Loading