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-session-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

Release a newly created Browserbase session when initial attachment fails, and support upload retries
24 changes: 24 additions & 0 deletions packages/evals/core/contracts/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type ToolSurface =
| "playwright_mcp"
| "chrome_devtools_mcp"
| "stagehand_facade"
| "stagehand_facade_legacy"
Comment thread
miguelg719 marked this conversation as resolved.
| "browse_cli";

export type StartupProfile =
Expand Down Expand Up @@ -135,6 +136,22 @@ export interface ToolStartInput {
};
}

export interface BrowserSessionLoss {
cause: string;
tool?: string;
at?: string;
}

/** MCP content returned unchanged by a runner call into its existing surface. */
export interface RunnerToolCallResult {
content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
| Record<string, unknown>
>;
isError?: boolean;
}

export interface ToolStartResult {
session: CoreSession;
/**
Expand All @@ -149,6 +166,13 @@ export interface ToolStartResult {
* Implementations must swallow per-field failures and must not throw.
*/
captureEvidence?: () => Promise<ProbeEvidence>;
/** Calls the same mounted surface; this must not launch another browser. */
callTool?: (
name: string,
args: Record<string, unknown>,
options?: { timeoutMs?: number },
) => Promise<RunnerToolCallResult>;
browserSessionLoss?: () => BrowserSessionLoss | undefined;
/** Releases the runtime; `captureEvidence` is invalid after this resolves. */
cleanup: () => Promise<void>;
metadata: {
Expand Down
5 changes: 4 additions & 1 deletion packages/evals/core/runtime/coreDeps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { fileURLToPath } from "node:url";

type BrowserbaseConstructor = new (options: { apiKey: string }) => {
extensions: {
create: (payload: { file: ReadStream }) => Promise<{ id: string }>;
create: (
payload: { file: ReadStream },
options?: { maxRetries?: number },
) => Promise<{ id: string }>;
delete: (
extensionId: string,
options?: { headers?: Record<string, string | null> },
Expand Down
36 changes: 32 additions & 4 deletions packages/evals/core/targets/browserbase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createReadStream } from "node:fs";
import { AsyncLocalStorage } from "node:async_hooks";
import { registerActiveRunCleanup } from "../../framework/activeRunCleanup.js";
import { loadBrowserbaseSdk, resolveStagehandExtensionArchivePath } from "../runtime/coreDeps.js";
import { evalBrowserbaseSessionOptions } from "./browserbaseSessionOptions.js";

const DEFAULT_VIEWPORT = { width: 1288, height: 711 };
const EXTENSION_SCOPE_DRAIN_TIMEOUT_MS = 5_000;
Expand All @@ -22,9 +23,10 @@ const extensionScopeStorage = new AsyncLocalStorage<ExtensionScope>();
async function uploadStagehandExtension(
bb: BrowserbaseClient,
): Promise<{ extensionId: string; deleteUpload: () => Promise<void> }> {
const uploaded = await bb.extensions.create({
file: createReadStream(resolveStagehandExtensionArchivePath()),
});
const uploaded = await bb.extensions.create(
{ file: createReadStream(resolveStagehandExtensionArchivePath()) },
{ maxRetries: 0 },
);
const extensionId = uploaded.id.trim();
if (!extensionId) {
throw new Error("Browserbase extension upload returned an empty extension ID");
Expand Down Expand Up @@ -77,6 +79,22 @@ export async function withBrowserbaseExtensionScope<T>(fn: () => Promise<T>): Pr
}
}

/**
* One Stagehand extension upload shared by every Browserbase session the
* current run launches (the runner wraps each experiment in
* withBrowserbaseExtensionScope). Used by the facade tool so N concurrent
* facades do not each upload the archive — a burst of uploads is what
* Browserbase rejects. Returns undefined when credentials are absent.
*/
export async function acquireRunScopedStagehandExtension(): Promise<
{ extensionId: string; release: () => Promise<void> } | undefined
> {
const apiKey = process.env.BROWSERBASE_API_KEY || process.env.BB_API_KEY;
if (!apiKey) return undefined;
const Browserbase = loadBrowserbaseSdk();
return await acquireStagehandExtension(new Browserbase({ apiKey }));
}

async function acquireStagehandExtension(
bb: BrowserbaseClient,
): Promise<{ extensionId: string; release: () => Promise<void> }> {
Expand Down Expand Up @@ -131,14 +149,24 @@ export async function launchRunnerProvidedBrowserbaseChrome(): Promise<{
const Browserbase = loadBrowserbaseSdk();
const bb = new Browserbase({ apiKey });

const sessionOptions = evalBrowserbaseSessionOptions();
const extension = await acquireStagehandExtension(bb);
const { extensionId } = extension;

const createPayload: Record<string, unknown> = {
...(projectId ? { projectId } : {}),
extensionId,
// Several CDP clients share this session (the agent's MCP server, the
// harness observer, the visible-tab probe), and each connect/disconnect
// would otherwise end it — Browserbase closes non-keepAlive sessions on
// the first disconnect (observed as "410 Gone - session not running" on
// the agent's second tool call). Release is explicit in cleanup.
keepAlive: true,
timeout: sessionOptions.timeoutSeconds,
proxies: sessionOptions.proxies,
browserSettings: {
viewport: DEFAULT_VIEWPORT,
verified: sessionOptions.verified,
},
userMetadata: {
stagehand: "true",
Expand Down Expand Up @@ -204,7 +232,7 @@ export async function launchRunnerProvidedBrowserbaseChrome(): Promise<{
return {
wsUrl: created.connectUrl,
sessionId: created.id,
sessionUrl: `https://www.browserbase.com/sessions/${created.id}`,
sessionUrl: `https://www.browserbase.com/sessions/${encodeURIComponent(created.id)}`,
debugUrl,
extensionId,
cleanup,
Expand Down
55 changes: 55 additions & 0 deletions packages/evals/core/targets/browserbaseSessionOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { EvalsError } from "../../errors.js";

/**
* Browserbase's project default session timeout (15 min) is shorter than many
* benchmark tasks; every eval session gets an explicit one. Seconds, mirroring
* the Browserbase API.
*/
export const DEFAULT_EVAL_BROWSERBASE_SESSION_TIMEOUT_SECONDS = 3600;
const MAX_BROWSERBASE_SESSION_TIMEOUT_SECONDS = 21_600;

export function evalBrowserbaseSessionTimeoutSeconds(raw: string | undefined): number {
Comment thread
miguelg719 marked this conversation as resolved.
const value = raw?.trim();
if (!value) return DEFAULT_EVAL_BROWSERBASE_SESSION_TIMEOUT_SECONDS;
const parsed = Number(value);
if (
!/^\d+$/u.test(value) ||
!Number.isSafeInteger(parsed) ||
parsed < 60 ||
parsed > MAX_BROWSERBASE_SESSION_TIMEOUT_SECONDS
) {
throw new EvalsError(
`EVAL_BROWSERBASE_SESSION_TIMEOUT_SECONDS must be an integer between 60 and ${MAX_BROWSERBASE_SESSION_TIMEOUT_SECONDS} seconds (got "${value}").`,
);
}
return parsed;
}

export function evalBooleanEnv(raw: string | undefined, fallback: boolean): boolean {
const v = raw?.trim().toLowerCase();
if (!v) return fallback;
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
throw new EvalsError(`Expected a boolean env value, got "${raw}".`);
}

/**
* Session settings shared by every Browserbase path the evals own — the
* Stagehand facade (via STAGEHAND_* env) and the runner-provided CDP target
* (playwright_mcp, chrome_devtools_mcp, playwright_code). Parity with the
* native Stagehand agent path: proxied + verified sessions, explicit timeout.
* EVAL_BROWSERBASE_PROXIES / _VERIFIED=0 to disable.
*/
export function evalBrowserbaseSessionOptions(env: NodeJS.ProcessEnv = process.env): {
timeoutSeconds: number;
proxies: boolean;
verified: boolean;
} {
return {
timeoutSeconds: evalBrowserbaseSessionTimeoutSeconds(
env.EVAL_BROWSERBASE_SESSION_TIMEOUT_SECONDS,
),
proxies: evalBooleanEnv(env.EVAL_BROWSERBASE_PROXIES, true),
verified: evalBooleanEnv(env.EVAL_BROWSERBASE_VERIFIED, true),
};
}
40 changes: 40 additions & 0 deletions packages/evals/core/tools/browserSessionLoss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { sanitizeErrorMessage } from "@browserbasehq/stagehand-integrations/harness";
import type { BrowserSessionLoss } from "../contracts/tool.js";

/**
* Wire strings the facade stdio server emits when its browser session is gone.
* Source of truth: packages/integrations/core/src/facade/contract.ts
* (BROWSER_SESSION_LOST_ERROR_PREFIX, SESSION_LOST_TELEMETRY_PREFIX). Mirrored
* here because evals consumes the integrations package through its built dist,
* and a facade build is allowed to lag behind the runner.
*/
export const BROWSER_SESSION_LOST_ERROR_PREFIX = "Browser session lost (";
export const SESSION_LOST_TELEMETRY_PREFIX = "stagehand_facade_session_lost ";

export function isBrowserSessionLostError(message: string): boolean {
return message.startsWith(BROWSER_SESSION_LOST_ERROR_PREFIX);
Comment thread
miguelg719 marked this conversation as resolved.
}

/** Extracts the cause from "Browser session lost (<cause>). ..." */
export function browserSessionLostCause(message: string): string | undefined {
if (!isBrowserSessionLostError(message)) return undefined;
return sanitizeErrorMessage(/^Browser session lost \((.*?)\)\./su.exec(message)?.[1] ?? message);
}

export function parseSessionLossTelemetry(line: string): BrowserSessionLoss | undefined {
if (!line.startsWith(SESSION_LOST_TELEMETRY_PREFIX)) return undefined;
try {
const parsed = JSON.parse(line.slice(SESSION_LOST_TELEMETRY_PREFIX.length)) as Record<
string,
unknown
>;
if (typeof parsed.cause !== "string") return undefined;
return {
cause: sanitizeErrorMessage(parsed.cause),
...(typeof parsed.tool === "string" && { tool: parsed.tool }),
...(typeof parsed.at === "string" && { at: parsed.at }),
};
} catch {
return undefined;
}
}
6 changes: 5 additions & 1 deletion packages/evals/core/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { ChromeDevtoolsMcpTool } from "./chrome_devtools_mcp.js";
import { PlaywrightCodeTool } from "./playwright_code.js";
import { PlaywrightMcpTool } from "./playwright_mcp.js";
import { StagehandCodeTool } from "./stagehand_code.js";
import { StagehandFacadeTool } from "./stagehand_facade.js";
import { StagehandFacadeTool, StagehandFacadeLegacyTool } from "./stagehand_facade.js";
import { UnderstudyCodeTool } from "./understudy_code.js";

/** Surfaces that exist only as an agent MCP mount; they have no runner-driven CoreSession (activePage() throws). */
export const AGENT_MOUNT_ONLY_TOOL_SURFACES: ReadonlySet<ToolSurface> = new Set<ToolSurface>([
"stagehand_facade",
"stagehand_facade_legacy",
]);

export function isAgentMountOnlyToolSurface(toolSurface: ToolSurface): boolean {
Expand All @@ -28,6 +29,7 @@ export function listCoreTools(): ToolSurface[] {
// Listed here as part of the full enumeration, but agent-mount-only:
// core-tier selection must use listCoreRunnableTools, which filters it.
"stagehand_facade",
"stagehand_facade_legacy",
"browse_cli",
];
}
Expand All @@ -53,6 +55,8 @@ export function getCoreTool(toolSurface: ToolSurface): CoreTool {
return new ChromeDevtoolsMcpTool();
case "stagehand_facade":
return new StagehandFacadeTool();
case "stagehand_facade_legacy":
return new StagehandFacadeLegacyTool();
case "browse_cli":
return new BrowseCliTool();
default:
Expand Down
Loading
Loading