-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(sdk-ts): add configurable RPC call controls (#2925) #3000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@browserbasehq/stagehand": minor | ||
| --- | ||
|
|
||
| Add configurable response timeouts and per-call abort signal propagation for ordinary TypeScript SDK RPC calls. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ import { | |
| StagehandInitParamsSchema, | ||
| StagehandLogLevelSchema, | ||
| } from "@browserbasehq/stagehand-protocol/schemas"; | ||
| import { StagehandMethods } from "@browserbasehq/stagehand-protocol/schema-registry"; | ||
| import { Page } from "./page.js"; | ||
| import { Locator } from "./locator.js"; | ||
| import { isStagehandBrowser, type StagehandBrowser } from "./browser/index.js"; | ||
|
|
@@ -267,8 +268,40 @@ export const StagehandBrowserSchema = z | |
| ) | ||
| .meta({ id: "StagehandBrowser" }); | ||
|
|
||
| export type StagehandRPCMethodName = | ||
| (typeof StagehandMethods)[keyof typeof StagehandMethods]["name"]; | ||
| export type StagehandRPCTimeouts = { | ||
| defaultMs?: number; | ||
| methods?: Partial<Record<StagehandRPCMethodName, number>>; | ||
| }; | ||
|
|
||
| const MAX_RPC_TIMEOUT_MS = 2_147_473_647; | ||
| const RPCTimeoutMsSchema = z.int().positive().max(MAX_RPC_TIMEOUT_MS); | ||
|
|
||
| export const StagehandRPCTimeoutsSchema = z | ||
| .strictObject({ | ||
| defaultMs: RPCTimeoutMsSchema.optional(), | ||
| methods: z.record(z.string(), RPCTimeoutMsSchema).optional(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a caller misspells an RPC method name, this schema accepts the key and the requested timeout is silently ignored. Validate Prompt for AI agents |
||
| }) | ||
| .meta({ id: "StagehandRPCTimeouts" }); | ||
|
|
||
| export const StagehandCallOptionsSchema = z | ||
| .strictObject({ | ||
| signal: z.custom<AbortSignal>((value) => value instanceof AbortSignal).optional(), | ||
| }) | ||
| .meta({ id: "StagehandCallOptions" }); | ||
|
|
||
| export const StagehandGetCallOptionsSchema = z | ||
| .custom<() => StagehandCallOptions | undefined>( | ||
| (value) => typeof value === "function", | ||
| "getCallOptions must be a function", | ||
| ) | ||
| .meta({ id: "StagehandGetCallOptions" }); | ||
|
|
||
| export const StagehandCreateOptionsSchema = StagehandClientCreateConfigSchema.extend({ | ||
| browser: StagehandBrowserSchema, | ||
| rpcTimeouts: StagehandRPCTimeoutsSchema.optional(), | ||
| getCallOptions: StagehandGetCallOptionsSchema.optional(), | ||
| }).meta({ id: "StagehandCreateOptions" }); | ||
|
|
||
| export type ClientLLM = z.infer<typeof ClientLLMSchema>; | ||
|
|
@@ -298,6 +331,8 @@ export type ResolvedStagehandClientCreateConfig = z.output< | |
| >; | ||
| export type StagehandCreateOptions = z.input<typeof StagehandCreateOptionsSchema>; | ||
| export type ResolvedStagehandCreateOptions = z.output<typeof StagehandCreateOptionsSchema>; | ||
| export type StagehandCallOptions = z.input<typeof StagehandCallOptionsSchema>; | ||
| export type StagehandGetCallOptions = z.input<typeof StagehandGetCallOptionsSchema>; | ||
| export type WebMCPToolsOptions = z.infer<typeof WebMCPToolsOptionsSchema>; | ||
| export type WebMCPInvokeOptions = z.infer<typeof WebMCPInvokeOptionsSchema>; | ||
| export type WebMCPResultOptions = z.infer<typeof WebMCPResultOptionsSchema>; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,7 +38,12 @@ import { | |
| import type { StagehandRpcNotification } from "@browserbasehq/stagehand-protocol/types"; | ||
| import { z } from "zod/v4"; | ||
| import { CDPClient, type ServiceWorkerInfo } from "./cdpClient.js"; | ||
| import { abortReason } from "./abort.js"; | ||
| import { | ||
| StagehandCallOptionsSchema, | ||
| type StagehandGetCallOptions, | ||
| type StagehandRPCTimeouts, | ||
| } from "./clientSchemas.js"; | ||
| import { abortReason, throwIfAborted } from "./abort.js"; | ||
|
|
||
| type PendingRequest = { | ||
| method: RPCMethod; | ||
|
|
@@ -144,9 +149,19 @@ export class RPCClient { | |
| pendingNotifications: StagehandRpcNotification[] = []; | ||
| closed = false; | ||
| readonly cdp: CDPTransport; | ||
|
|
||
| constructor(cdp: CDPTransport) { | ||
| readonly rpcTimeouts?: StagehandRPCTimeouts; | ||
| readonly getCallOptions?: StagehandGetCallOptions; | ||
|
|
||
| constructor( | ||
| cdp: CDPTransport, | ||
| options: { | ||
| rpcTimeouts?: StagehandRPCTimeouts; | ||
| getCallOptions?: StagehandGetCallOptions; | ||
| } = {}, | ||
| ) { | ||
| this.cdp = cdp; | ||
| this.rpcTimeouts = options.rpcTimeouts; | ||
| this.getCallOptions = options.getCallOptions; | ||
| this.serviceWorker = cdp.serviceWorker; | ||
| this.browserWebSocketDebuggerUrl = cdp.webSocketDebuggerUrl; | ||
| this.cdp.onmessage = (message) => this.receive(message); | ||
|
|
@@ -163,6 +178,16 @@ export class RPCClient { | |
| if (method.name === StagehandMethods.stagehandInit.name && !options.signal) { | ||
| throw new Error("stagehand.init requires an initialization lifecycle signal"); | ||
| } | ||
| const callOptions = | ||
| method.name === StagehandMethods.stagehandInit.name || | ||
| method.name === StagehandMethods.stagehandCallbackBatch.name | ||
| ? undefined | ||
| : this.getCallOptions?.(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When local parameter validation fails, Prompt for AI agents |
||
| const callSignal = combineAbortSignals( | ||
| options.signal, | ||
| callOptions === undefined ? undefined : StagehandCallOptionsSchema.parse(callOptions).signal, | ||
| ); | ||
| throwIfAborted(callSignal); | ||
|
|
||
| const parentContext = context.active(); | ||
| const span = TRACER.startSpan( | ||
|
|
@@ -190,13 +215,10 @@ export class RPCClient { | |
| ...getTraceContextFields(requestContext), | ||
| }); | ||
| span.setAttribute("jsonrpc.request.id", String(request.id)); | ||
| const responseTimeoutMs = rpcResponseTimeoutMs(method.name, parsedParams); | ||
| const responseTimeoutMs = rpcResponseTimeoutMs(method.name, parsedParams, this.rpcTimeouts); | ||
| const timeoutController = | ||
| responseTimeoutMs === undefined ? undefined : new AbortController(); | ||
| const signal = | ||
| options.signal && timeoutController | ||
| ? AbortSignal.any([options.signal, timeoutController.signal]) | ||
| : (options.signal ?? timeoutController?.signal); | ||
| const signal = combineAbortSignals(callSignal, timeoutController?.signal); | ||
| const timeoutId = | ||
| timeoutController && responseTimeoutMs !== undefined | ||
| ? setTimeout(() => { | ||
|
|
@@ -213,6 +235,7 @@ export class RPCClient { | |
| const [, result] = await Promise.all([ | ||
| this.cdp.send(request, signal).catch((error: unknown) => { | ||
| this.rejectPending(request.id, asError(error)); | ||
| throw error; | ||
| }), | ||
| response, | ||
| ]); | ||
|
|
@@ -509,7 +532,25 @@ function asError(error: unknown): Error { | |
| return error instanceof Error ? error : new Error(String(error)); | ||
| } | ||
|
|
||
| export function rpcResponseTimeoutMs(method: string, params: unknown): number | undefined { | ||
| function combineAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined { | ||
| const unique = [...new Set(signals.filter((signal) => signal !== undefined))]; | ||
| if (unique.length === 0) return undefined; | ||
| if (unique.length === 1) return unique[0]; | ||
| return AbortSignal.any(unique); | ||
| } | ||
|
|
||
| export function rpcResponseTimeoutMs( | ||
| method: string, | ||
| params: unknown, | ||
| configuredTimeouts?: StagehandRPCTimeouts, | ||
| ): number | undefined { | ||
| const configuredTimeoutMs = | ||
| method === StagehandMethods.stagehandInit.name || | ||
| method === StagehandMethods.stagehandCallbackBatch.name | ||
| ? undefined | ||
| : ((configuredTimeouts?.methods as Record<string, number> | undefined)?.[method] ?? | ||
| configuredTimeouts?.defaultMs); | ||
| if (configuredTimeoutMs !== undefined) return configuredTimeoutMs; | ||
| let operationTimeoutMs: number | undefined; | ||
| switch (method) { | ||
| case StagehandMethods.stagehandAct.name: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Tell callers to return an options object, such as
{ signal: controller.signal }, rather than the signal itself. Following the current prose produces a value thatStagehandCallOptionsSchemarejects.Prompt for AI agents