diff --git a/.changeset/ready-carrots-fly.md b/.changeset/ready-carrots-fly.md new file mode 100644 index 000000000..e0e2f83a4 --- /dev/null +++ b/.changeset/ready-carrots-fly.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": minor +--- + +Added custom error types to allow for a smoother debugging experience. diff --git a/packages/core/lib/v3/agent/GoogleCUAClient.ts b/packages/core/lib/v3/agent/GoogleCUAClient.ts index 968a542a4..0afd0211e 100644 --- a/packages/core/lib/v3/agent/GoogleCUAClient.ts +++ b/packages/core/lib/v3/agent/GoogleCUAClient.ts @@ -15,7 +15,10 @@ import { AgentExecutionOptions, } from "../types/public/agent"; import { AgentClient } from "./AgentClient"; -import { AgentScreenshotProviderError } from "../types/public/sdkErrors"; +import { + AgentScreenshotProviderError, + LLMResponseError, +} from "../types/public/sdkErrors"; import { buildGoogleCUASystemPrompt } from "../../prompt"; import { compressGoogleConversationImages } from "./utils/imageCompression"; import { mapKeyToPlaywright } from "./utils/cuaKeyMapping"; @@ -312,7 +315,7 @@ export class GoogleCUAClient extends AgentClient { // Check if we have valid response content if (!response.candidates || response.candidates.length === 0) { - throw new Error("Response has no candidates!"); + throw new LLMResponseError("agent", "Response has no candidates!"); } // Success - we have a valid response diff --git a/packages/core/lib/v3/api.ts b/packages/core/lib/v3/api.ts index bb59400c3..fb06dff76 100644 --- a/packages/core/lib/v3/api.ts +++ b/packages/core/lib/v3/api.ts @@ -27,6 +27,7 @@ import { StagehandResponseBodyError, StagehandResponseParseError, StagehandServerError, + ExperimentalNotConfiguredError, } from "./types/public"; import type { SerializableResponse } from "./types/private"; @@ -224,9 +225,7 @@ export class StagehandAPIClient { ): Promise { // Check if integrations are being used in API mode if (agentConfig.integrations && agentConfig.integrations.length > 0) { - throw new StagehandAPIError( - "MCP integrations are not supported in API mode. Set experimental: true to use MCP integrations.", - ); + throw new ExperimentalNotConfiguredError("MCP integrations"); } if (typeof executeOptions === "object") { if (executeOptions.page) { diff --git a/packages/core/lib/v3/cache/ActCache.ts b/packages/core/lib/v3/cache/ActCache.ts index ac20d7fce..87b55ecac 100644 --- a/packages/core/lib/v3/cache/ActCache.ts +++ b/packages/core/lib/v3/cache/ActCache.ts @@ -10,6 +10,7 @@ import { ActCacheDeps, CachedActEntry, } from "../types/private"; +import { StagehandNotInitializedError } from "../types/public/sdkErrors"; export class ActCache { private readonly storage: CacheStorage; @@ -164,7 +165,7 @@ export class ActCache { ): Promise { const handler = this.getActHandler(); if (!handler) { - throw new Error("V3 not initialized. Call init() before act()."); + throw new StagehandNotInitializedError("act()"); } const execute = async (): Promise => { diff --git a/packages/core/lib/v3/handlers/extractHandler.ts b/packages/core/lib/v3/handlers/extractHandler.ts index c686470a2..453ee5264 100644 --- a/packages/core/lib/v3/handlers/extractHandler.ts +++ b/packages/core/lib/v3/handlers/extractHandler.ts @@ -14,6 +14,7 @@ import { ClientOptions, ModelConfiguration, } from "../types/public/model"; +import { StagehandInvalidArgumentError } from "../types/public/sdkErrors"; /** * Scans the provided Zod schema for any `z.string().url()` fields and @@ -105,7 +106,7 @@ export class ExtractHandler { } if (!instruction && schema) { - throw new Error( + throw new StagehandInvalidArgumentError( "extract() requires an instruction when a schema is provided.", ); } diff --git a/packages/core/lib/v3/handlers/v3AgentHandler.ts b/packages/core/lib/v3/handlers/v3AgentHandler.ts index 6057bb619..c175f7ca3 100644 --- a/packages/core/lib/v3/handlers/v3AgentHandler.ts +++ b/packages/core/lib/v3/handlers/v3AgentHandler.ts @@ -11,6 +11,7 @@ import { } from "../types/public/agent"; import { V3FunctionName } from "../types/public/methods"; import { mapToolResultToActions } from "../agent/utils/actionMapping"; +import { MissingLLMConfigurationError } from "../types/public/sdkErrors"; export class V3AgentHandler { private v3: V3; @@ -65,9 +66,7 @@ export class V3AgentHandler { ]; if (!this.llmClient?.getLanguageModel) { - throw new Error( - "V3AgentHandler requires an AISDK-backed LLM client. Ensure your model is configured like 'openai/gpt-4.1-mini'.", - ); + throw new MissingLLMConfigurationError(); } const baseModel = this.llmClient.getLanguageModel(); const wrappedModel = wrapLanguageModel({ diff --git a/packages/core/lib/v3/launch/browserbase.ts b/packages/core/lib/v3/launch/browserbase.ts index 702af761d..37ece84c1 100644 --- a/packages/core/lib/v3/launch/browserbase.ts +++ b/packages/core/lib/v3/launch/browserbase.ts @@ -1,4 +1,8 @@ import Browserbase from "@browserbasehq/sdk"; +import { + BrowserbaseSessionNotFoundError, + StagehandInitError, +} from "../types/public/sdkErrors"; export async function createBrowserbaseSession( apiKey: string, @@ -16,12 +20,12 @@ export async function createBrowserbaseSession( resumeSessionId, )) as unknown as { id: string; connectUrl?: string; status?: string }; if (!existing?.id) { - throw new Error(`Browserbase session not found: ${resumeSessionId}`); + throw new BrowserbaseSessionNotFoundError(); } const ws = existing.connectUrl; if (!ws) { - throw new Error( + throw new StagehandInitError( `Browserbase session resume missing connectUrl for ${resumeSessionId}`, ); } @@ -55,7 +59,7 @@ export async function createBrowserbaseSession( }; if (!created?.connectUrl || !created?.id) { - throw new Error( + throw new StagehandInitError( "Browserbase session creation returned an unexpected shape.", ); } diff --git a/packages/core/lib/v3/launch/local.ts b/packages/core/lib/v3/launch/local.ts index 5d214defe..afbb6d6a6 100644 --- a/packages/core/lib/v3/launch/local.ts +++ b/packages/core/lib/v3/launch/local.ts @@ -1,4 +1,5 @@ import { launch, LaunchedChrome } from "chrome-launcher"; +import { ConnectionTimeoutError } from "../types/public/sdkErrors"; interface LaunchLocalOptions { chromePath?: string; @@ -60,7 +61,7 @@ async function waitForWebSocketDebuggerUrl( await new Promise((r) => setTimeout(r, 250)); } - throw new Error( + throw new ConnectionTimeoutError( `Timed out waiting for /json/version on port ${port}${ lastErrMsg ? ` (last error: ${lastErrMsg})` : "" }`, diff --git a/packages/core/lib/v3/tests/locator-count-iframe.spec.ts b/packages/core/lib/v3/tests/locator-count-iframe.spec.ts index ca4b0be3a..ba38c4ad1 100644 --- a/packages/core/lib/v3/tests/locator-count-iframe.spec.ts +++ b/packages/core/lib/v3/tests/locator-count-iframe.spec.ts @@ -151,7 +151,9 @@ test.describe("Locator count() method with iframes", () => { expect(true).toBe(false); } catch (error) { // Expected behavior - frameLocator should throw when iframe doesn't exist - expect(error.message).toContain("Element not found for selector"); + expect(error.message).toContain( + "Could not find an element for the given xPath(s):", + ); } }); }); diff --git a/packages/core/lib/v3/types/public/sdkErrors.ts b/packages/core/lib/v3/types/public/sdkErrors.ts index a5a3ac127..73fc50e38 100644 --- a/packages/core/lib/v3/types/public/sdkErrors.ts +++ b/packages/core/lib/v3/types/public/sdkErrors.ts @@ -209,6 +209,15 @@ export class ExperimentalNotConfiguredError extends StagehandError { } } +export class CuaModelRequiredError extends StagehandError { + constructor(availableModels: readonly string[]) { + super( + `To use the computer use agent (CUA), please provide a CUA model in the agent constructor or stagehand config. ` + + `Try one of our supported CUA models: ${availableModels.join(", ")}`, + ); + } +} + export class ZodSchemaValidationError extends Error { constructor( public readonly received: unknown, @@ -274,3 +283,39 @@ export class StagehandShadowSegmentNotFoundError extends StagehandError { ); } } + +export class ElementNotVisibleError extends StagehandError { + constructor(selector: string) { + super(`Element not visible (no box model): ${selector}`); + } +} + +export class ResponseBodyError extends StagehandError { + constructor(message: string) { + super(`Failed to retrieve response body: ${message}`); + } +} + +export class ResponseParseError extends StagehandError { + constructor(message: string) { + super(`Failed to parse response: ${message}`); + } +} + +export class TimeoutError extends StagehandError { + constructor(operation: string, timeoutMs: number) { + super(`${operation} timed out after ${timeoutMs}ms`); + } +} + +export class PageNotFoundError extends StagehandError { + constructor(identifier: string) { + super(`No Page found for ${identifier}`); + } +} + +export class ConnectionTimeoutError extends StagehandError { + constructor(message: string) { + super(`Connection timeout: ${message}`); + } +} diff --git a/packages/core/lib/v3/understudy/a11y/snapshot.ts b/packages/core/lib/v3/understudy/a11y/snapshot.ts index ef9d734bd..d7edd8d3f 100644 --- a/packages/core/lib/v3/understudy/a11y/snapshot.ts +++ b/packages/core/lib/v3/understudy/a11y/snapshot.ts @@ -4,6 +4,7 @@ import type { CDPSessionLike } from "../cdp"; import { Page } from "../page"; import { executionContexts } from "../executionContextRegistry"; import { v3Logger } from "../../logger"; +import { StagehandIframeError } from "../../types/public/sdkErrors"; /** * a11y/snapshot @@ -1005,7 +1006,11 @@ async function resolveFocusFrameAndTail( selectorForIframe, ctxFrameId, ); - if (!objectId) throw new Error("Failed to resolve iframe element by XPath"); + if (!objectId) + throw new StagehandIframeError( + selectorForIframe, + "Failed to resolve iframe element by XPath", + ); try { await parentSess.send("DOM.enable").catch(() => {}); @@ -1031,7 +1036,10 @@ async function resolveFocusFrameAndTail( } } if (!childFrameId) - throw new Error("Could not map iframe to child frameId"); + throw new StagehandIframeError( + selectorForIframe, + "Could not map iframe to child frameId", + ); // Update absolute prefix with the iframe element path within the parent document absPrefix = prefixXPath(absPrefix || "/", selectorForIframe); @@ -1083,7 +1091,11 @@ async function resolveCssFocusFrameAndTail( parts[i]!, ctxFrameId, ); - if (!objectId) throw new Error("Failed to resolve iframe via CSS hop"); + if (!objectId) + throw new StagehandIframeError( + parts[i]!, + "Failed to resolve iframe via CSS hop", + ); try { await parentSess.send("DOM.enable").catch(() => {}); const desc = await parentSess.send( @@ -1107,7 +1119,10 @@ async function resolveCssFocusFrameAndTail( } } if (!childFrameId) - throw new Error("Could not map CSS iframe hop to child frameId"); + throw new StagehandIframeError( + parts[i]!, + "Could not map CSS iframe hop to child frameId", + ); ctxFrameId = childFrameId; } finally { await parentSess diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 65eca2f3e..080634d24 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -7,6 +7,7 @@ import { installV3PiercerIntoSession } from "./piercer"; import { executionContexts } from "./executionContextRegistry"; import type { StagehandAPIClient } from "../api"; import { LocalBrowserLaunchOptions } from "../types/public"; +import { TimeoutError, PageNotFoundError } from "../types/public/sdkErrors"; type TargetId = string; type SessionId = string; @@ -96,8 +97,9 @@ export class V3Context { } await new Promise((r) => setTimeout(r, 25)); } - throw new Error( - `waitForFirstTopLevelPage timed out after ${timeoutMs}ms (no top-level Page)`, + throw new TimeoutError( + "waitForFirstTopLevelPage (no top-level Page)", + timeoutMs, ); } @@ -201,8 +203,7 @@ export class V3Context { rootMainFrameId: string, ): Promise { const owner = this.resolvePageByMainFrameId(rootMainFrameId); - if (!owner) - throw new Error(`No Page found for mainFrameId=${rootMainFrameId}`); + if (!owner) throw new PageNotFoundError(`mainFrameId=${rootMainFrameId}`); return owner.asProtocolFrameTree(rootMainFrameId); } @@ -225,7 +226,7 @@ export class V3Context { if (page) return page; await new Promise((r) => setTimeout(r, 25)); } - throw new Error(`newPage timeout: target not attached (${targetId})`); + throw new TimeoutError(`newPage: target not attached (${targetId})`, 5000); } /** @@ -670,6 +671,6 @@ export class V3Context { await new Promise((r) => setTimeout(r, 25)); } if (immediate) return immediate; - throw new Error("awaitActivePage: no page available"); + throw new PageNotFoundError("awaitActivePage: no page available"); } } diff --git a/packages/core/lib/v3/understudy/deepLocator.ts b/packages/core/lib/v3/understudy/deepLocator.ts index 37544011b..1e8566b52 100644 --- a/packages/core/lib/v3/understudy/deepLocator.ts +++ b/packages/core/lib/v3/understudy/deepLocator.ts @@ -3,6 +3,7 @@ import type { Frame } from "./frame"; import type { Page } from "./page"; import { v3Logger } from "../logger"; import { FrameLocator, frameLocatorFromFrame } from "./frameLocator"; +import { StagehandInvalidArgumentError } from "../types/public/sdkErrors"; /** * Recognize iframe steps like "iframe" or "iframe[2]" in an XPath. @@ -240,7 +241,9 @@ export class DeepLocatorDelegate { nth(index: number): DeepLocatorDelegate { const value = Number(index); if (!Number.isFinite(value) || value < 0) { - throw new Error("deepLocator().nth() expects a non-negative index"); + throw new StagehandInvalidArgumentError( + "deepLocator().nth() expects a non-negative index", + ); } const nextIndex = Math.floor(value); diff --git a/packages/core/lib/v3/understudy/frame.ts b/packages/core/lib/v3/understudy/frame.ts index 2dad1f2f8..c9bdcee9a 100644 --- a/packages/core/lib/v3/understudy/frame.ts +++ b/packages/core/lib/v3/understudy/frame.ts @@ -2,6 +2,7 @@ import { Protocol } from "devtools-protocol"; import type { CDPSessionLike } from "./cdp"; import { Locator } from "./locator"; +import { StagehandEvalError } from "../types/public/sdkErrors"; interface FrameManager { session: CDPSessionLike; @@ -150,7 +151,9 @@ export class Frame implements FrameManager { }, ); if (res.exceptionDetails) { - throw new Error(res.exceptionDetails.text ?? "Evaluation failed"); + throw new StagehandEvalError( + res.exceptionDetails.text ?? "Evaluation failed", + ); } return res.result.value as R; } diff --git a/packages/core/lib/v3/understudy/frameLocator.ts b/packages/core/lib/v3/understudy/frameLocator.ts index 05498234b..bd1347128 100644 --- a/packages/core/lib/v3/understudy/frameLocator.ts +++ b/packages/core/lib/v3/understudy/frameLocator.ts @@ -3,6 +3,7 @@ import { Locator } from "./locator"; import type { Page } from "./page"; import { Frame } from "./frame"; import { executionContexts } from "./executionContextRegistry"; +import { ContentFrameNotFoundError } from "../types/public/sdkErrors"; /** * FrameLocator: resolves iframe elements to their child Frames and allows @@ -72,9 +73,7 @@ export class FrameLocator { // ignore and try next } } - throw new Error( - `frameLocator: could not resolve child frame for selector: ${this.selector}`, - ); + throw new ContentFrameNotFoundError(this.selector); } finally { await parentSession .send("Runtime.releaseObject", { objectId }) diff --git a/packages/core/lib/v3/understudy/lifecycleWatcher.ts b/packages/core/lib/v3/understudy/lifecycleWatcher.ts index 4af695ea4..5adc09e82 100644 --- a/packages/core/lib/v3/understudy/lifecycleWatcher.ts +++ b/packages/core/lib/v3/understudy/lifecycleWatcher.ts @@ -3,6 +3,7 @@ import type { LoadState } from "../types/public/page"; import type { CDPSessionLike } from "./cdp"; import type { NetworkManager } from "./networkManager"; import type { Page } from "./page"; +import { TimeoutError } from "../types/public/sdkErrors"; import { DEFAULT_IDLE_WAIT, IGNORED_RESOURCE_TYPES, @@ -201,7 +202,7 @@ export class LifecycleWatcher { private timeRemaining(deadline: number): number { const remaining = deadline - Date.now(); if (remaining <= 0) { - throw new Error(`Lifecycle wait timed out after ${this.timeoutMs}ms`); + throw new TimeoutError("Lifecycle wait", this.timeoutMs); } return remaining; } diff --git a/packages/core/lib/v3/understudy/locator.ts b/packages/core/lib/v3/understudy/locator.ts index 07c51f69e..38acd8ca9 100644 --- a/packages/core/lib/v3/understudy/locator.ts +++ b/packages/core/lib/v3/understudy/locator.ts @@ -7,6 +7,11 @@ import { Buffer } from "buffer"; import { locatorScriptSources } from "../dom/build/locatorScripts.generated"; import type { Frame } from "./frame"; import { FrameSelectorResolver, type SelectorQuery } from "./selectorResolver"; +import { + StagehandElementNotFoundError, + StagehandInvalidArgumentError, + ElementNotVisibleError, +} from "../types/public/sdkErrors"; type MouseButton = "left" | "right" | "middle"; @@ -96,7 +101,9 @@ export class Locator { if (data instanceof Uint8Array) return Buffer.from(data); if (typeof data === "string") return Buffer.from(data); if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data)); - throw new Error("Unsupported file payload buffer type"); + throw new StagehandInvalidArgumentError( + "Unsupported file payload buffer type", + ); }; try { @@ -112,9 +119,11 @@ export class Locator { ); const ok = Boolean(res.result.value); if (!ok) - throw new Error('Target is not an element'); + throw new StagehandInvalidArgumentError( + 'Target is not an element', + ); } catch (e) { - throw new Error( + throw new StagehandInvalidArgumentError( e instanceof Error ? e.message : "Unable to verify file input element", @@ -151,7 +160,7 @@ export class Locator { filePaths.push(tmp); continue; } - throw new Error( + throw new StagehandInvalidArgumentError( "Unsupported setInputFiles item – expected path or payload", ); } @@ -220,7 +229,7 @@ export class Locator { "DOM.getBoxModel", { objectId }, ); - if (!box.model) throw new Error("Element not visible (no box model)"); + if (!box.model) throw new ElementNotVisibleError(this.selector); const { cx, cy } = this.centerFromBoxContent(box.model.content); return { x: Math.round(cx), y: Math.round(cy) }; } finally { @@ -324,7 +333,7 @@ export class Locator { "DOM.getBoxModel", { objectId }, ); - if (!box.model) throw new Error("Element not visible (no box model)"); + if (!box.model) throw new ElementNotVisibleError(this.selector); const { cx, cy } = this.centerFromBoxContent(box.model.content); await session.send("Input.dispatchMouseEvent", { @@ -367,7 +376,7 @@ export class Locator { "DOM.getBoxModel", { objectId }, ); - if (!box.model) throw new Error("Element not visible (no box model)"); + if (!box.model) throw new ElementNotVisibleError(this.selector); const { cx, cy } = this.centerFromBoxContent(box.model.content); // Dispatch input (from the same session) @@ -571,7 +580,9 @@ export class Locator { typeof result?.reason === "string" && result.reason.length > 0 ? result.reason : "Failed to fill element"; - throw new Error(`Failed to fill element (${reason})`); + throw new StagehandInvalidArgumentError( + `Failed to fill element (${reason})`, + ); } // Backward compatibility: if no status is returned (older bundle), fall back to setter logic. @@ -797,7 +808,9 @@ export class Locator { nth(index: number): Locator { const value = Number(index); if (!Number.isFinite(value) || value < 0) { - throw new Error("locator().nth() expects a non-negative index"); + throw new StagehandInvalidArgumentError( + "locator().nth() expects a non-negative index", + ); } const nextIndex = Math.floor(value); @@ -828,7 +841,7 @@ export class Locator { this.nthIndex, ); if (!resolved) { - throw new Error(`Element not found for selector: ${this.selector}`); + throw new StagehandElementNotFoundError([this.selector]); } return resolved; @@ -838,7 +851,7 @@ export class Locator { private centerFromBoxContent(content: number[]): { cx: number; cy: number } { // content is [x1,y1, x2,y2, x3,y3, x4,y4] if (!content || content.length < 8) { - throw new Error("Invalid box model content quad"); + throw new StagehandInvalidArgumentError("Invalid box model content quad"); } const xs = [content[0], content[2], content[4], content[6]]; const ys = [content[1], content[3], content[5], content[7]]; diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index 261726829..d9d972170 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -17,6 +17,10 @@ import { ConsoleMessage, ConsoleListener } from "./consoleMessage"; import type { StagehandAPIClient } from "../api"; import type { LocalBrowserLaunchOptions } from "../types/public"; import type { Locator } from "./locator"; +import { + StagehandInvalidArgumentError, + StagehandEvalError, +} from "../types/public/sdkErrors"; import type { ScreenshotAnimationsOption, ScreenshotCaretOption, @@ -456,7 +460,7 @@ export class Page { public on(event: "console", listener: ConsoleListener): Page { if (event !== "console") { - throw new Error(`Unsupported event: ${event}`); + throw new StagehandInvalidArgumentError(`Unsupported event: ${event}`); } const firstListener = this.consoleListeners.size === 0; @@ -471,7 +475,7 @@ export class Page { public once(event: "console", listener: ConsoleListener): Page { if (event !== "console") { - throw new Error(`Unsupported event: ${event}`); + throw new StagehandInvalidArgumentError(`Unsupported event: ${event}`); } const wrapper: ConsoleListener = (message) => { @@ -484,7 +488,7 @@ export class Page { public off(event: "console", listener: ConsoleListener): Page { if (event !== "console") { - throw new Error(`Unsupported event: ${event}`); + throw new StagehandInvalidArgumentError(`Unsupported event: ${event}`); } this.consoleListeners.delete(listener); @@ -979,15 +983,19 @@ export class Page { const type = opts.type ?? "png"; if (type !== "png" && type !== "jpeg") { - throw new Error(`screenshot: unsupported image type "${type}"`); + throw new StagehandInvalidArgumentError( + `screenshot: unsupported image type "${type}"`, + ); } if (opts.fullPage && opts.clip) { - throw new Error("screenshot: clip and fullPage cannot be used together"); + throw new StagehandInvalidArgumentError( + "screenshot: clip and fullPage cannot be used together", + ); } if (type === "png" && typeof opts.quality === "number") { - throw new Error( + throw new StagehandInvalidArgumentError( 'screenshot: quality option is only valid for type="jpeg"', ); } @@ -1148,7 +1156,7 @@ export class Page { exceptionDetails.text || exceptionDetails.exception?.description || "Evaluation failed"; - throw new Error(msg); + throw new StagehandEvalError(msg); } return result?.value as R; diff --git a/packages/core/lib/v3/understudy/response.ts b/packages/core/lib/v3/understudy/response.ts index 82005f210..43d09f249 100644 --- a/packages/core/lib/v3/understudy/response.ts +++ b/packages/core/lib/v3/understudy/response.ts @@ -14,10 +14,14 @@ */ import type { Protocol } from "devtools-protocol"; +import type { SerializableResponse } from "../types/private"; +import { + ResponseBodyError, + ResponseParseError, +} from "../types/public/sdkErrors"; import type { CDPSessionLike } from "./cdp"; import type { Frame } from "./frame"; import type { Page } from "./page"; -import type { SerializableResponse } from "../types/private"; type ServerAddr = { ipAddress: string; port: number }; @@ -291,7 +295,7 @@ export class Response { { requestId: this.requestId }, ) .catch((error) => { - throw new Error(`Failed to retrieve response body: ${String(error)}`); + throw new ResponseBodyError(String(error)); }); if (result.base64Encoded) { @@ -312,7 +316,7 @@ export class Response { try { return JSON.parse(text) as T; } catch (error) { - throw new Error(`Failed to parse JSON response: ${String(error)}`); + throw new ResponseParseError(String(error)); } } diff --git a/packages/core/lib/v3/understudy/screenshotUtils.ts b/packages/core/lib/v3/understudy/screenshotUtils.ts index b431502fa..6d0b68656 100644 --- a/packages/core/lib/v3/understudy/screenshotUtils.ts +++ b/packages/core/lib/v3/understudy/screenshotUtils.ts @@ -7,6 +7,7 @@ import type { ScreenshotClip, ScreenshotScaleOption, } from "../types/public/screenshotTypes"; +import { StagehandInvalidArgumentError } from "../types/public/sdkErrors"; export type ScreenshotCleanup = () => Promise | void; @@ -28,12 +29,16 @@ export function normalizeScreenshotClip(clip: ScreenshotClip): ScreenshotClip { for (const [key, value] of Object.entries({ x, y, width, height })) { if (!Number.isFinite(value)) { - throw new Error(`screenshot: clip.${key} must be a finite number`); + throw new StagehandInvalidArgumentError( + `screenshot: clip.${key} must be a finite number`, + ); } } if (width <= 0 || height <= 0) { - throw new Error("screenshot: clip width/height must be positive"); + throw new StagehandInvalidArgumentError( + "screenshot: clip width/height must be positive", + ); } return { x, y, width, height }; diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index b7457f000..882bfe3e6 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -59,6 +59,13 @@ import { PatchrightPage, PlaywrightPage, PuppeteerPage, + ExperimentalApiConflictError, + ExperimentalNotConfiguredError, + CuaModelRequiredError, + StagehandInvalidArgumentError, + StagehandNotInitializedError, + MissingEnvironmentVariableError, + StagehandInitError, } from "./types/public"; import { V3Context } from "./understudy/context"; import { Page } from "./understudy/page"; @@ -87,7 +94,7 @@ function resolveModelConfiguration( if (model && typeof model === "object") { const { modelName, ...clientOptions } = model; if (!modelName) { - throw new Error( + throw new StagehandInvalidArgumentError( "model.modelName is required when providing client options.", ); } @@ -216,6 +223,11 @@ export class V3 { this.llmProvider = new LLMProvider(this.logger); this.domSettleTimeoutMs = opts.domSettleTimeout; this.disableAPI = opts.disableAPI ?? false; + + // Validate that experimental and API are not both enabled + if (this.experimental && !this.disableAPI) { + throw new ExperimentalApiConflictError(); + } const baseClientOptions: ClientOptions = clientOptions ? ({ ...clientOptions } as ClientOptions) : ({} as ClientOptions); @@ -713,8 +725,9 @@ export class V3 { if (this.opts.env === "BROWSERBASE") { const { apiKey, projectId } = this.requireBrowserbaseCreds(); if (!apiKey || !projectId) { - throw new Error( - "BROWSERBASE credentials missing. Provide in your v3 constructor, or set BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID in your .env", + throw new MissingEnvironmentVariableError( + "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID", + "Browserbase environment", ); } this.logger({ @@ -811,7 +824,7 @@ export class V3 { } const neverEnv: never = this.opts.env; - throw new Error(`Unsupported env: ${neverEnv}`); + throw new StagehandInitError(`Unsupported env: ${neverEnv}`); }); } catch (error) { // Cleanup instanceLoggers map on init failure to prevent memory leak @@ -873,8 +886,7 @@ export class V3 { async act(input: string | Action, options?: ActOptions): Promise { return await withInstanceLogContext(this.instanceId, async () => { - if (!this.actHandler) - throw new Error("V3 not initialized. Call init() before act()."); + if (!this.actHandler) throw new StagehandNotInitializedError("act()"); let actResult: ActResult; @@ -911,7 +923,7 @@ export class V3 { } // instruction path if (typeof input !== "string" || !input.trim()) { - throw new Error( + throw new StagehandInvalidArgumentError( "act(): instruction string is required unless passing an Action", ); } @@ -1022,7 +1034,7 @@ export class V3 { ): Promise { return await withInstanceLogContext(this.instanceId, async () => { if (!this.extractHandler) { - throw new Error("V3 not initialized. Call init() before extract()."); + throw new StagehandNotInitializedError("extract()"); } // Normalize args @@ -1049,7 +1061,9 @@ export class V3 { } if (!instruction && schema) { - throw new Error("extract(): schema provided without instruction"); + throw new StagehandInvalidArgumentError( + "extract(): schema provided without instruction", + ); } // If instruction without schema → defaultExtractSchema @@ -1098,7 +1112,7 @@ export class V3 { ): Promise { return await withInstanceLogContext(this.instanceId, async () => { if (!this.observeHandler) { - throw new Error("V3 not initialized. Call init() before observe()."); + throw new StagehandNotInitializedError("observe()"); } // Normalize args @@ -1150,7 +1164,7 @@ export class V3 { /** Return the browser-level CDP WebSocket endpoint. */ connectURL(): string { if (this.state.kind === "UNINITIALIZED") { - throw new Error("V3 not initialized. Call await v3.init() first."); + throw new StagehandNotInitializedError("connectURL()"); } return this.state.ws; } @@ -1238,10 +1252,9 @@ export class V3 { const missing: string[] = []; if (!apiKey) missing.push("BROWSERBASE_API_KEY"); if (!projectId) missing.push("BROWSERBASE_PROJECT_ID"); - throw new Error( - `BROWSERBASE credentials missing. Provide in your v3 constructor, or set ${missing.join( - ", ", - )} in your .env`, + throw new MissingEnvironmentVariableError( + missing.join(", "), + "Browserbase", ); } @@ -1300,7 +1313,9 @@ export class V3 { return frameTree.frame.id; } - throw new Error("Unsupported page object passed to V3.act()"); + throw new StagehandInvalidArgumentError( + "Unsupported page object passed to V3.act()", + ); } private isPlaywrightPage(p: unknown): p is PlaywrightPage { @@ -1334,9 +1349,7 @@ export class V3 { } const ctx = this.ctx; if (!ctx) { - throw new Error( - "V3 context not initialized. Call init() before resolving pages.", - ); + throw new StagehandNotInitializedError("resolvePage()"); } return await ctx.awaitActivePage(); } @@ -1349,24 +1362,30 @@ export class V3 { const frameId = await this.resolveTopFrameId(input); const page = this.ctx!.resolvePageByMainFrameId(frameId); if (!page) - throw new Error("Failed to resolve V3 Page from Playwright page."); + throw new StagehandInitError( + "Failed to resolve V3 Page from Playwright page.", + ); return page; } if (this.isPatchrightPage(input)) { const frameId = await this.resolveTopFrameId(input); const page = this.ctx!.resolvePageByMainFrameId(frameId); if (!page) - throw new Error("Failed to resolve V3 Page from Playwright page."); + throw new StagehandInitError( + "Failed to resolve V3 Page from Patchright page.", + ); return page; } if (this.isPuppeteerPage(input)) { const frameId = await this.resolveTopFrameId(input); const page = this.ctx!.resolvePageByMainFrameId(frameId); if (!page) - throw new Error("Failed to resolve V3 Page from Puppeteer page."); + throw new StagehandInitError( + "Failed to resolve V3 Page from Puppeteer page.", + ); return page; } - throw new Error("Unsupported page object."); + throw new StagehandInvalidArgumentError("Unsupported page object."); } /** @@ -1403,8 +1422,8 @@ export class V3 { // If CUA is enabled, use the computer-use agent path if (options?.cua) { if ((options?.integrations || options?.tools) && !this.experimental) { - throw new Error( - "MCP integrations and custom tools are experimental. Enable experimental: true in V3 options.", + throw new ExperimentalNotConfiguredError( + "MCP integrations and custom tools", ); } @@ -1416,10 +1435,7 @@ export class V3 { const { modelName, isCua, clientOptions } = resolveModel(modelToUse); if (!isCua) { - throw new Error( - "To use the computer use agent, please provide a CUA model in the agent constructor or stagehand config. Try one of our supported CUA models: " + - AVAILABLE_CUA_MODELS.join(", "), - ); + throw new CuaModelRequiredError(AVAILABLE_CUA_MODELS); } const agentConfigSignature = @@ -1428,9 +1444,7 @@ export class V3 { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { if (options?.integrations && !this.experimental) { - throw new Error( - "MCP integrations are experimental. Enable experimental: true in V3 options.", - ); + throw new ExperimentalNotConfiguredError("MCP integrations"); } const tools = options?.integrations ? await resolveTools(options.integrations, options.tools) @@ -1526,8 +1540,8 @@ export class V3 { execute: async (instructionOrOptions: string | AgentExecuteOptions) => withInstanceLogContext(this.instanceId, async () => { if ((options?.integrations || options?.tools) && !this.experimental) { - throw new Error( - "MCP integrations and custom tools are experimental. Enable experimental: true in V3 options.", + throw new ExperimentalNotConfiguredError( + "MCP integrations and custom tools", ); } diff --git a/packages/core/lib/v3Evaluator.ts b/packages/core/lib/v3Evaluator.ts index 149e09347..d3df688d0 100644 --- a/packages/core/lib/v3Evaluator.ts +++ b/packages/core/lib/v3Evaluator.ts @@ -17,6 +17,7 @@ import { LLMResponse, LLMClient } from "./v3/llm/LLMClient"; import { LogLine } from "./v3/types/public/logs"; import { V3 } from "./v3/v3"; import { LLMProvider } from "./v3/llm/LLMProvider.js"; +import { StagehandInvalidArgumentError } from "./v3/types/public/sdkErrors"; dotenv.config(); @@ -63,9 +64,14 @@ export class V3Evaluator { screenshotDelayMs = 250, agentReasoning, } = options; - if (!question) throw new Error("Question cannot be an empty string"); + if (!question) + throw new StagehandInvalidArgumentError( + "Question cannot be an empty string", + ); if (!answer && !screenshot) - throw new Error("Either answer (text) or screenshot must be provided"); + throw new StagehandInvalidArgumentError( + "Either answer (text) or screenshot must be provided", + ); if (Array.isArray(screenshot)) { return this._evaluateWithMultipleScreenshots({ @@ -145,7 +151,10 @@ export class V3Evaluator { systemPrompt = "You are an expert evaluator that returns YES or NO with a concise reasoning.", screenshotDelayMs = 250, } = options; - if (!questions?.length) throw new Error("Questions array cannot be empty"); + if (!questions?.length) + throw new StagehandInvalidArgumentError( + "Questions array cannot be empty", + ); await new Promise((r) => setTimeout(r, screenshotDelayMs)); let imageBuffer: Buffer | undefined; @@ -233,9 +242,14 @@ export class V3Evaluator { Today's date is ${new Date().toLocaleDateString()}`, } = options; - if (!question) throw new Error("Question cannot be an empty string"); + if (!question) + throw new StagehandInvalidArgumentError( + "Question cannot be an empty string", + ); if (!screenshots || screenshots.length === 0) - throw new Error("At least one screenshot must be provided"); + throw new StagehandInvalidArgumentError( + "At least one screenshot must be provided", + ); const llmClient = this.getClient();