diff --git a/apps/templates/stagehand-extension/.gitignore b/apps/templates/stagehand-extension/.gitignore new file mode 100644 index 0000000000..fff71c6847 --- /dev/null +++ b/apps/templates/stagehand-extension/.gitignore @@ -0,0 +1,9 @@ +node_modules +.env* +.eve +.vercel +.output +.nitro +dist +.DS_Store +*.tsbuildinfo diff --git a/apps/templates/stagehand-extension/README.md b/apps/templates/stagehand-extension/README.md new file mode 100644 index 0000000000..28fdad30ab --- /dev/null +++ b/apps/templates/stagehand-extension/README.md @@ -0,0 +1,82 @@ +# Stagehand extension template + +This package demonstrates an eve extension that imports the published Stagehand v4 SDK directly. +It contributes native `run`, `snapshot`, and `screenshot` tools without an MCP bridge or a copied +Playwright compatibility layer. + +The extension declares `@browserbasehq/stagehand` and `@browserbasehq/sdk` in +`eve.extension.externalDependencies`. Stagehand loads browser-extension assets relative to its +installed package, and the Browserbase SDK provides a bounded release fallback if the browser +transport fails during initialization or cleanup. eve preserves both dependencies when it builds a +consuming agent. + +The `run` callback receives Stagehand v4's `Page` and `BrowserContext` objects, not complete +Playwright objects. The bundled agent instructions enumerate the supported methods and warn models +not to guess Playwright-only helpers. Snapshot IDs are currently descriptive rather than actionable +selectors because this template does not retain Stagehand's snapshot lookup maps between tools. + +## Use in an eve project + +This is a private source template rather than a published package. Its source manifest intentionally +uses the eve monorepo's `workspace:` and `catalog:` dependency ranges. Consume it as a workspace +package, or pack it before installing it elsewhere so pnpm materializes concrete dependency +versions. + +For an agent package named `my-agent` in the same pnpm workspace, add the template as a workspace +dependency: + +```bash +pnpm --filter my-agent add "@eve-template/stagehand-extension@workspace:*" +``` + +To try it from a separate eve project, create a local package artifact from this repository and add +that artifact to the agent project: + +```bash +mkdir -p /tmp/eve-stagehand-extension +pnpm --filter @eve-template/stagehand-extension build +pnpm --filter @eve-template/stagehand-extension pack \ + --pack-destination /tmp/eve-stagehand-extension + +cd /path/to/eve-agent +pnpm add /tmp/eve-stagehand-extension/eve-template-stagehand-extension-0.0.0.tgz +``` + +Mount it by creating `agent/extensions/browser.ts` in the consuming project: + +```ts +export { default } from "@eve-template/stagehand-extension"; +``` + +## Build and test + +```bash +pnpm --filter @eve-template/stagehand-extension build +pnpm --filter @eve-template/stagehand-extension typecheck +pnpm --filter @eve-template/stagehand-extension test:unit +``` + +## Browser configuration + +Set `BROWSERBASE_API_KEY` and optionally `BROWSERBASE_PROJECT_ID` to use Browserbase. Without an API +key, the extension launches a headed local browser. Set `STAGEHAND_BROWSER` to `local` or +`browserbase` to choose explicitly. `BROWSERBASE_API_URL` can override the Browserbase API endpoint +for both launch and release. + +The three tools share one browser for the life of the eve process. Browserbase sessions do not use +keep-alive, and `run` code can call `close()` to make the host close both Stagehand and the browser. +The next tool call starts a fresh browser. Tool operations, health probes, and cleanup are bounded; +a hung browser call is detached and cannot permanently block the serialized operation queue. If +browser cleanup fails, the extension requests release through the Browserbase SDK and retries a +failed release before launching another browser. Model-visible cleanup errors use stable messages +without exposing SDK, transport, or session details. + +## Code execution boundary + +`run` compiles model-authored JavaScript in the Node.js host before sending the serializable callback +to Stagehand. The callback executes in Stagehand's browser extension, where it can use `page`, +`context`, `act`, `observe`, and `extract`. Calling `close()` sends a cleanup request back to the host. + +Browserbase provides the recommended isolation boundary. The callback does not execute in eve's Node +process, but it is still powerful browser-side code and should not be treated as a sandbox for +hostile input. diff --git a/apps/templates/stagehand-extension/extension/extension.ts b/apps/templates/stagehand-extension/extension/extension.ts new file mode 100644 index 0000000000..eb45471fd7 --- /dev/null +++ b/apps/templates/stagehand-extension/extension/extension.ts @@ -0,0 +1,3 @@ +import { defineExtension } from "eve/extension"; + +export default defineExtension(); diff --git a/apps/templates/stagehand-extension/extension/instructions/browser.md b/apps/templates/stagehand-extension/extension/instructions/browser.md new file mode 100644 index 0000000000..741fd4347a --- /dev/null +++ b/apps/templates/stagehand-extension/extension/instructions/browser.md @@ -0,0 +1,41 @@ +Use the Stagehand tools to control one persistent browser. Operations are serialized, and browser +state persists across calls. + +- Use `snapshot` to inspect the active page. Its accessibility IDs are descriptive, not selectors. +- Use `run` for navigation and multi-step browser operations. It accepts the body of an async + JavaScript function; write direct `await` statements and return a JSON-serializable result. +- Use `screenshot` when visual inspection is useful. + +`run` provides `page`, `context`, `act`, `observe`, `extract`, and `close`. `page` is Stagehand v4's +`Page`, not a Playwright `Page`. Treat these method lists as allow-lists and do not guess Playwright +methods such as `getByRole`, `getByText`, `frameLocator`, or `keyboard`. + +Supported page methods include `goto`, `reload`, `goBack`, `goForward`, `click`, `hover`, `scroll`, +`dragAndDrop`, `type`, `keyPress`, `evaluate`, `addInitScript`, `setExtraHTTPHeaders`, +`setViewportSize`, `waitForLoadState`, `waitForTimeout`, `waitForSelector`, `screenshot`, `snapshot`, +`tools`, `url`, `title`, `close`, and `locator`. + +Supported locator methods include `click`, `hover`, `fill`, `count`, `isChecked`, `inputValue`, +`isVisible`, `innerText`, `innerHtml`, `textContent`, `scrollTo`, `centroid`, `highlight`, +`sendClickEvent`, `type`, `selectOption`, `setInputFiles`, `first`, and `nth`. Locators take CSS or +XPath selectors. For example: + +```js +await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); +await page.locator("a").click(); +return { title: await page.title(), url: await page.url() }; +``` + +Use Stagehand's exact signatures: `page.setViewportSize(width, height)`, `page.keyPress(key)`, and +`locator.scrollTo(percent)`. A locator does not provide `press` or `keyPress`. + +Supported context methods include `pages`, `newPage`, `activePage`, `setActivePage`, +`addInitScript`, `setExtraHTTPHeaders`, `getDomainPolicy`, `setDomainPolicy`, `cookies`, +`addCookies`, and `clearCookies`. + +Await `page.url()`, `page.title()`, and every context method. Use `page.evaluate` for DOM queries or +attributes that the locator allow-list does not cover. Use `act`, `observe`, and `extract` for +AI-assisted operations. Do not import packages, access Node.js APIs, or launch another browser. + +Call `close()` in the final `run` after collecting the result when the browser session is no longer +needed. It asks the host to release the owned browser after the callback returns. diff --git a/apps/templates/stagehand-extension/extension/lib/run.ts b/apps/templates/stagehand-extension/extension/lib/run.ts new file mode 100644 index 0000000000..fb5aaf2f4e --- /dev/null +++ b/apps/templates/stagehand-extension/extension/lib/run.ts @@ -0,0 +1,88 @@ +import type { ExperimentalBatchCallback } from "@browserbasehq/stagehand"; + +import { stagehandSession, type StagehandSession } from "./session.js"; + +interface RunEnvelope { + value?: unknown; + closeRequested: boolean; + executionError?: { + name: string; + message: string; + stack?: string; + }; +} + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new ( + ...args: string[] +) => ExperimentalBatchCallback, RunEnvelope>; + +export function compileRunCallback( + code: string, +): ExperimentalBatchCallback, RunEnvelope> { + if (code.trim().length === 0) throw new TypeError("run code must not be empty"); + + return new AsyncFunction( + "batch", + "input", + `"use strict"; +const { page, context, act, observe, extract } = batch; +let closeRequested = false; +const close = async () => { closeRequested = true; }; +let value; +let executionError; +try { + value = await (async () => { +${code} + })(); +} catch (error) { + executionError = { + name: typeof error?.name === "string" ? error.name : "Error", + message: typeof error?.message === "string" ? error.message : String(error), + }; + if (typeof error?.stack === "string") executionError.stack = error.stack; +} +return { value, closeRequested, executionError };`, + ); +} + +export async function runStagehandCode( + code: string, + session: StagehandSession = stagehandSession, +): Promise { + const callback = compileRunCallback(code); + const value = await session.run(async (resources) => { + const envelope = await resources.stagehand.experimentalBatch(callback, {}, { timeout: 60_000 }); + let cleanupError: unknown; + if (envelope.closeRequested) { + try { + await session.close(resources); + } catch (error) { + cleanupError = error; + } + } + + if (envelope.executionError) { + const error = new Error(envelope.executionError.message); + error.name = envelope.executionError.name; + if (envelope.executionError.stack) error.stack = envelope.executionError.stack; + if (cleanupError) { + throw new AggregateError([error, cleanupError], "Run failed and cleanup also failed.", { + cause: error, + }); + } + throw error; + } + if (cleanupError) throw cleanupError; + return envelope.value; + }); + return stringifyResult(value); +} + +function stringifyResult(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} diff --git a/apps/templates/stagehand-extension/extension/lib/session-release.ts b/apps/templates/stagehand-extension/extension/lib/session-release.ts new file mode 100644 index 0000000000..1cd02c1587 --- /dev/null +++ b/apps/templates/stagehand-extension/extension/lib/session-release.ts @@ -0,0 +1,47 @@ +import Browserbase from "@browserbasehq/sdk"; + +export interface BrowserbaseSessionRelease { + apiKey: string; + baseUrl?: string; + sessionId: string; +} + +const BROWSERBASE_API_URL = "https://api.browserbase.com"; +const SESSION_RELEASE_MAX_RETRIES = 2; +const SESSION_RELEASE_TIMEOUT_MS = 10_000; + +export class BrowserbaseSessionReleaseError extends Error { + override readonly name = "BrowserbaseSessionReleaseError"; + + constructor() { + super("Failed to release the Browserbase session."); + } +} + +export async function releaseBrowserbaseSession(session: BrowserbaseSessionRelease): Promise { + const browserbase = new Browserbase({ + apiKey: session.apiKey, + baseURL: (session.baseUrl ?? BROWSERBASE_API_URL).replace(/\/+$/u, ""), + maxRetries: SESSION_RELEASE_MAX_RETRIES, + timeout: SESSION_RELEASE_TIMEOUT_MS, + }); + // The generated SDK currently interpolates path parameters without encoding + // them. Keep the server-issued ID confined to one URL path segment. + const sessionId = encodeURIComponent(session.sessionId); + + try { + await browserbase.sessions.update(sessionId, { status: "REQUEST_RELEASE" }); + return; + } catch { + // Verify the remote state below before reporting a failed retry. + } + + try { + const remoteSession = await browserbase.sessions.retrieve(sessionId); + if (remoteSession.status === "COMPLETED") return; + } catch { + // Fall through to the stable lifecycle error below. + } + + throw new BrowserbaseSessionReleaseError(); +} diff --git a/apps/templates/stagehand-extension/extension/lib/session.ts b/apps/templates/stagehand-extension/extension/lib/session.ts new file mode 100644 index 0000000000..ad5f3e8456 --- /dev/null +++ b/apps/templates/stagehand-extension/extension/lib/session.ts @@ -0,0 +1,293 @@ +import { + browserbase, + localBrowser, + Stagehand, + type BrowserbaseLaunchOptions, + type StagehandBrowser, +} from "@browserbasehq/stagehand"; + +import { BrowserbaseSessionReleaseError, releaseBrowserbaseSession } from "./session-release.js"; + +type StagehandSessionRelease = () => Promise; + +export interface StagehandBrowserLaunch { + browser: StagehandBrowser; + releaseSession?: StagehandSessionRelease; +} + +export type StagehandBrowserLauncher = () => Promise; +export type StagehandCreator = (browser: StagehandBrowser) => Promise; + +export interface StagehandResources { + browser: StagehandBrowser; + stagehand: Stagehand; + releaseSession?: StagehandSessionRelease; +} + +export type StagehandResourceFactory = () => Promise; +export type StagehandResourceCleanup = (resources: StagehandResources) => Promise; + +export interface StagehandSessionOptions { + operationTimeoutMs?: number; + healthCheckTimeoutMs?: number; + cleanupTimeoutMs?: number; +} + +const DEFAULT_OPERATION_TIMEOUT_MS = 75_000; +const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 5_000; +const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000; + +export class StagehandSessionCleanupError extends Error { + override readonly name = "StagehandSessionCleanupError"; + + constructor() { + super("Failed to close the Stagehand browser session."); + } +} + +export class StagehandSessionInitializationError extends Error { + override readonly name = "StagehandSessionInitializationError"; + + constructor() { + super("Stagehand initialization failed and the browser session could not be closed."); + } +} + +const defaultResourceFactory = createStagehandResourceFactory(); + +export class StagehandSession { + private resources: StagehandResources | undefined; + private resourcesPromise: Promise | undefined; + private operationQueue: Promise = Promise.resolve(); + + constructor( + private readonly createResources: StagehandResourceFactory = defaultResourceFactory, + private readonly cleanupResources: StagehandResourceCleanup = closeStagehandResources, + private readonly options: StagehandSessionOptions = {}, + ) {} + + run(operation: (resources: StagehandResources) => Promise): Promise { + const execute = () => this.execute(operation); + const result = this.operationQueue.then(execute, execute); + this.operationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async close(expected: StagehandResources): Promise { + if (!this.detach(expected)) return; + await withTimeout( + this.cleanupResources(expected), + this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS, + "Stagehand browser cleanup", + ); + } + + private async execute( + operation: (resources: StagehandResources) => Promise, + ): Promise { + const current = await this.ensureResources(); + try { + return await withTimeout( + operation(current), + this.options.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS, + "Stagehand operation", + ); + } catch (error) { + const operationTimedOut = error instanceof StagehandTimeoutError; + const healthy = + !operationTimedOut && + (await withTimeout( + resourcesAreHealthy(current), + this.options.healthCheckTimeoutMs ?? DEFAULT_HEALTH_CHECK_TIMEOUT_MS, + "Stagehand health check", + ).catch(() => false)); + if (this.resources === current && !healthy) { + await this.invalidate(current); + } + throw error; + } + } + + private async ensureResources(): Promise { + if (this.resources && !this.resources.browser.closed) return this.resources; + if (this.resources) await this.invalidate(this.resources); + + const pending = (this.resourcesPromise ??= this.createResources()); + try { + const created = await pending; + if (this.resourcesPromise === pending) this.resources = created; + return created; + } catch (error) { + if (this.resourcesPromise === pending) this.resourcesPromise = undefined; + throw error; + } + } + + private async invalidate(expected: StagehandResources): Promise { + if (!this.detach(expected)) return; + await withTimeout( + this.cleanupResources(expected), + this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS, + "Stagehand browser cleanup", + ).catch(() => undefined); + } + + private detach(expected: StagehandResources): boolean { + if (this.resources !== expected) return false; + this.resources = undefined; + this.resourcesPromise = undefined; + return true; + } +} + +export const stagehandSession = new StagehandSession(); + +export function createStagehandResourceFactory( + launchBrowser: StagehandBrowserLauncher = createBrowser, + createStagehand: StagehandCreator = (browser) => Stagehand.create({ browser }), +): StagehandResourceFactory { + const pendingReleases = new Set(); + + return async () => { + await retryPendingReleases(pendingReleases); + const launched = await launchBrowser(); + const releaseSession = launched.releaseSession + ? trackRelease(launched.releaseSession, pendingReleases) + : undefined; + try { + const stagehand = await createStagehand(launched.browser); + const resources: StagehandResources = { browser: launched.browser, stagehand }; + if (releaseSession) resources.releaseSession = releaseSession; + return resources; + } catch (error) { + let browserCloseFailed = false; + await launched.browser.close().catch(() => { + browserCloseFailed = true; + }); + if (browserCloseFailed && releaseSession) { + try { + await releaseSession(); + browserCloseFailed = false; + } catch { + throw new StagehandSessionInitializationError(); + } + } + if (browserCloseFailed) throw new StagehandSessionInitializationError(); + throw error; + } + }; +} + +async function createBrowser(): Promise { + const requestedBrowser = process.env.STAGEHAND_BROWSER; + if ( + requestedBrowser !== undefined && + requestedBrowser !== "local" && + requestedBrowser !== "browserbase" + ) { + throw new Error('STAGEHAND_BROWSER must be either "local" or "browserbase".'); + } + + const apiKey = process.env.BROWSERBASE_API_KEY; + const browserType = requestedBrowser ?? (apiKey ? "browserbase" : "local"); + if (browserType === "local") { + return { browser: await localBrowser.launch({ headless: false }) }; + } + if (!apiKey) { + throw new Error('BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".'); + } + + const baseUrl = process.env.BROWSERBASE_API_URL; + const launchOptions: BrowserbaseLaunchOptions = { + apiKey, + keepAlive: false, + }; + if (baseUrl) launchOptions.baseUrl = baseUrl; + if (process.env.BROWSERBASE_PROJECT_ID) { + launchOptions.projectId = process.env.BROWSERBASE_PROJECT_ID; + } + const browser = await browserbase.launch(launchOptions); + const sessionId = browser.sessionId; + const launched: StagehandBrowserLaunch = { browser }; + if (sessionId) { + launched.releaseSession = () => releaseBrowserbaseSession({ apiKey, baseUrl, sessionId }); + } + return launched; +} + +export async function closeStagehandResources(resources: StagehandResources): Promise { + const [, browserClose] = await Promise.allSettled([ + resources.stagehand.close(), + resources.browser.closed ? Promise.resolve() : resources.browser.close(), + ]); + + // Stagehand.close() performs its local teardown in a finally block, but its closing RPC can lose + // the CDP transport before the response arrives. Once browser.close() succeeds, the owned local + // browser or Browserbase session is released, so that transport error is no longer actionable. + if (browserClose.status !== "rejected") return; + if (resources.releaseSession) { + try { + await resources.releaseSession(); + return; + } catch { + // The tracked release is retried before the next browser launch. + } + } + throw new StagehandSessionCleanupError(); +} + +function trackRelease( + releaseSession: StagehandSessionRelease, + pendingReleases: Set, +): StagehandSessionRelease { + const trackedRelease = async () => { + try { + await releaseSession(); + pendingReleases.delete(trackedRelease); + } catch { + pendingReleases.add(trackedRelease); + throw new BrowserbaseSessionReleaseError(); + } + }; + return trackedRelease; +} + +async function retryPendingReleases(pendingReleases: Set): Promise { + for (const releaseSession of pendingReleases) await releaseSession(); +} + +class StagehandTimeoutError extends Error { + constructor(label: string, timeoutMs: number) { + super(`${label} timed out after ${timeoutMs}ms.`); + this.name = "StagehandTimeoutError"; + } +} + +async function withTimeout( + operation: Promise, + timeoutMs: number, + label: string, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new StagehandTimeoutError(label, timeoutMs)), timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function resourcesAreHealthy(resources: StagehandResources): Promise { + if (resources.browser.closed) return false; + try { + await resources.browser.context.pages(); + return true; + } catch { + return false; + } +} diff --git a/apps/templates/stagehand-extension/extension/tools/run.ts b/apps/templates/stagehand-extension/extension/tools/run.ts new file mode 100644 index 0000000000..1d1bc77fbb --- /dev/null +++ b/apps/templates/stagehand-extension/extension/tools/run.ts @@ -0,0 +1,15 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { runStagehandCode } from "../lib/run.js"; + +export default defineTool({ + description: + "Run JavaScript against the active Stagehand v4 page. Code can use page, context, act, observe, extract, and close, must await async methods, and must return its result. The page supports Stagehand's documented Page and Locator methods, not Playwright-only helpers.", + inputSchema: z.object({ + code: z.string().min(1), + }), + async execute({ code }) { + return runStagehandCode(code); + }, +}); diff --git a/apps/templates/stagehand-extension/extension/tools/screenshot.ts b/apps/templates/stagehand-extension/extension/tools/screenshot.ts new file mode 100644 index 0000000000..fa324ebe73 --- /dev/null +++ b/apps/templates/stagehand-extension/extension/tools/screenshot.ts @@ -0,0 +1,34 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { stagehandSession } from "../lib/session.js"; + +export default defineTool({ + description: "Capture a screenshot of the active Stagehand page.", + inputSchema: z.object({ + fullPage: z.boolean().default(false), + type: z.enum(["png", "jpeg"]).default("png"), + quality: z.number().int().min(0).max(100).optional(), + }), + async execute({ fullPage, type, quality }) { + return stagehandSession.run(async ({ browser }) => { + const page = (await browser.context.activePage()) ?? (await browser.context.newPage()); + const options: Parameters[0] = { fullPage, type }; + if (type === "jpeg" && quality !== undefined) options.quality = quality; + const bytes = await page.screenshot(options); + return { + data: Buffer.from(bytes).toString("base64"), + mimeType: type === "jpeg" ? "image/jpeg" : "image/png", + }; + }); + }, + toModelOutput({ data, mimeType }) { + return { + type: "content", + value: [ + { type: "text", text: "Screenshot captured." }, + { type: "file", data: { type: "data", data }, mediaType: mimeType }, + ], + }; + }, +}); diff --git a/apps/templates/stagehand-extension/extension/tools/snapshot.ts b/apps/templates/stagehand-extension/extension/tools/snapshot.ts new file mode 100644 index 0000000000..fa10755df7 --- /dev/null +++ b/apps/templates/stagehand-extension/extension/tools/snapshot.ts @@ -0,0 +1,19 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { stagehandSession } from "../lib/session.js"; + +export default defineTool({ + description: + "Return the accessibility snapshot for the active Stagehand page. Snapshot IDs describe elements but are not selectors for run code.", + inputSchema: z.object({ + includeIframes: z.boolean().default(true), + }), + async execute({ includeIframes }) { + return stagehandSession.run(async ({ browser }) => { + const page = (await browser.context.activePage()) ?? (await browser.context.newPage()); + const snapshot = await page.snapshot({ includeIframes }); + return snapshot.formattedTree; + }); + }, +}); diff --git a/apps/templates/stagehand-extension/package.json b/apps/templates/stagehand-extension/package.json new file mode 100644 index 0000000000..10c5fe3aa8 --- /dev/null +++ b/apps/templates/stagehand-extension/package.json @@ -0,0 +1,51 @@ +{ + "name": "@eve-template/stagehand-extension", + "version": "0.0.0", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "./tools": { + "types": "./dist/tools/index.d.ts", + "default": "./dist/tools/index.mjs" + } + }, + "scripts": { + "build": "eve extension build", + "test:unit": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@browserbasehq/sdk": "2.16.0", + "@browserbasehq/stagehand": "4.0.1", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "eve": "workspace:*", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "eve": "*" + }, + "engines": { + "node": ">=24" + }, + "eve": { + "extension": { + "source": "./extension", + "dist": "./dist/extension", + "externalDependencies": [ + "@browserbasehq/sdk", + "@browserbasehq/stagehand" + ] + } + } +} diff --git a/apps/templates/stagehand-extension/tests/run.test.ts b/apps/templates/stagehand-extension/tests/run.test.ts new file mode 100644 index 0000000000..e75132c600 --- /dev/null +++ b/apps/templates/stagehand-extension/tests/run.test.ts @@ -0,0 +1,103 @@ +import type { ExperimentalBatchContext } from "@browserbasehq/stagehand"; +import { describe, expect, it, vi } from "vitest"; + +import { compileRunCallback, runStagehandCode } from "../extension/lib/run.js"; +import { StagehandSession, type StagehandResources } from "../extension/lib/session.js"; + +describe("Stagehand run", () => { + it("compiles a closure-free callback in the host process", async () => { + const callback = compileRunCallback( + 'return { title: await page.title(), observed: await observe("the heading") };', + ); + const source = callback.toString(); + const batch = createBatch(); + + expect(source).toContain('await observe("the heading")'); + expect(source).not.toMatch(/AsyncFunction|new Function|eval\(/u); + await expect(callback(batch, {})).resolves.toMatchObject({ + value: { title: "Example Domain", observed: "heading" }, + closeRequested: false, + }); + }); + + it("propagates close requests to host cleanup", async () => { + const resources = createResources(); + const replacement = createResources(); + const factory = vi + .fn<() => Promise>() + .mockResolvedValueOnce(resources) + .mockResolvedValueOnce(replacement); + const cleanup = vi.fn(async () => { + markClosed(resources); + }); + const session = new StagehandSession(factory, cleanup); + + await expect(runStagehandCode('await close(); return "closed";', session)).resolves.toBe( + "closed", + ); + await expect(session.run(async (current) => current === replacement)).resolves.toBe(true); + expect(cleanup).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledWith(resources); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("closes the browser before surfacing a model-authored error", async () => { + const resources = createResources(); + const cleanup = vi.fn(async () => { + markClosed(resources); + }); + const session = new StagehandSession(async () => resources, cleanup); + + await expect( + runStagehandCode('await close(); throw new Error("boom");', session), + ).rejects.toThrow("boom"); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("preserves a healthy session after model-authored code throws", async () => { + const resources = createResources(); + const factory = vi.fn(async () => resources); + const cleanup = vi.fn(); + const session = new StagehandSession(factory, cleanup); + + await expect(runStagehandCode('throw new Error("expected");', session)).rejects.toThrow( + "expected", + ); + await expect(runStagehandCode('return "recovered";', session)).resolves.toBe("recovered"); + expect(factory).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + }); +}); + +function createBatch(): ExperimentalBatchContext { + const batch = Object.create(null) as ExperimentalBatchContext; + return Object.assign(batch, { + page: { title: vi.fn(async () => "Example Domain") }, + context: {}, + act: vi.fn(), + observe: vi.fn(async () => "heading"), + extract: vi.fn(), + metrics: vi.fn(), + }); +} + +function markClosed(resources: StagehandResources): void { + Object.defineProperty(resources.browser, "closed", { value: true, configurable: true }); +} + +function createResources(): StagehandResources { + const batch = createBatch(); + const browser = { + closed: false, + context: { pages: vi.fn(async () => [{}]) }, + close: vi.fn(), + }; + const resources = Object.create(null) as StagehandResources; + return Object.assign(resources, { + browser, + stagehand: { + experimentalBatch: vi.fn(async (callback, input) => callback(batch, input)), + close: vi.fn(), + }, + }); +} diff --git a/apps/templates/stagehand-extension/tests/session-release.test.ts b/apps/templates/stagehand-extension/tests/session-release.test.ts new file mode 100644 index 0000000000..a35c71058c --- /dev/null +++ b/apps/templates/stagehand-extension/tests/session-release.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + BrowserbaseSessionReleaseError, + releaseBrowserbaseSession, +} from "../extension/lib/session-release.js"; + +const mocks = vi.hoisted(() => ({ + createClient: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), +})); + +vi.mock("@browserbasehq/sdk", () => ({ + default: class Browserbase { + readonly sessions = { + retrieve: mocks.retrieve, + update: mocks.update, + }; + + constructor(options: unknown) { + mocks.createClient(options); + } + }, +})); + +describe("Browserbase session release", () => { + beforeEach(() => { + mocks.createClient.mockReset(); + mocks.retrieve.mockReset(); + mocks.update.mockReset(); + }); + + it("uses a bounded Browserbase SDK client to request release", async () => { + mocks.update.mockResolvedValueOnce({ status: "COMPLETED" }); + + await expect( + releaseBrowserbaseSession({ apiKey: "test-key", sessionId: "session-one" }), + ).resolves.toBeUndefined(); + expect(mocks.createClient).toHaveBeenCalledWith({ + apiKey: "test-key", + baseURL: "https://api.browserbase.com", + maxRetries: 2, + timeout: 10_000, + }); + expect(mocks.update).toHaveBeenCalledWith("session-one", { + status: "REQUEST_RELEASE", + }); + expect(mocks.retrieve).not.toHaveBeenCalled(); + }); + + it("accepts an already-completed session after a failed release request", async () => { + mocks.update.mockRejectedValueOnce(new Error("network failed")); + mocks.retrieve.mockResolvedValueOnce({ status: "COMPLETED" }); + + await expect( + releaseBrowserbaseSession({ apiKey: "test-key", sessionId: "session-one" }), + ).resolves.toBeUndefined(); + }); + + it("normalizes a custom API URL and encodes the session path segment", async () => { + mocks.update.mockResolvedValueOnce({ status: "COMPLETED" }); + + await releaseBrowserbaseSession({ + apiKey: "test-key", + baseUrl: "https://api.example.test///", + sessionId: "session/one?#", + }); + + expect(mocks.createClient).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://api.example.test" }), + ); + expect(mocks.update).toHaveBeenCalledWith("session%2Fone%3F%23", { + status: "REQUEST_RELEASE", + }); + }); + + it("reports a release that remains incomplete with a stable error", async () => { + mocks.update.mockRejectedValueOnce(new Error("release failed")); + mocks.retrieve.mockResolvedValueOnce({ status: "RUNNING" }); + + const error = await releaseBrowserbaseSession({ + apiKey: "test-key", + sessionId: "session-one", + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(BrowserbaseSessionReleaseError); + expect(error).toMatchObject({ + name: "BrowserbaseSessionReleaseError", + message: "Failed to release the Browserbase session.", + }); + expect((error as Error & { cause?: unknown }).cause).toBeUndefined(); + }); +}); diff --git a/apps/templates/stagehand-extension/tests/session.test.ts b/apps/templates/stagehand-extension/tests/session.test.ts new file mode 100644 index 0000000000..9563749071 --- /dev/null +++ b/apps/templates/stagehand-extension/tests/session.test.ts @@ -0,0 +1,286 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + closeStagehandResources, + createStagehandResourceFactory, + StagehandSession, + StagehandSessionCleanupError, + StagehandSessionInitializationError, + type StagehandResourceCleanup, + type StagehandResources, +} from "../extension/lib/session.js"; + +afterEach(() => vi.useRealTimers()); + +describe("StagehandSession", () => { + it("retries initialization after a rejected factory promise", async () => { + const resources = createResources(); + const factory = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("temporary launch failure")) + .mockResolvedValue(resources); + const session = new StagehandSession(factory, vi.fn()); + + await expect(session.run(async () => "unreachable")).rejects.toThrow( + "temporary launch failure", + ); + await expect(session.run(async () => "recovered")).resolves.toBe("recovered"); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("serializes operations and continues after an operation error", async () => { + const resources = createResources(); + const session = new StagehandSession(async () => resources, vi.fn()); + const firstStarted = deferred(); + const releaseFirst = deferred(); + const events: string[] = []; + + const first = session.run(async () => { + events.push("first:start"); + firstStarted.resolve(); + await releaseFirst.promise; + events.push("first:end"); + }); + const second = session.run(async () => { + events.push("second"); + throw new Error("expected operation failure"); + }); + const third = session.run(async () => { + events.push("third"); + return "done"; + }); + + await firstStarted.promise; + expect(events).toEqual(["first:start"]); + releaseFirst.resolve(); + await first; + await expect(second).rejects.toThrow("expected operation failure"); + await expect(third).resolves.toBe("done"); + expect(events).toEqual(["first:start", "first:end", "second", "third"]); + }); + + it("cleans up unhealthy resources before creating replacements", async () => { + const first = createResources(); + const second = createResources(); + const factory = vi + .fn<() => Promise>() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second); + const cleanup = vi.fn(async () => undefined); + const session = new StagehandSession(factory, cleanup); + + await expect( + session.run(async () => { + markClosed(first); + throw new Error("connection lost"); + }), + ).rejects.toThrow("connection lost"); + await expect(session.run(async (resources) => resources === second)).resolves.toBe(true); + expect(cleanup).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledWith(first); + }); + + it("bounds a hung operation and lets the queue continue with fresh resources", async () => { + vi.useFakeTimers(); + const first = createResources(); + const second = createResources(); + const factory = vi + .fn<() => Promise>() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second); + const cleanup = vi.fn(async () => undefined); + const session = new StagehandSession(factory, cleanup, { + operationTimeoutMs: 50, + cleanupTimeoutMs: 50, + }); + + const hung = session.run(() => new Promise(() => undefined)); + const next = session.run(async (resources) => resources === second); + await vi.advanceTimersByTimeAsync(50); + + await expect(hung).rejects.toThrow("Stagehand operation timed out after 50ms."); + await expect(next).resolves.toBe(true); + expect(cleanup).toHaveBeenCalledWith(first); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("bounds cleanup after a timed-out operation", async () => { + vi.useFakeTimers(); + const first = createResources(); + const second = createResources(); + const factory = vi + .fn<() => Promise>() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second); + const cleanup = vi.fn(() => new Promise(() => undefined)); + const session = new StagehandSession(factory, cleanup, { + operationTimeoutMs: 25, + cleanupTimeoutMs: 25, + }); + + const hung = session.run(() => new Promise(() => undefined)); + const next = session.run(async (resources) => resources === second); + await vi.advanceTimersByTimeAsync(50); + + await expect(hung).rejects.toThrow("Stagehand operation timed out after 25ms."); + await expect(next).resolves.toBe(true); + }); +}); + +describe("closeStagehandResources", () => { + it("does not report a Stagehand transport error after the browser closes", async () => { + const resources = createResources(); + vi.mocked(resources.stagehand.close).mockRejectedValueOnce(new TypeError()); + + await expect(closeStagehandResources(resources)).resolves.toBeUndefined(); + expect(resources.stagehand.close).toHaveBeenCalledOnce(); + expect(resources.browser.close).toHaveBeenCalledOnce(); + }); + + it("surfaces a sanitized typed browser close failure", async () => { + const resources = createResources(); + const browserCloseError = new Error("browser release failed"); + vi.mocked(resources.browser.close).mockRejectedValueOnce(browserCloseError); + + const error = await closeStagehandResources(resources).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(StagehandSessionCleanupError); + expect(error).not.toBeInstanceOf(AggregateError); + expect(error).toMatchObject({ + name: "StagehandSessionCleanupError", + message: "Failed to close the Stagehand browser session.", + }); + expect((error as Error & { cause?: unknown }).cause).toBeUndefined(); + expect(String(error)).not.toContain(browserCloseError.message); + }); + + it("does not expose Stagehand or browser transport details", async () => { + const resources = createResources(); + const stagehandCloseError = new TypeError("Stagehand transport failed"); + const browserCloseError = new Error("browser release failed"); + vi.mocked(resources.stagehand.close).mockRejectedValueOnce(stagehandCloseError); + vi.mocked(resources.browser.close).mockRejectedValueOnce(browserCloseError); + + const error = await closeStagehandResources(resources).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(StagehandSessionCleanupError); + expect(String(error)).not.toContain(stagehandCloseError.message); + expect(String(error)).not.toContain(browserCloseError.message); + }); + + it("falls back to direct Browserbase release when browser close fails", async () => { + const resources = createResources(); + const releaseSession = vi.fn(async () => undefined); + resources.releaseSession = releaseSession; + vi.mocked(resources.stagehand.close).mockRejectedValueOnce(new TypeError("transport closed")); + vi.mocked(resources.browser.close).mockRejectedValueOnce(new Error("CDP close failed")); + + await expect(closeStagehandResources(resources)).resolves.toBeUndefined(); + expect(releaseSession).toHaveBeenCalledOnce(); + }); +}); + +describe("createStagehandResourceFactory", () => { + it("releases an owned session when initialization and browser close fail", async () => { + const resources = createResources(); + const initializationError = new Error("Stagehand initialization failed"); + const releaseSession = vi.fn(async () => undefined); + vi.mocked(resources.browser.close).mockRejectedValueOnce(new Error("CDP close failed")); + const factory = createStagehandResourceFactory( + async () => ({ browser: resources.browser, releaseSession }), + async () => { + throw initializationError; + }, + ); + + await expect(factory()).rejects.toBe(initializationError); + expect(releaseSession).toHaveBeenCalledOnce(); + }); + + it("retries a failed release before launching another browser", async () => { + const first = createResources(); + const second = createResources(); + const releaseSession = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("release transport failed")) + .mockResolvedValue(undefined); + vi.mocked(first.browser.close).mockRejectedValueOnce(new Error("CDP close failed")); + const launch = vi + .fn() + .mockResolvedValueOnce({ browser: first.browser, releaseSession }) + .mockResolvedValueOnce({ browser: second.browser }); + const createStagehand = vi + .fn() + .mockRejectedValueOnce(new Error("initialization failed")) + .mockResolvedValueOnce(second.stagehand); + const factory = createStagehandResourceFactory(launch, createStagehand); + + await expect(factory()).rejects.toBeInstanceOf(StagehandSessionInitializationError); + expect(launch).toHaveBeenCalledOnce(); + expect(releaseSession).toHaveBeenCalledOnce(); + + await expect(factory()).resolves.toMatchObject({ + browser: second.browser, + stagehand: second.stagehand, + }); + expect(releaseSession).toHaveBeenCalledTimes(2); + expect(launch).toHaveBeenCalledTimes(2); + }); + + it("retries a release from failed explicit cleanup before the next launch", async () => { + const first = createResources(); + const second = createResources(); + const releaseSession = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("release transport failed")) + .mockResolvedValue(undefined); + vi.mocked(first.browser.close).mockRejectedValueOnce(new Error("CDP close failed")); + const launch = vi + .fn() + .mockResolvedValueOnce({ browser: first.browser, releaseSession }) + .mockResolvedValueOnce({ browser: second.browser }); + const createStagehand = vi + .fn() + .mockResolvedValueOnce(first.stagehand) + .mockResolvedValueOnce(second.stagehand); + const factory = createStagehandResourceFactory(launch, createStagehand); + + const firstResources = await factory(); + await expect(closeStagehandResources(firstResources)).rejects.toBeInstanceOf( + StagehandSessionCleanupError, + ); + expect(releaseSession).toHaveBeenCalledOnce(); + expect(launch).toHaveBeenCalledOnce(); + + await expect(factory()).resolves.toMatchObject({ browser: second.browser }); + expect(releaseSession).toHaveBeenCalledTimes(2); + expect(launch).toHaveBeenCalledTimes(2); + }); +}); + +function createResources(): StagehandResources { + const browser = { + closed: false, + context: { pages: vi.fn(async () => [{}]) }, + close: vi.fn(async function (this: { closed: boolean }) { + this.closed = true; + }), + }; + const resources = Object.create(null) as StagehandResources; + return Object.assign(resources, { + browser, + stagehand: { close: vi.fn(async () => undefined) }, + }); +} + +function markClosed(resources: StagehandResources): void { + Object.defineProperty(resources.browser, "closed", { value: true, configurable: true }); +} + +function deferred() { + let resolve!: (value: Value | PromiseLike) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} diff --git a/apps/templates/stagehand-extension/tsconfig.json b/apps/templates/stagehand-extension/tsconfig.json new file mode 100644 index 0000000000..16f6f70d91 --- /dev/null +++ b/apps/templates/stagehand-extension/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["extension/**/*.ts", "tests/**/*.ts"] +} diff --git a/apps/templates/stagehand-extension/vitest.config.ts b/apps/templates/stagehand-extension/vitest.config.ts new file mode 100644 index 0000000000..19384e80fd --- /dev/null +++ b/apps/templates/stagehand-extension/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 426b807515..a363c92e11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,31 @@ importers: specifier: 'catalog:' version: 4.1.10(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(vite@8.1.5(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) + apps/templates/stagehand-extension: + dependencies: + '@browserbasehq/sdk': + specifier: 2.16.0 + version: 2.16.0 + '@browserbasehq/stagehand': + specifier: 4.0.1 + version: 4.0.1(supports-color@10.2.2) + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.9.1 + eve: + specifier: workspace:* + version: link:../../../packages/eve + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(vite@8.1.5(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) + e2e/fixtures/agent-background-tools: dependencies: '@eve-e2e/config': @@ -1959,6 +1984,10 @@ packages: puppeteer-core: optional: true + '@browserbasehq/stagehand@4.0.1': + resolution: {integrity: sha512-nWeXNp6dg8Hw4OnMzNrDfmtWQwPJO8jaRTa9HBemMm6D3872NxyzRxCJ4175AhGG4Qkvc2LuZFQCvif3yu0Rew==} + engines: {node: '>=22.18.0'} + '@bufbuild/protobuf@2.13.0': resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} @@ -3791,6 +3820,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-http@0.218.0': resolution: {integrity: sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -8740,6 +8775,7 @@ packages: chrome-launcher@1.2.1: resolution: {integrity: sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==} engines: {node: '>=12.13.0'} + hasBin: true chromium-edge-launcher@0.3.0: resolution: {integrity: sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==} @@ -11041,6 +11077,7 @@ packages: is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} + hasBin: true is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} @@ -16273,6 +16310,17 @@ snapshots: - supports-color - utf-8-validate + '@browserbasehq/stagehand@4.0.1(supports-color@10.2.2)': + dependencies: + '@browserbasehq/sdk': 2.16.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + chrome-launcher: 1.2.1(supports-color@10.2.2) + zod: 4.4.3 + transitivePeerDependencies: + - encoding + - supports-color + '@bufbuild/protobuf@2.13.0': {} '@bytecodealliance/preview2-shim@0.17.6': {} @@ -18508,6 +18556,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/exporter-logs-otlp-http@0.218.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -23634,7 +23687,6 @@ snapshots: lighthouse-logger: 2.0.2(supports-color@10.2.2) transitivePeerDependencies: - supports-color - optional: true chromium-edge-launcher@0.3.0(supports-color@10.2.2): dependencies: @@ -26965,7 +27017,6 @@ snapshots: marky: 1.3.0 transitivePeerDependencies: - supports-color - optional: true lightningcss-android-arm64@1.32.0: optional: true