diff --git a/packages/docs/tests/sdk-reference.test.ts b/packages/docs/tests/sdk-reference.test.ts index 73242359f..e01dee25a 100644 --- a/packages/docs/tests/sdk-reference.test.ts +++ b/packages/docs/tests/sdk-reference.test.ts @@ -685,7 +685,12 @@ describe("SDK reference surface", () => { for (const field of projectedResultFields(method, language, protocol)) { const actual = documented.get(field.key); if (!actual) continue; - const expectedType = canonicalSchemaType(field.schema, language, protocol); + const expectedType = + method.operationName === "stagehand.extract" && field.key === "result.data" + ? language === "TypeScript" + ? "z.output" + : "ResultModel" + : canonicalSchemaType(field.schema, language, protocol); if (actual.type !== expectedType || actual.optional !== field.optional) { differences.push( `${methodKey(method)} ${language} ${field.key}: expected type=${expectedType} optional=${field.optional}, received type=${actual.type ?? ""} optional=${actual.optional}`, diff --git a/packages/docs/v4/basics/act.mdx b/packages/docs/v4/basics/act.mdx index fe40aa7ed..07f65c7a2 100644 --- a/packages/docs/v4/basics/act.mdx +++ b/packages/docs/v4/basics/act.mdx @@ -82,29 +82,30 @@ await stagehand.act("Open filters, choose 4-star rating, and click apply"); ### Return value -`act()` resolves to an `ActResultData` object. +`act()` resolves to an `ActResult` with typed `data` and shared `metadata`. ```typescript const result = await stagehand.act("Click the sign in button"); // { -// success: true, -// message: "Successfully clicked the sign in button", -// actionDescription: "Clicked button with text 'Sign in'", -// actions: [ -// { selector: "xpath=/html/body/...", description: "Sign in", method: "click", arguments: [] } -// ] +// data: { +// success: true, +// message: "Successfully clicked the sign in button", +// actionDescription: "Clicked button with text 'Sign in'", +// actions: [...] +// }, +// metadata: { cacheStatus: "MISS" } // } -if (!result.success) { - throw new Error(`act() failed: ${result.message}`); +if (!result.data.success) { + throw new Error(`act() failed: ${result.data.message}`); } ``` -Always check `success` before assuming the action ran. See the full field list in the +Always check `data.success` before assuming the action ran. See the full field list in the [`stagehand` reference](/v4/reference/stagehand). - Full parameters, per-call options, and the `ActResultData` shape. + Full parameters, per-call options, and the `ActResult` shape. ## Advanced configuration @@ -159,7 +160,9 @@ await stagehand.act("Click the login button"); exists or to preview what `act()` will do. ```typescript -const [candidate] = await stagehand.observe("Find the login button"); +const { + data: [candidate], +} = await stagehand.observe("Find the login button"); if (candidate) { await stagehand.act("Click the login button"); } @@ -180,7 +183,8 @@ On Browserbase, turn on caching so repeated, identical actions are served from a cache instead of calling the model again. ```typescript -await stagehand.act("Click the sign in button", { cache: true }); +const result = await stagehand.act("Click the sign in button", { cache: true }); +console.log(result.metadata.cacheStatus); ``` ## Troubleshooting @@ -194,7 +198,9 @@ the target element exists before acting. ```typescript await page.waitForLoadState("domcontentloaded"); -const [candidate] = await stagehand.observe("Find the submit button"); +const { + data: [candidate], +} = await stagehand.observe("Find the submit button"); if (candidate) { await stagehand.act("Click the submit button", { timeout: 30_000 }); } @@ -214,7 +220,9 @@ await stagehand.act("Click the button"); await stagehand.act("Click the red 'Delete' button next to the user John Smith"); // Or preview with observe() and confirm the description before acting: -const [candidate] = await stagehand.observe("Find the submit button in the checkout form"); +const { + data: [candidate], +} = await stagehand.observe("Find the submit button in the checkout form"); if (candidate?.description.toLowerCase().includes("checkout")) { await stagehand.act("Click the submit button in the checkout form"); } @@ -302,24 +310,25 @@ await stagehand.act("Open filters, choose 4-star rating, and click apply") ### Return value -`act()` resolves to an `ActResultData` object. +`act()` resolves to an `ActResult` with typed `data` and shared `metadata`. ```python result = await stagehand.act("Click the sign in button") -# result.success -> bool -# result.message -> str -# result.action_description -> str -# result.actions -> list of executed actions - -if not result.success: - raise RuntimeError(f"act() failed: {result.message}") +# result.data.success -> bool +# result.data.message -> str +# result.data.action_description -> str +# result.data.actions -> list of executed actions +# result.metadata.cache_status -> "HIT", "MISS", or None + +if not result.data.success: + raise RuntimeError(f"act() failed: {result.data.message}") ``` -Always check `success` before assuming the action ran. See the full field list in the +Always check `data.success` before assuming the action ran. See the full field list in the [`stagehand` reference](/v4/reference/stagehand). - Full parameters, per-call options, and the `ActResultData` shape. + Full parameters, per-call options, and the `ActResult` shape. ## Advanced configuration @@ -387,7 +396,7 @@ exists or to preview what `act()` will do. ```python candidates = await stagehand.observe(instruction="Find the login button") -if candidates: +if candidates.data: await stagehand.act("Click the login button") ``` @@ -406,7 +415,8 @@ On Browserbase, turn on caching so repeated, identical actions are served from a cache instead of calling the model again. ```python -await stagehand.act("Click the sign in button", cache=True) +result = await stagehand.act("Click the sign in button", cache=True) +print(result.metadata.cache_status) ``` ## Troubleshooting @@ -421,7 +431,7 @@ the target element exists before acting. await page.wait_for_load_state("domcontentloaded") candidates = await stagehand.observe(instruction="Find the submit button") -if candidates: +if candidates.data: await stagehand.act("Click the submit button", timeout=30_000) ``` @@ -440,7 +450,7 @@ await stagehand.act("Click the red 'Delete' button next to the user John Smith") # Or preview with observe() and confirm the description before acting: candidates = await stagehand.observe(instruction="Find the submit button in the checkout form") -if candidates and "checkout" in candidates[0].description.lower(): +if candidates.data and "checkout" in candidates.data[0].description.lower(): await stagehand.act("Click the submit button in the checkout form") ``` diff --git a/packages/docs/v4/basics/extract.mdx b/packages/docs/v4/basics/extract.mdx index 7d8d3573a..53b0ad92a 100644 --- a/packages/docs/v4/basics/extract.mdx +++ b/packages/docs/v4/basics/extract.mdx @@ -15,7 +15,7 @@ Define the schema as a [Zod](https://zod.dev) object: ```typescript import { z } from "zod"; -const product = await stagehand.extract( +const { data: product } = await stagehand.extract( "Extract the product name and price", z.object({ name: z.string(), @@ -50,7 +50,7 @@ values, and use field descriptions to guide the model. ```typescript import { z } from "zod"; -const results = await stagehand.extract( +const { data: results } = await stagehand.extract( "Extract each search result", z.object({ results: z.array( @@ -66,25 +66,26 @@ const results = await stagehand.extract( ### Return value -`extract()` resolves to data that matches your schema, with no wrapper object. In -TypeScript the type is inferred from the Zod schema; in Python it's an instance of your -Pydantic model. When the schema is an array or list, you get an array or list back. +`extract()` resolves to an object with `data` and `metadata`. `data` matches your schema: +in TypeScript its type is inferred from Zod, and in Python it is an instance of your +Pydantic model. `metadata` contains Stagehand-owned information such as cache status and +an optional action ID. ```typescript -const product = await stagehand.extract( +const extraction = await stagehand.extract( "Extract the product details", z.object({ name: z.string(), price: z.number(), inStock: z.boolean() }), ); -// product is typed as { name: string; price: number; inStock: boolean } -// e.g. { name: "Wireless Mouse", price: 29.99, inStock: true } +// extraction.data is typed as { name: string; price: number; inStock: boolean } -console.log(product.name, product.price); +console.log(extraction.data.name, extraction.data.price); +console.log(extraction.metadata.cacheStatus); ``` ## Advanced configuration ```typescript -const data = await stagehand.extract("Extract the article body", schema, { +const extraction = await stagehand.extract("Extract the article body", schema, { selector: "#main-content", ignoreSelectors: ["nav", ".cookie-banner"], screenshot: true, @@ -108,7 +109,9 @@ with `ignoreSelectors`) is faster, cheaper, and more accurate than extracting fr whole document. ```typescript -const { price } = await stagehand.extract( +const { + data: { price }, +} = await stagehand.extract( "Extract the sale price", z.object({ price: z.number() }), { selector: "#product-summary", ignoreSelectors: [".related-products"] }, @@ -126,11 +129,12 @@ On Browserbase, turn on caching so repeated, identical extractions are served from a cache instead of calling the model again. ```typescript -const product = await stagehand.extract( +const extraction = await stagehand.extract( "Extract the product name and price", z.object({ name: z.string(), price: z.number() }), { cache: true }, ); +console.log(extraction.data, extraction.metadata.cacheStatus); ``` ## Best practices @@ -155,7 +159,9 @@ Narrow with `selector`, exclude distractors with `ignoreSelectors`, or enable `s when the correct value is clear visually but ambiguous in the DOM. ```typescript -const { price } = await stagehand.extract( +const { + data: { price }, +} = await stagehand.extract( "Extract the sale price, not the original price", z.object({ price: z.number() }), { selector: "#product-summary", ignoreSelectors: [".related-products"] }, @@ -180,11 +186,11 @@ class Product(BaseModel): price: float -product = await stagehand.extract( +extraction = await stagehand.extract( instruction="Extract the product name and price", schema=Product, ) -# product.name -> str, product.price -> float +# extraction.data.name -> str, extraction.data.price -> float ``` ## Why use `extract()`? @@ -223,7 +229,7 @@ class SearchResults(BaseModel): results: list[Result] -results = await stagehand.extract( +extraction = await stagehand.extract( instruction="Extract each search result", schema=SearchResults, ) @@ -231,9 +237,9 @@ results = await stagehand.extract( ### Return value -`extract()` resolves to data that matches your schema, with no wrapper object. In -TypeScript the type is inferred from the Zod schema; in Python it's an instance of your -Pydantic model. When the schema is an array or list, you get an array or list back. +`extract()` resolves to an object with `data` and `metadata`. `data` is an instance of +your Pydantic model, while `metadata` contains Stagehand-owned information such as cache +status and an optional action ID. ```python class Product(BaseModel): @@ -242,13 +248,14 @@ class Product(BaseModel): in_stock: bool -product = await stagehand.extract( +extraction = await stagehand.extract( instruction="Extract the product details", schema=Product, ) -# product is a Product instance, e.g. Product(name="Wireless Mouse", price=29.99, in_stock=True) +# extraction.data is a Product instance -print(product.name, product.price) +print(extraction.data.name, extraction.data.price) +print(extraction.metadata.cache_status) ``` ## Advanced configuration @@ -257,7 +264,7 @@ print(product.name, product.price) from stagehand import ModelConfig -data = await stagehand.extract( +extraction = await stagehand.extract( instruction="Extract the article body", schema=schema, selector="#main-content", @@ -302,11 +309,12 @@ On Browserbase, turn on caching so repeated, identical extractions are served from a cache instead of calling the model again. ```python -product = await stagehand.extract( +extraction = await stagehand.extract( instruction="Extract the product name and price", schema=Product, cache=True, ) +print(extraction.data, extraction.metadata.cache_status) ``` ## Best practices diff --git a/packages/docs/v4/basics/observe.mdx b/packages/docs/v4/basics/observe.mdx index 26f6e819a..cba9e4678 100644 --- a/packages/docs/v4/basics/observe.mdx +++ b/packages/docs/v4/basics/observe.mdx @@ -11,9 +11,10 @@ description: "Discover and plan executable actions on any web page." ```typescript const actions = await stagehand.observe("Find the sign in button"); -// [ +// actions.data -> [ // { selector: "xpath=/html/body/...", description: "Sign in", method: "click", arguments: [] } // ] +// actions.metadata.cacheStatus -> "HIT", "MISS", or undefined ``` The `instruction` is optional: call `observe()` with no argument to survey the page broadly. @@ -53,7 +54,9 @@ The `instruction` is optional: call `observe()` with no argument to survey the p The most common pattern is to observe first, decide, then act. ```typescript -const [candidate] = await stagehand.observe("Find the primary call-to-action"); +const { + data: [candidate], +} = await stagehand.observe("Find the primary call-to-action"); if (candidate) { console.log(candidate.description); // inspect before acting @@ -93,12 +96,12 @@ await stagehand.observe("What is the page title?"); ### Return value -`observe()` resolves to an array of `Action` objects. Each action has a `selector`, a -human-readable `description`, and an optional `method` and `arguments`. +`observe()` resolves to an `ObserveResult`. Its `data` is an array of `Action` objects, +and its `metadata` contains shared operation information such as cache status. ```typescript const actions = await stagehand.observe("Find the learn more button"); -// [ +// actions.data -> [ // { // selector: "xpath=/html/body/shadow-demo/div[1]/button[1]", // description: "Learn more button", @@ -156,6 +159,7 @@ so repeated, identical observations are served from a cache instead of calling t ```typescript const actions = await stagehand.observe("Find the sign in button", { cache: true }); +console.log(actions.metadata.cacheStatus); ``` ## Best practices @@ -163,7 +167,7 @@ const actions = await stagehand.observe("Find the sign in button", { cache: true - **Observe to verify, act to do:** Use `observe()` to confirm the right element exists, then run `act()` with the same intent. - **Scope busy pages:** `selector` and `ignoreSelectors` cut noise and speed up planning. -- **Handle empty results:** An empty array means nothing matched, so surface that instead +- **Handle empty results:** An empty `data` array means nothing matched, so surface that instead of acting anyway. ### Scope downstream work @@ -172,11 +176,14 @@ An observed action's `selector` can scope a follow-up `extract()`, narrowing the just the relevant region. ```typescript -const [table] = await stagehand.observe("Find the pricing table"); +const { + data: [table], +} = await stagehand.observe("Find the pricing table"); if (table) { const pricing = await stagehand.extract("Extract each pricing tier", pricingSchema, { selector: table.selector, }); + console.log(pricing.data); } ``` @@ -185,7 +192,9 @@ if (table) { Check that an observed action matches what you expect before running a critical step. ```typescript -const [deleteButton] = await stagehand.observe("Find the delete account button"); +const { + data: [deleteButton], +} = await stagehand.observe("Find the delete account button"); if (deleteButton?.method === "click") { await stagehand.act("Click the delete account button"); } else { @@ -198,12 +207,12 @@ if (deleteButton?.method === "click") { -An empty array means nothing matched. Loosen an over-specific instruction, make sure the +An empty `data` array means nothing matched. Loosen an over-specific instruction, make sure the page has loaded, and confirm you're scoped to the right region. ```typescript let actions = await stagehand.observe("Find the checkout button"); -if (actions.length === 0) { +if (actions.data.length === 0) { await page.waitForLoadState("domcontentloaded"); actions = await stagehand.observe("Find the checkout button"); } @@ -231,7 +240,8 @@ const actions = await stagehand.observe("Find the form fields", { ```python actions = await stagehand.observe(instruction="Find the sign in button") -# [ Action(selector="xpath=...", description="Sign in", method="click", arguments=[]) ] +# actions.data -> [Action(selector="xpath=...", description="Sign in", method="click", arguments=[])] +# actions.metadata.cache_status -> "HIT", "MISS", or None ``` The `instruction` is optional: call `observe()` with no argument to survey the page broadly. @@ -273,8 +283,8 @@ The most common pattern is to observe first, decide, then act. ```python candidates = await stagehand.observe(instruction="Find the primary call-to-action") -if candidates: - print(candidates[0].description) # inspect before acting +if candidates.data: + print(candidates.data[0].description) # inspect before acting await stagehand.act("Click the primary call-to-action") ``` @@ -310,12 +320,12 @@ await stagehand.observe(instruction="What is the page title?") ### Return value -`observe()` resolves to an array of `Action` objects. Each action has a `selector`, a -human-readable `description`, and an optional `method` and `arguments`. +`observe()` resolves to an `ObserveResult`. Its `data` is a list of `Action` objects, +and its `metadata` contains shared operation information such as cache status. ```python actions = await stagehand.observe(instruction="Find the learn more button") -# [ +# actions.data -> [ # Action( # selector="xpath=/html/body/shadow-demo/div[1]/button[1]", # description="Learn more button", @@ -378,6 +388,7 @@ so repeated, identical observations are served from a cache instead of calling t ```python actions = await stagehand.observe(instruction="Find the sign in button", cache=True) +print(actions.metadata.cache_status) ``` ## Best practices @@ -385,7 +396,7 @@ actions = await stagehand.observe(instruction="Find the sign in button", cache=T - **Observe to verify, act to do:** Use `observe()` to confirm the right element exists, then run `act()` with the same intent. - **Scope busy pages:** `selector` and `ignore_selectors` cut noise and speed up planning. -- **Handle empty results:** An empty array means nothing matched, so surface that instead +- **Handle empty results:** An empty `data` list means nothing matched, so surface that instead of acting anyway. ### Scope downstream work @@ -395,12 +406,13 @@ just the relevant region. ```python tables = await stagehand.observe(instruction="Find the pricing table") -if tables: +if tables.data: pricing = await stagehand.extract( instruction="Extract each pricing tier", schema=Pricing, - selector=tables[0].selector, + selector=tables.data[0].selector, ) + print(pricing.data) ``` ### Validate before acting @@ -409,7 +421,7 @@ Check that an observed action matches what you expect before running a critical ```python candidates = await stagehand.observe(instruction="Find the delete account button") -if candidates and candidates[0].method == "click": +if candidates.data and candidates.data[0].method == "click": await stagehand.act("Click the delete account button") else: raise RuntimeError("Delete button not found") @@ -420,12 +432,12 @@ else: -An empty array means nothing matched. Loosen an over-specific instruction, make sure the +An empty `data` list means nothing matched. Loosen an over-specific instruction, make sure the page has loaded, and confirm you're scoped to the right region. ```python actions = await stagehand.observe(instruction="Find the checkout button") -if not actions: +if not actions.data: await page.wait_for_load_state("domcontentloaded") actions = await stagehand.observe(instruction="Find the checkout button") ``` diff --git a/packages/docs/v4/first-steps/ai-rules.mdx b/packages/docs/v4/first-steps/ai-rules.mdx index be9edfbdd..ae429b654 100644 --- a/packages/docs/v4/first-steps/ai-rules.mdx +++ b/packages/docs/v4/first-steps/ai-rules.mdx @@ -57,21 +57,23 @@ try { ## Act: one action per call - `await stagehand.act("click the sign in button")` returns - `{ success, message, actionDescription, actions }`. Always check `result.success`. + `{ data, metadata }`; `data` contains `{ success, message, actionDescription, actions }`. + Always check `result.data.success`. - Keep instructions to a single step. Chain multiple `act` calls for multi-step flows. - Pass secrets through `variables`, referenced as `%name%` in the instruction: `await stagehand.act("type %password% into the password field", { variables: { password } })`. ## Extract: typed data with Zod -- `await stagehand.extract("instruction", zodSchema)` returns data validated against the schema. +- `await stagehand.extract("instruction", zodSchema)` returns `{ data, metadata }`; `data` is + validated against the schema. - Define the schema with `zod`: `z.object({ price: z.number() })`. - Scope large pages with `{ selector }` or `{ ignoreSelectors }`. ## Observe: plan before acting -- `await stagehand.observe("find the submit button")` returns an array of - `{ selector, description, method?, arguments? }`. +- `await stagehand.observe("find the submit button")` returns `{ data, metadata }`; `data` is + an array of `{ selector, description, method?, arguments? }`. - Use it to check an element exists or to preview what `act` will do. ## Don't @@ -133,18 +135,19 @@ asyncio.run(main()) ## Act -- `result = await stagehand.act("click the sign in button")`; check `result.success`. +- `result = await stagehand.act("click the sign in button")`; check `result.data.success`. - One action per call. Pass secrets via `variables` with `%name%` placeholders. ## Extract: typed data with Pydantic - Define a `pydantic.BaseModel` and pass it as `schema`: `await stagehand.extract(instruction="...", schema=MyModel)`. +- Read the validated model from `.data` and Stagehand-owned information from `.metadata`. ## Observe -- `actions = await stagehand.observe(instruction="find the submit button")` returns a list of - action objects. Use it to plan or verify before acting. +- `result = await stagehand.observe(instruction="find the submit button")` returns + `{ data, metadata }`; iterate over `result.data` to use the action objects. ## Don't diff --git a/packages/docs/v4/first-steps/introduction.mdx b/packages/docs/v4/first-steps/introduction.mdx index 2b58e2ac1..487ac6ec5 100644 --- a/packages/docs/v4/first-steps/introduction.mdx +++ b/packages/docs/v4/first-steps/introduction.mdx @@ -45,12 +45,15 @@ try { // Observe: discover the available actions const actions = await stagehand.observe("Find the primary call-to-action"); + console.log(actions.data); // Act: perform a natural-language action await stagehand.act("Click the primary call-to-action"); // Extract: pull structured data - const { heading } = await stagehand.extract( + const { + data: { heading }, + } = await stagehand.extract( "Extract the page heading", z.object({ heading: z.string() }), ); @@ -83,6 +86,7 @@ async def main() -> None: # Observe: discover the available actions actions = await stagehand.observe(instruction="Find the primary call-to-action") + print(actions.data) # Act: perform a natural-language action await stagehand.act("Click the primary call-to-action") @@ -92,6 +96,7 @@ async def main() -> None: instruction="Extract the page heading", schema=PageInfo, ) + print(page_info.data.heading) finally: await stagehand.close() diff --git a/packages/docs/v4/first-steps/quickstart.mdx b/packages/docs/v4/first-steps/quickstart.mdx index 1c22abd4d..3cc44dd28 100644 --- a/packages/docs/v4/first-steps/quickstart.mdx +++ b/packages/docs/v4/first-steps/quickstart.mdx @@ -48,14 +48,14 @@ try { const actions = await stagehand.observe( "Find the link that provides more information about Example Domain", ); - console.log(actions); + console.log(actions.data); // Act: perform a natural-language action. const result = await stagehand.act( "Click the link that provides more information about Example Domain", ); - if (!result.success) { - throw new Error(`act() failed: ${result.message}`); + if (!result.data.success) { + throw new Error(`act() failed: ${result.data.message}`); } // Extract: pull structured, typed data. @@ -63,7 +63,7 @@ try { "Extract the page heading and description", z.object({ heading: z.string(), description: z.string() }), ); - console.log(info); + console.log(info.data); } finally { await stagehand.close(); } @@ -125,21 +125,21 @@ async def main() -> None: actions = await stagehand.observe( instruction="Find the link that provides more information about Example Domain", ) - print(actions) + print(actions.data) # Act: perform a natural-language action. result = await stagehand.act( "Click the link that provides more information about Example Domain" ) - if not result.success: - raise RuntimeError(f"act() failed: {result.message}") + if not result.data.success: + raise RuntimeError(f"act() failed: {result.data.message}") # Extract: pull structured, typed data. info = await stagehand.extract( instruction="Extract the page heading and description", schema=PageInfo, ) - print(info) + print(info.data) finally: await stagehand.close() diff --git a/packages/docs/v4/reference/stagehand.mdx b/packages/docs/v4/reference/stagehand.mdx index b4408f9ad..577ccc920 100644 --- a/packages/docs/v4/reference/stagehand.mdx +++ b/packages/docs/v4/reference/stagehand.mdx @@ -272,43 +272,59 @@ const result = await stagehand.act("Click the sign in button"); - + The operation result. - + + The action outcome. + + A summary of the completed action. - + The actions that were performed. - + Arguments passed to the action. - + A human-readable action description. - + The action method. - + The action target selector. - + The operation message. - + Whether the action succeeded. + + Metadata associated with the operation. + + + Whether server-side caching served or computed the result. + + + + The action ID associated with the operation. + + + + ## observe() Find candidate actions on the page from an optional instruction. @@ -384,32 +400,49 @@ const actions = await stagehand.observe("Find the sign in button"); - + The operation result. - + + Candidate actions found on the page. + + Arguments passed to the action. - + A human-readable action description. - + The action method. - + The action target selector. + + Metadata associated with the operation. + + + Whether server-side caching served or computed the result. + + + + The action ID associated with the operation. + + + + ## extract() Extract structured data from the page. ```typescript const product = await stagehand.extract("Extract the product", ProductSchema); +console.log(product.data, product.metadata.cacheStatus); ``` @@ -483,8 +516,24 @@ const product = await stagehand.extract("Extract the product", ProductSchema); - - The operation result. + + The extraction result. + + + Data validated against the caller's schema. + + + + Metadata associated with the extraction. + + + Whether server-side caching served or computed the result. + + + + The action ID associated with the extraction. + + @@ -752,43 +801,59 @@ result = await stagehand.act("Click the sign in button") - + The operation result. - + + The action outcome. + + A summary of the completed action. - + The actions that were performed. - + Arguments passed to the action. - + A human-readable action description. - + The action method. - + The action target selector. - + The operation message. - + Whether the action succeeded. + + Metadata associated with the operation. + + + Whether server-side caching served or computed the result. + + + + The action ID associated with the operation. + + + + ## observe() Find candidate actions on the page from an optional instruction. @@ -861,32 +926,49 @@ actions = await stagehand.observe(instruction="Find the sign in button") - + The operation result. - + + Candidate actions found on the page. + + Arguments passed to the action. - + A human-readable action description. - + The action method. - + The action target selector. + + Metadata associated with the operation. + + + Whether server-side caching served or computed the result. + + + + The action ID associated with the operation. + + + + ## extract() Extract structured data from the page. ```python product = await stagehand.extract(instruction="Extract the product", schema=Product) +print(product.data, product.metadata.cache_status) ``` @@ -957,8 +1039,24 @@ product = await stagehand.extract(instruction="Extract the product", schema=Prod - - The operation result. + + The extraction result. + + + Data validated against the caller's Pydantic model. + + + + Metadata associated with the extraction. + + + Whether server-side caching served or computed the result. + + + + The action ID associated with the extraction. + + diff --git a/packages/protocol/json-rpc/wire-casing.ts b/packages/protocol/json-rpc/wire-casing.ts index 6f8cd9f60..d947e0800 100644 --- a/packages/protocol/json-rpc/wire-casing.ts +++ b/packages/protocol/json-rpc/wire-casing.ts @@ -16,6 +16,11 @@ const defaultOpaqueKeys = new Set([ export type WireCasingOptions = { /** API-side container keys whose nested, user-controlled keys must retain their casing. */ readonly opaqueKeys?: readonly string[]; + /** + * API-side container keys whose nested fields use normal protocol casing, + * even when the key is opaque by default. + */ + readonly transformKeys?: readonly string[]; }; export function wireSchema( @@ -28,7 +33,7 @@ export function wireSchema( ? camelcaseKeys(preserveApiAcronyms(value, options.opaqueKeys), { deep: true, preserveConsecutiveUppercase: true, - stopPaths: findOpaquePaths(value, options.opaqueKeys), + stopPaths: findOpaquePaths(value, options), }) : value, schema, @@ -36,7 +41,7 @@ export function wireSchema( } export function encodeWireValue(value: unknown, options: WireCasingOptions = {}) { - const opaqueKeys = new Set([...defaultOpaqueKeys, ...(options.opaqueKeys ?? [])]); + const opaqueKeys = resolveOpaqueKeys(options); return z.json().parse( isCaseable(value) ? snakecaseKeys(value, { @@ -87,15 +92,11 @@ function toWirePropertyName(key: string): string { return Object.keys(snakecaseKeys({ [key]: null }, { deep: false }))[0] ?? key; } -function findOpaquePaths(value: unknown, additionalOpaqueKeys: readonly string[] = []): string[] { +function findOpaquePaths(value: unknown, options: WireCasingOptions = {}): string[] { const paths: string[] = []; const opaqueKeys = new Set( Object.keys( - snakecaseKeys( - Object.fromEntries( - [...defaultOpaqueKeys, ...additionalOpaqueKeys].map((key) => [key, null]), - ), - ), + snakecaseKeys(Object.fromEntries([...resolveOpaqueKeys(options)].map((key) => [key, null]))), ), ); @@ -120,6 +121,14 @@ function findOpaquePaths(value: unknown, additionalOpaqueKeys: readonly string[] return paths; } +function resolveOpaqueKeys(options: WireCasingOptions): Set { + const opaqueKeys = new Set([...defaultOpaqueKeys, ...(options.opaqueKeys ?? [])]); + for (const key of options.transformKeys ?? []) { + opaqueKeys.delete(key); + } + return opaqueKeys; +} + function preserveApiAcronyms( value: Record | Record[], additionalOpaqueKeys: readonly string[] = [], diff --git a/packages/protocol/schema-registry.ts b/packages/protocol/schema-registry.ts index e2d91a3cd..f454a15aa 100644 --- a/packages/protocol/schema-registry.ts +++ b/packages/protocol/schema-registry.ts @@ -140,18 +140,20 @@ export const StagehandMethods = { name: "stagehand.act", params: StagehandActParamsSchema, result: ActResultSchema, + resultWire: { transformKeys: ["data"] }, }, stagehandObserve: { name: "stagehand.observe", params: StagehandObserveParamsSchema, result: ObserveResultSchema, + resultWire: { transformKeys: ["data"] }, }, stagehandExtract: { name: "stagehand.extract", params: StagehandExtractParamsSchema, result: ExtractResultSchema, paramsWire: { opaqueKeys: ["schema"] }, - resultWire: { opaqueKeys: ["result"] }, + resultWire: { opaqueKeys: ["data"] }, }, stagehandMetrics: { name: "stagehand.metrics", diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index 7ab8b023f..6a8e6627d 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -645,7 +645,7 @@ export const StagehandMetricsSchema = z }) .meta({ id: "StagehandMetrics" }); -const CacheStatusSchema = z.enum(["HIT", "MISS"]).meta({ id: "CacheStatus" }); +export const CacheStatusSchema = z.enum(["HIT", "MISS"]).meta({ id: "CacheStatus" }); /** Server-side caching configuration: a boolean toggle, or an object enabling * caching with an optional hit-count threshold (how many identical results @@ -1032,6 +1032,17 @@ export const ActionSchema = z // Act // ============================================================================= +export const StagehandResultMetadataSchema = z + .strictObject({ + actionId: z.string().optional().meta({ + description: "Action ID for tracking", + }), + cacheStatus: CacheStatusSchema.optional().meta({ + description: "Server-side cache status for this result", + }), + }) + .meta({ id: "StagehandResultMetadata" }); + export const ActOptionsSchema = z .strictObject({ model: ModelConfigSchema.optional().meta({ @@ -1085,13 +1096,8 @@ export const ActResultDataSchema = z export const ActResultSchema = z .strictObject({ - result: ActResultDataSchema, - actionId: z.string().optional().meta({ - description: "Action ID for tracking", - }), - cacheStatus: CacheStatusSchema.optional().meta({ - description: "Server-side cache status for this result", - }), + data: ActResultDataSchema, + metadata: StagehandResultMetadataSchema, }) .meta({ id: "ActResult" }); @@ -1136,18 +1142,10 @@ export const ExtractOptionsSchema = z export const ExtractResultSchema = z .strictObject({ - result: z.unknown().meta({ + data: z.json().meta({ description: "Extracted data matching the requested schema", - override: ({ jsonSchema }: { jsonSchema: Record }) => { - jsonSchema["x-stainless-any"] = true; - }, - }), - actionId: z.string().optional().meta({ - description: "Action ID for tracking", - }), - cacheStatus: CacheStatusSchema.optional().meta({ - description: "Server-side cache status for this result", }), + metadata: StagehandResultMetadataSchema, }) .meta({ id: "ExtractResult" }); @@ -1198,13 +1196,8 @@ export const ObserveOptionsSchema = z export const ObserveResultSchema = z .strictObject({ - result: z.array(ActionSchema), - actionId: z.string().optional().meta({ - description: "Action ID for tracking", - }), - cacheStatus: CacheStatusSchema.optional().meta({ - description: "Server-side cache status for this result", - }), + data: z.array(ActionSchema), + metadata: StagehandResultMetadataSchema, }) .meta({ id: "ObserveResult" }); @@ -1222,24 +1215,21 @@ export const PageNavigationOptionsSchema = z .meta({ id: "PageNavigationOptions" }); export const PageVoidResultSchema = z - .object({ + .strictObject({ ok: z.literal(true), }) - .strict() .meta({ id: "PageVoidResult" }); export const ContextVoidResultSchema = z - .object({ + .strictObject({ ok: z.literal(true), }) - .strict() .meta({ id: "ContextVoidResult" }); export const ContextCloseResultSchema = z - .object({ + .strictObject({ closed: z.literal(true), }) - .strict() .meta({ id: "ContextCloseResult" }); export const PageCoordinateResultSchema = z @@ -1734,10 +1724,9 @@ export const StagehandInitResultSchema = z .meta({ id: "StagehandInitResult" }); export const StagehandCloseResultSchema = z - .object({ + .strictObject({ closed: z.literal(true), }) - .strict() .meta({ id: "StagehandCloseResult" }); export const ContextPagesResultSchema = z.array(PageRefSchema).meta({ id: "ContextPagesResult" }); @@ -1777,10 +1766,9 @@ export const PageTitleResultSchema = z .meta({ id: "PageTitleResult" }); export const PageCloseResultSchema = z - .object({ + .strictObject({ closed: z.literal(true), }) - .strict() .meta({ id: "PageCloseResult" }); export const PageDragAndDropResultSchema = z @@ -1810,24 +1798,21 @@ export const PageWaitForSelectorResultSchema = z .meta({ id: "PageWaitForSelectorResult" }); export const LocatorClickResultSchema = z - .object({ + .strictObject({ clicked: z.literal(true), }) - .strict() .meta({ id: "LocatorClickResult" }); export const LocatorFillResultSchema = z - .object({ + .strictObject({ filled: z.literal(true), }) - .strict() .meta({ id: "LocatorFillResult" }); export const LocatorHoverResultSchema = z - .object({ + .strictObject({ hovered: z.literal(true), }) - .strict() .meta({ id: "LocatorHoverResult" }); export const LocatorCountResultSchema = z @@ -1873,10 +1858,9 @@ export const LocatorTextContentResultSchema = z .meta({ id: "LocatorTextContentResult" }); export const LocatorScrollToResultSchema = z - .object({ + .strictObject({ scrolled: z.literal(true), }) - .strict() .meta({ id: "LocatorScrollToResult" }); export const LocatorCentroidResultSchema = z @@ -1887,24 +1871,21 @@ export const LocatorCentroidResultSchema = z .meta({ id: "LocatorCentroidResult" }); export const LocatorHighlightResultSchema = z - .object({ + .strictObject({ highlighted: z.literal(true), }) - .strict() .meta({ id: "LocatorHighlightResult" }); export const LocatorSendClickEventResultSchema = z - .object({ + .strictObject({ clicked: z.literal(true), }) - .strict() .meta({ id: "LocatorSendClickEventResult" }); export const LocatorTypeResultSchema = z - .object({ + .strictObject({ typed: z.literal(true), }) - .strict() .meta({ id: "LocatorTypeResult" }); export const LocatorSelectOptionResultSchema = z diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index d3a50f9d5..4bf25c266 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -1731,19 +1731,14 @@ "ActResult": { "type": "object", "properties": { - "result": { + "data": { "$ref": "#/$defs/ActResultData" }, - "action_id": { - "description": "Action ID for tracking", - "type": "string" - }, - "cache_status": { - "description": "Server-side cache status for this result", - "$ref": "#/$defs/CacheStatus" + "metadata": { + "$ref": "#/$defs/StagehandResultMetadata" } }, - "required": ["result"], + "required": ["data", "metadata"], "additionalProperties": false }, "ActResultData": { @@ -1806,6 +1801,20 @@ "additionalProperties": false, "description": "Action object returned by observe and used by act" }, + "StagehandResultMetadata": { + "type": "object", + "properties": { + "action_id": { + "description": "Action ID for tracking", + "type": "string" + }, + "cache_status": { + "description": "Server-side cache status for this result", + "$ref": "#/$defs/CacheStatus" + } + }, + "additionalProperties": false + }, "CacheStatus": { "type": "string", "enum": ["HIT", "MISS"] @@ -1877,22 +1886,17 @@ "ObserveResult": { "type": "object", "properties": { - "result": { + "data": { "type": "array", "items": { "$ref": "#/$defs/Action" } }, - "action_id": { - "description": "Action ID for tracking", - "type": "string" - }, - "cache_status": { - "description": "Server-side cache status for this result", - "$ref": "#/$defs/CacheStatus" + "metadata": { + "$ref": "#/$defs/StagehandResultMetadata" } }, - "required": ["result"], + "required": ["data", "metadata"], "additionalProperties": false }, "StagehandExtractParams": { @@ -1991,21 +1995,48 @@ "ExtractResult": { "type": "object", "properties": { - "result": { - "description": "Extracted data matching the requested schema" - }, - "action_id": { - "description": "Action ID for tracking", - "type": "string" + "data": { + "description": "Extracted data matching the requested schema", + "$ref": "#/$defs/__schema1" }, - "cache_status": { - "description": "Server-side cache status for this result", - "$ref": "#/$defs/CacheStatus" + "metadata": { + "$ref": "#/$defs/StagehandResultMetadata" } }, - "required": ["result"], + "required": ["data", "metadata"], "additionalProperties": false }, + "__schema1": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/__schema1" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema1" + } + } + ] + }, "StagehandMetrics": { "type": "object", "properties": { @@ -2254,14 +2285,14 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema2" } } }, "required": ["type", "id", "name", "input"], "additionalProperties": false }, - "__schema1": { + "__schema2": { "anyOf": [ { "type": "string" @@ -2278,7 +2309,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema2" } }, { @@ -2287,7 +2318,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema1" + "$ref": "#/$defs/__schema2" } } ] @@ -2314,7 +2345,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema2" + "$ref": "#/$defs/__schema3" } }, "is_error": { @@ -2334,7 +2365,7 @@ } ] }, - "__schema2": { + "__schema3": { "anyOf": [ { "type": "string" @@ -2351,7 +2382,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema2" + "$ref": "#/$defs/__schema3" } }, { @@ -2360,7 +2391,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema2" + "$ref": "#/$defs/__schema3" } } ] @@ -2379,13 +2410,13 @@ "type": "string" }, "schema": { - "$ref": "#/$defs/__schema3" + "$ref": "#/$defs/__schema4" } }, "required": ["type", "name", "schema"], "additionalProperties": false }, - "__schema3": { + "__schema4": { "anyOf": [ { "type": "string" @@ -2402,7 +2433,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema3" + "$ref": "#/$defs/__schema4" } }, { @@ -2411,7 +2442,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema3" + "$ref": "#/$defs/__schema4" } } ] @@ -2532,7 +2563,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema4" + "$ref": "#/$defs/__schema5" } } }, @@ -2546,7 +2577,7 @@ "required": ["type"], "additionalProperties": false }, - "__schema4": { + "__schema5": { "anyOf": [ { "type": "string" @@ -2563,7 +2594,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema4" + "$ref": "#/$defs/__schema5" } }, { @@ -2572,7 +2603,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema4" + "$ref": "#/$defs/__schema5" } } ] @@ -2734,13 +2765,13 @@ "const": "json_schema" }, "structured_content": { - "$ref": "#/$defs/__schema5" + "$ref": "#/$defs/__schema6" } }, "required": ["role", "content", "output_format", "structured_content"], "additionalProperties": false }, - "__schema5": { + "__schema6": { "anyOf": [ { "type": "string" @@ -2757,7 +2788,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema5" + "$ref": "#/$defs/__schema6" } }, { @@ -2766,7 +2797,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema5" + "$ref": "#/$defs/__schema6" } } ] @@ -3507,13 +3538,13 @@ "type": "object", "properties": { "value": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema7" } }, "required": ["value"], "additionalProperties": false }, - "__schema6": { + "__schema7": { "anyOf": [ { "type": "string" @@ -3530,7 +3561,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema7" } }, { @@ -3539,7 +3570,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema7" } } ] @@ -4313,10 +4344,10 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema8" } }, - "__schema7": { + "__schema8": { "anyOf": [ { "type": "string" @@ -4333,7 +4364,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema8" } }, { @@ -4342,7 +4373,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema8" } } ] @@ -6191,7 +6222,7 @@ "const": "2.0" }, "result": { - "$ref": "#/$defs/__schema8" + "$ref": "#/$defs/__schema9" }, "id": { "$ref": "#/$defs/JSONRPCRequestId" @@ -6200,7 +6231,7 @@ "required": ["jsonrpc", "result", "id"], "additionalProperties": false }, - "__schema8": { + "__schema9": { "anyOf": [ { "type": "string" @@ -6217,7 +6248,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema8" + "$ref": "#/$defs/__schema9" } }, { @@ -6226,7 +6257,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema8" + "$ref": "#/$defs/__schema9" } } ] @@ -6260,13 +6291,13 @@ "type": "string" }, "data": { - "$ref": "#/$defs/__schema9" + "$ref": "#/$defs/__schema10" } }, "required": ["code", "message"], "additionalProperties": false }, - "__schema9": { + "__schema10": { "anyOf": [ { "type": "string" @@ -6283,7 +6314,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/__schema9" + "$ref": "#/$defs/__schema10" } }, { @@ -6292,7 +6323,7 @@ "type": "string" }, "additionalProperties": { - "$ref": "#/$defs/__schema9" + "$ref": "#/$defs/__schema10" } } ] diff --git a/packages/protocol/tests/protocol/wire-casing.test.ts b/packages/protocol/tests/protocol/wire-casing.test.ts index 8638b2987..cff89bd76 100644 --- a/packages/protocol/tests/protocol/wire-casing.test.ts +++ b/packages/protocol/tests/protocol/wire-casing.test.ts @@ -398,12 +398,12 @@ describe("JSON-RPC wire casing", () => { it("preserves arbitrary extraction result keys", () => { const definition = StagehandMethods.stagehandExtract; const apiValue = { - result: { userName: "Sam" }, - actionId: "action_1", + data: { userName: "Sam" }, + metadata: { actionId: "action_1", cacheStatus: "HIT" as const }, }; const wireValue = { - result: { userName: "Sam" }, - action_id: "action_1", + data: { userName: "Sam" }, + metadata: { action_id: "action_1", cache_status: "HIT" }, }; expect(encodeWireValue(apiValue, definition.resultWire)).toStrictEqual(wireValue); @@ -439,6 +439,66 @@ describe("JSON-RPC wire casing", () => { ); }); + it("cases structured act and observe result data while preserving extracted JSON", () => { + const act = StagehandMethods.stagehandAct; + const actApiValue = { + data: { + success: true, + message: "Clicked the button", + actionDescription: "Clicked submit", + actions: [{ selector: "#submit", description: "Submit" }], + }, + metadata: { cacheStatus: "MISS" as const }, + }; + const actWireValue = { + data: { + success: true, + message: "Clicked the button", + action_description: "Clicked submit", + actions: [{ selector: "#submit", description: "Submit" }], + }, + metadata: { cache_status: "MISS" }, + }; + + expect(encodeWireValue(actApiValue, act.resultWire)).toStrictEqual(actWireValue); + expect(wireSchema(act.result, act.resultWire).parse(actWireValue)).toStrictEqual(actApiValue); + + const observe = StagehandMethods.stagehandObserve; + const observeApiValue = { + data: [ + { + selector: "#submit", + description: "Submit", + method: "click", + arguments: ["withValue"], + }, + ], + metadata: { actionId: "action_1" }, + }; + const observeWireValue = { + data: [ + { + selector: "#submit", + description: "Submit", + method: "click", + arguments: ["withValue"], + }, + ], + metadata: { action_id: "action_1" }, + }; + + expect(encodeWireValue(observeApiValue, observe.resultWire)).toStrictEqual(observeWireValue); + expect(wireSchema(observe.result, observe.resultWire).parse(observeWireValue)).toStrictEqual( + observeApiValue, + ); + + const extract = StagehandMethods.stagehandExtract; + const extractWireValue = { data: { callerChosenKey: 1 }, metadata: {} }; + expect(wireSchema(extract.result, extract.resultWire).parse(extractWireValue)).toStrictEqual( + extractWireValue, + ); + }); + it("keeps every generated method and notification shape snake_case", async () => { const protocol = JSON.parse(await readFile(schemaUrl, "utf8")) as Record; const properties = asRecord(protocol.properties); diff --git a/packages/protocol/types.ts b/packages/protocol/types.ts index ba16a9234..927ec27d2 100644 --- a/packages/protocol/types.ts +++ b/packages/protocol/types.ts @@ -27,6 +27,7 @@ import type { BrowserbaseRegionSchema, BrowserbaseSessionCreateParamsSchema, BrowserbaseViewportSchema, + CacheStatusSchema, CachingSchema, CerebrasModelIdSchema, CerebrasModelNameSchema, @@ -185,6 +186,7 @@ import type { StagehandMetricsSchema, StagehandObserveParamsSchema, StagehandPingResultSchema, + StagehandResultMetadataSchema, SnapshotResultSchema, TelemetryConfigSchema, ThinkingEffortSchema, @@ -324,6 +326,8 @@ export type LocatorSendClickEventParams = z.infer; export type LocatorSelectOptionParams = z.infer; export type StagehandPingResult = z.infer; +export type CacheStatus = z.infer; +export type StagehandResultMetadata = z.infer; export type RuntimeConfigureResult = z.infer; export type RuntimeLoopbackStatusResult = z.infer; export type BrowserGetVersionResult = z.infer; diff --git a/packages/sdk-go/client_test.go b/packages/sdk-go/client_test.go index 13e21b8d9..d81606e7b 100644 --- a/packages/sdk-go/client_test.go +++ b/packages/sdk-go/client_test.go @@ -81,7 +81,7 @@ func TestThinClientUsesGeneratedBoundaryTypes(t *testing.T) { "stagehand.init": StagehandInitResult{Initialized: true}, "context.active_page": PageRef{PageID: "page-1"}, "page.goto": PageRef{PageID: "page-1"}, - "stagehand.act": ActResult{Result: ActResultData{ + "stagehand.act": ActResult{Data: ActResultData{ Success: true, Message: "clicked", ActionDescription: "click", Actions: []Action{}, }}, "stagehand.close": StagehandCloseResult{Closed: true}, diff --git a/packages/sdk-go/examples/act.go b/packages/sdk-go/examples/act.go index e19ff9153..c7bf86786 100644 --- a/packages/sdk-go/examples/act.go +++ b/packages/sdk-go/examples/act.go @@ -59,8 +59,8 @@ func run(ctx context.Context) (err error) { return err } fmt.Println(string(output)) - if !result.Success { - return fmt.Errorf("act failed: %s", result.Message) + if !result.Data.Success { + return fmt.Errorf("act failed: %s", result.Data.Message) } return nil } diff --git a/packages/sdk-go/examples/caching.go b/packages/sdk-go/examples/caching.go index 8538e04e8..92d79be0f 100644 --- a/packages/sdk-go/examples/caching.go +++ b/packages/sdk-go/examples/caching.go @@ -88,7 +88,7 @@ func run(ctx context.Context) (err error) { } extractCompanies := func() (companies, time.Duration, error) { start := time.Now() - raw, extractErr := client.Extract( + extractResult, extractErr := client.Extract( ctx, "Extract the names and descriptions of the first five companies listed on the page", companiesSchema, @@ -98,7 +98,7 @@ func run(ctx context.Context) (err error) { return companies{}, time.Since(start), extractErr } var result companies - if decodeErr := json.Unmarshal(raw, &result); decodeErr != nil { + if decodeErr := json.Unmarshal(extractResult.Data, &result); decodeErr != nil { return companies{}, time.Since(start), decodeErr } return result, time.Since(start), nil diff --git a/packages/sdk-go/examples/custom-llm.go b/packages/sdk-go/examples/custom-llm.go index a8b9c03fe..46e36d892 100644 --- a/packages/sdk-go/examples/custom-llm.go +++ b/packages/sdk-go/examples/custom-llm.go @@ -78,11 +78,11 @@ func run(ctx context.Context) (err error) { return err } fmt.Println(string(output)) - if len(actions) == 0 { + if len(actions.Data) == 0 { return errors.New("observe returned no matching actions") } - if !actionResult.Success { - return fmt.Errorf("act failed: %s", actionResult.Message) + if !actionResult.Data.Success { + return fmt.Errorf("act failed: %s", actionResult.Data.Message) } return nil } diff --git a/packages/sdk-go/examples/extract.go b/packages/sdk-go/examples/extract.go index 2e4b031f2..81704cc7d 100644 --- a/packages/sdk-go/examples/extract.go +++ b/packages/sdk-go/examples/extract.go @@ -61,12 +61,17 @@ func run(ctx context.Context) (err error) { return err } - raw, err := client.Extract(ctx, "Extract the page heading and description", pageInfoSchema, nil) + result, err := client.Extract( + ctx, + "Extract the page heading and description", + pageInfoSchema, + nil, + ) if err != nil { return err } var info pageInfo - if err := json.Unmarshal(raw, &info); err != nil { + if err := json.Unmarshal(result.Data, &info); err != nil { return fmt.Errorf("decode extracted page info: %w", err) } output, err := json.MarshalIndent(info, "", " ") diff --git a/packages/sdk-go/examples/observe.go b/packages/sdk-go/examples/observe.go index 93bb64491..7c7123a30 100644 --- a/packages/sdk-go/examples/observe.go +++ b/packages/sdk-go/examples/observe.go @@ -56,7 +56,7 @@ func run(ctx context.Context) (err error) { return err } fmt.Println(string(output)) - if len(actions) == 0 { + if len(actions.Data) == 0 { return errors.New("observe returned no matching actions") } return nil diff --git a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip index b1c3d8af2..d043c3584 100644 Binary files a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip and b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip differ diff --git a/packages/sdk-go/models.gen.go b/packages/sdk-go/models.gen.go index 4a56ac534..13343442e 100644 --- a/packages/sdk-go/models.gen.go +++ b/packages/sdk-go/models.gen.go @@ -25,14 +25,11 @@ type ActOptions struct { } type ActResult struct { - // Action ID for tracking - ActionID *string `json:"action_id,omitempty,omitzero"` - - // Server-side cache status for this result - CacheStatus *CacheStatus `json:"cache_status,omitempty,omitzero"` + // Data corresponds to the JSON schema field "data". + Data ActResultData `json:"data"` - // Result corresponds to the JSON schema field "result". - Result ActResultData `json:"result"` + // Metadata corresponds to the JSON schema field "metadata". + Metadata StagehandResultMetadata `json:"metadata"` } type ActResultData struct { @@ -536,14 +533,11 @@ type ExtractOptions struct { } type ExtractResult struct { - // Action ID for tracking - ActionID *string `json:"action_id,omitempty,omitzero"` - - // Server-side cache status for this result - CacheStatus *CacheStatus `json:"cache_status,omitempty,omitzero"` + // Data corresponds to the JSON schema field "data". + Data json.RawMessage `json:"data"` - // Result corresponds to the JSON schema field "result". - Result json.RawMessage `json:"result"` + // Metadata corresponds to the JSON schema field "metadata". + Metadata StagehandResultMetadata `json:"metadata"` } type GoogleModelName string @@ -1105,14 +1099,11 @@ type ObserveOptions struct { } type ObserveResult struct { - // Action ID for tracking - ActionID *string `json:"action_id,omitempty,omitzero"` - - // Server-side cache status for this result - CacheStatus *CacheStatus `json:"cache_status,omitempty,omitzero"` + // Data corresponds to the JSON schema field "data". + Data []Action `json:"data"` - // Result corresponds to the JSON schema field "result". - Result []Action `json:"result"` + // Metadata corresponds to the JSON schema field "metadata". + Metadata StagehandResultMetadata `json:"metadata"` } type OpenAIModelName string @@ -1817,6 +1808,14 @@ type StagehandPingResult struct { Runtime string `json:"runtime"` } +type StagehandResultMetadata struct { + // Action ID for tracking + ActionID *string `json:"action_id,omitempty,omitzero"` + + // Server-side cache status for this result + CacheStatus *CacheStatus `json:"cache_status,omitempty,omitzero"` +} + type TelemetryConfig struct { // Traces corresponds to the JSON schema field "traces". Traces TelemetryTraces `json:"traces"` @@ -2427,6 +2426,10 @@ type generatedModelCatalog struct { // StagehandPingResult corresponds to the JSON schema field "StagehandPingResult". StagehandPingResult *StagehandPingResult `json:"StagehandPingResult,omitempty,omitzero"` + // StagehandResultMetadata corresponds to the JSON schema field + // "StagehandResultMetadata". + StagehandResultMetadata *StagehandResultMetadata `json:"StagehandResultMetadata,omitempty,omitzero"` + // TelemetryConfig corresponds to the JSON schema field "TelemetryConfig". TelemetryConfig *TelemetryConfig `json:"TelemetryConfig,omitempty,omitzero"` diff --git a/packages/sdk-go/stagehand.go b/packages/sdk-go/stagehand.go index 9a66268a5..2175ac43a 100644 --- a/packages/sdk-go/stagehand.go +++ b/packages/sdk-go/stagehand.go @@ -92,14 +92,14 @@ func (s *Stagehand) Act( ctx context.Context, input string, options *StagehandClientActOptions, -) (ActResultData, error) { +) (ActResult, error) { rpc, err := s.connectedProtocol() if err != nil { - return ActResultData{}, err + return ActResult{}, err } page, err := s.targetPage(ctx, pageFromActOptions(options)) if err != nil { - return ActResultData{}, err + return ActResult{}, err } params := StagehandActParams{PageID: page.PageID(), Input: input} if options != nil { @@ -107,9 +107,9 @@ func (s *Stagehand) Act( } var result ActResult if err := rpc.call(ctx, "stagehand.act", params, &result); err != nil { - return ActResultData{}, err + return ActResult{}, err } - return result.Result, nil + return result, nil } // Observe finds actions on the selected or active page. @@ -117,14 +117,14 @@ func (s *Stagehand) Observe( ctx context.Context, instruction *string, options *StagehandClientObserveOptions, -) ([]Action, error) { +) (ObserveResult, error) { rpc, err := s.connectedProtocol() if err != nil { - return nil, err + return ObserveResult{}, err } page, err := s.targetPage(ctx, pageFromObserveOptions(options)) if err != nil { - return nil, err + return ObserveResult{}, err } params := StagehandObserveParams{PageID: page.PageID(), Instruction: instruction} if options != nil { @@ -132,9 +132,9 @@ func (s *Stagehand) Observe( } var result ObserveResult if err := rpc.call(ctx, "stagehand.observe", params, &result); err != nil { - return nil, err + return ObserveResult{}, err } - return result.Result, nil + return result, nil } // Extract returns the generated protocol's dynamic JSON result for the @@ -144,14 +144,14 @@ func (s *Stagehand) Extract( instruction string, schema json.RawMessage, options *StagehandClientExtractOptions, -) (json.RawMessage, error) { +) (ExtractResult, error) { rpc, err := s.connectedProtocol() if err != nil { - return nil, err + return ExtractResult{}, err } page, err := s.targetPage(ctx, pageFromExtractOptions(options)) if err != nil { - return nil, err + return ExtractResult{}, err } params := StagehandExtractParams{ PageID: page.PageID(), Instruction: instruction, Schema: schema, @@ -161,9 +161,9 @@ func (s *Stagehand) Extract( } var result ExtractResult if err := rpc.call(ctx, "stagehand.extract", params, &result); err != nil { - return nil, err + return ExtractResult{}, err } - return result.Result, nil + return result, nil } // ExtractAs decodes an Extract result into a caller-selected Go type. @@ -175,11 +175,11 @@ func ExtractAs[T any]( options *StagehandClientExtractOptions, ) (T, error) { var value T - raw, err := client.Extract(ctx, instruction, schema, options) + result, err := client.Extract(ctx, instruction, schema, options) if err != nil { return value, err } - if err := json.Unmarshal(raw, &value); err != nil { + if err := json.Unmarshal(result.Data, &value); err != nil { return value, fmt.Errorf("decode stagehand.extract result: %w", err) } return value, nil diff --git a/packages/sdk-python/examples/act.py b/packages/sdk-python/examples/act.py index 14480f6bd..96016d06e 100644 --- a/packages/sdk-python/examples/act.py +++ b/packages/sdk-python/examples/act.py @@ -31,8 +31,8 @@ async def main() -> None: print(json.dumps(result.model_dump(mode="json", by_alias=True), indent=2)) - if not result.success: - raise RuntimeError(f"act() failed: {result.message}") + if not result.data.success: + raise RuntimeError(f"act() failed: {result.data.message}") finally: await stagehand.close() diff --git a/packages/sdk-python/examples/caching.py b/packages/sdk-python/examples/caching.py index df4daec31..7d892531b 100644 --- a/packages/sdk-python/examples/caching.py +++ b/packages/sdk-python/examples/caching.py @@ -5,7 +5,7 @@ from pydantic import BaseModel -from stagehand import Stagehand +from stagehand import ExtractResult, Stagehand BROWSERBASE_API_KEY = os.environ["BROWSERBASE_API_KEY"] OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] @@ -41,7 +41,7 @@ async def main() -> None: raise RuntimeError("Stagehand initialized without an active page") await page.goto("https://aigrant.com") - async def extract_companies() -> tuple[Companies, int]: + async def extract_companies() -> tuple[ExtractResult[Companies], int]: start = perf_counter() result = await stagehand.extract( instruction=( @@ -56,11 +56,13 @@ async def extract_companies() -> tuple[Companies, int]: first, first_duration_ms = await extract_companies() print(f"First extraction ({first_duration_ms}ms):") - print(json.dumps(first.model_dump(mode="json"), indent=2)) + print(json.dumps(first.data.model_dump(mode="json"), indent=2)) + print(f"Cache status: {first.metadata.cache_status or 'disabled'}") second, second_duration_ms = await extract_companies() print(f"Second extraction ({second_duration_ms}ms):") - print(json.dumps(second.model_dump(mode="json"), indent=2)) + print(json.dumps(second.data.model_dump(mode="json"), indent=2)) + print(f"Cache status: {second.metadata.cache_status or 'disabled'}") finally: await stagehand.close() diff --git a/packages/sdk-python/examples/custom_llm.py b/packages/sdk-python/examples/custom_llm.py index bdf2831c1..e8451d453 100644 --- a/packages/sdk-python/examples/custom_llm.py +++ b/packages/sdk-python/examples/custom_llm.py @@ -65,7 +65,7 @@ async def main() -> None: { "page_info": page_info.model_dump(mode="json"), "actions": [ - action.model_dump(mode="json", by_alias=True) for action in actions + action.model_dump(mode="json", by_alias=True) for action in actions.data ], "action_result": action_result.model_dump(mode="json", by_alias=True), "generation_names": generation_names, @@ -74,10 +74,10 @@ async def main() -> None: ) ) - if not actions: + if not actions.data: raise RuntimeError("observe() returned no matching actions") - if not action_result.success: - raise RuntimeError(f"act() failed: {action_result.message}") + if not action_result.data.success: + raise RuntimeError(f"act() failed: {action_result.data.message}") finally: await stagehand.close() diff --git a/packages/sdk-python/examples/observe.py b/packages/sdk-python/examples/observe.py index c23439668..3b8748dd6 100644 --- a/packages/sdk-python/examples/observe.py +++ b/packages/sdk-python/examples/observe.py @@ -31,12 +31,12 @@ async def main() -> None: print( json.dumps( - [action.model_dump(mode="json", by_alias=True) for action in actions], + [action.model_dump(mode="json", by_alias=True) for action in actions.data], indent=2, ) ) - if not actions: + if not actions.data: raise RuntimeError("observe() returned no matching actions") finally: await stagehand.close() diff --git a/packages/sdk-python/src/stagehand/__init__.py b/packages/sdk-python/src/stagehand/__init__.py index 8a33047e8..fe7aff046 100644 --- a/packages/sdk-python/src/stagehand/__init__.py +++ b/packages/sdk-python/src/stagehand/__init__.py @@ -2,12 +2,14 @@ from ._generated.models import ( Action, + ActResult, ActResultData, Animations, BrowserbaseBrowserSettings, BrowserbaseProxyConfig, BrowserbaseRegion, BrowserGetVersionResult, + CacheStatus, Caret, Cookie, CookieParam, @@ -23,12 +25,14 @@ LoadState, ModelConfig, MouseButton, + ObserveResult, PageScreenshotClip, RgbaColor, RuntimeLoopbackStatusResult, Scale, StagehandMetrics, StagehandPingResult, + StagehandResultMetadata, State, TelemetryConfig, Variables, @@ -43,6 +47,7 @@ from .browser_context import BrowserContext from .client_models import ( CacheOptions, + ExtractResult, LLMGenerateCallback, LLMGenerateInput, LLMGenerateOutput, @@ -54,6 +59,7 @@ __all__ = [ "ActResultData", + "ActResult", "Action", "Animations", "BrowserGetVersionResult", @@ -62,12 +68,14 @@ "BrowserbaseBrowserSettings", "BrowserbaseProxyConfig", "BrowserbaseRegion", + "CacheStatus", "CacheOptions", "Caret", "Cookie", "CookieParam", "DomainPolicy", "ExternalProxyConfig", + "ExtractResult", "LLMGenerateCallback", "LLMGenerateInput", "LLMGenerateOutput", @@ -82,6 +90,7 @@ "Locator", "ModelConfig", "MouseButton", + "ObserveResult", "Page", "PageScreenshotClip", "ProtocolLocator", @@ -93,6 +102,7 @@ "StagehandClientLoggingConfig", "StagehandMetrics", "StagehandPingResult", + "StagehandResultMetadata", "State", "TelemetryConfig", "Variables", diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index bb33c9b4e..42203cbb5 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -58,11 +58,8 @@ class ActResult(WireModel): extra="forbid", validate_by_name=True, ) - result: ActResultData - action_id: Optional[StrictStr] = None - """Action ID for tracking""" - cache_status: Optional[CacheStatus] = None - """Server-side cache status for this result""" + data: ActResultData + metadata: StagehandResultMetadata class ActResultData(WireModel): @@ -609,12 +606,9 @@ class ExtractResult(WireModel): extra="forbid", validate_by_name=True, ) - result: Any + data: Optional[FieldSchema1] """Extracted data matching the requested schema""" - action_id: Optional[StrictStr] = None - """Action ID for tracking""" - cache_status: Optional[CacheStatus] = None - """Server-side cache status for this result""" + metadata: StagehandResultMetadata class FieldSchema0( @@ -629,6 +623,12 @@ class FieldSchema1( root: Optional[Union[StrictStr, StrictFloat, StrictBool, list[Optional["FieldSchema1"]], dict[StrictStr, Optional["FieldSchema1"]]]] +class FieldSchema10( + RootModel[Optional[Union[StrictStr, StrictFloat, StrictBool, list[Optional["FieldSchema10"]], dict[StrictStr, Optional["FieldSchema10"]]]]] +): + root: Optional[Union[StrictStr, StrictFloat, StrictBool, list[Optional["FieldSchema10"]], dict[StrictStr, Optional["FieldSchema10"]]]] + + class FieldSchema2( RootModel[Optional[Union[StrictStr, StrictFloat, StrictBool, list[Optional["FieldSchema2"]], dict[StrictStr, Optional["FieldSchema2"]]]]] ): @@ -716,7 +716,7 @@ class JSONRPCErrorObject(WireModel): ) code: Annotated[StrictInt, Field(ge=-9007199254740991, le=9007199254740991)] message: StrictStr - data: Optional[FieldSchema9] = None + data: Optional[FieldSchema10] = None class JSONRPCRequestId(RootModel[StrictInt]): @@ -796,7 +796,7 @@ class LLMJsonSchemaResponseFormat(WireModel): type: Literal["json_schema"] name: StrictStr description: Optional[StrictStr] = None - schema_: Annotated[Optional[FieldSchema3], Field(alias="schema")] + schema_: Annotated[Optional[FieldSchema4], Field(alias="schema")] class LLMMessage(WireModel): @@ -867,7 +867,7 @@ class LLMStructuredGenerateResult(WireModel): stop_reason: Optional[StrictStr] = None usage: Optional[LLMUsage] = None output_format: Literal["json_schema"] - structured_content: Optional[FieldSchema5] + structured_content: Optional[FieldSchema6] class LLMGenerateResult( @@ -941,7 +941,7 @@ class LLMToolJson(WireModel): ) field_schema: Annotated[Optional[StrictStr], Field(alias="$schema")] = None type: Literal["object"] - properties: Optional[dict[StrictStr, dict[StrictStr, Optional[FieldSchema4]]]] = ( + properties: Optional[dict[StrictStr, dict[StrictStr, Optional[FieldSchema5]]]] = ( None ) required: Optional[list[StrictStr]] = None @@ -955,7 +955,7 @@ class LLMToolResultContent(WireModel): type: Literal["tool_result"] tool_use_id: StrictStr content: list[LLMToolResultContentBlock] - structured_content: Optional[dict[StrictStr, Optional[FieldSchema2]]] = None + structured_content: Optional[dict[StrictStr, Optional[FieldSchema3]]] = None is_error: Optional[StrictBool] = None @@ -971,7 +971,7 @@ class LLMToolUseContent(WireModel): type: Literal["tool_use"] id: StrictStr name: StrictStr - input: dict[StrictStr, Optional[FieldSchema1]] + input: dict[StrictStr, Optional[FieldSchema2]] class LLMMessageContentBlock( @@ -1340,11 +1340,8 @@ class ObserveResult(WireModel): extra="forbid", validate_by_name=True, ) - result: list[Action] - action_id: Optional[StrictStr] = None - """Action ID for tracking""" - cache_status: Optional[CacheStatus] = None - """Server-side cache status for this result""" + data: list[Action] + metadata: StagehandResultMetadata class OpenAIModelName(RootModel[StrictStr]): @@ -1472,7 +1469,7 @@ class PageEvaluateResult(WireModel): extra="forbid", validate_by_name=True, ) - value: Optional[FieldSchema6] + value: Optional[FieldSchema7] class PageGoBackParams(WireModel): @@ -1947,8 +1944,8 @@ class StagehandLog(WireModel): data: StagehandLogData -class StagehandLogData(RootModel[dict[StrictStr, Optional[FieldSchema7]]]): - root: dict[StrictStr, Optional[FieldSchema7]] +class StagehandLogData(RootModel[dict[StrictStr, Optional[FieldSchema8]]]): + root: dict[StrictStr, Optional[FieldSchema8]] class StagehandLogLevel(StrEnum): @@ -2004,6 +2001,17 @@ class StagehandPingResult(WireModel): runtime: Literal["service_worker"] +class StagehandResultMetadata(WireModel): + model_config = ConfigDict( + extra="forbid", + validate_by_name=True, + ) + action_id: Optional[StrictStr] = None + """Action ID for tracking""" + cache_status: Optional[CacheStatus] = None + """Server-side cache status for this result""" + + class State(StrEnum): attached = "attached" detached = "detached" @@ -2059,6 +2067,7 @@ class Variables(RootModel[dict[StrictStr, VariableValue]]): FieldSchema0.model_rebuild() FieldSchema1.model_rebuild() +FieldSchema10.model_rebuild() FieldSchema2.model_rebuild() FieldSchema3.model_rebuild() FieldSchema4.model_rebuild() diff --git a/packages/sdk-python/src/stagehand/client_models.py b/packages/sdk-python/src/stagehand/client_models.py index d726632f2..e66cc303d 100644 --- a/packages/sdk-python/src/stagehand/client_models.py +++ b/packages/sdk-python/src/stagehand/client_models.py @@ -1,9 +1,9 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Annotated, Literal +from typing import Annotated, Any, Generic, Literal, TypeVar -from pydantic import ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from ._generated import models as _models from ._generated.models import ( @@ -19,9 +19,28 @@ ProxyConfig, StagehandInitParams, StagehandLog, + StagehandResultMetadata, ) from ._validation import WireModel +ExtractData = TypeVar("ExtractData", bound=BaseModel) + + +class ExtractResult(WireModel, Generic[ExtractData]): + model_config = ConfigDict(extra="forbid") + + data: ExtractData + metadata: StagehandResultMetadata + + +class _ExtractWireResult(WireModel): + """Internal extract response model that preserves arbitrary JSON values.""" + + model_config = ConfigDict(extra="forbid") + + data: Any + metadata: StagehandResultMetadata + class LocalProxyConfig(WireModel): model_config = ConfigDict(extra="forbid") diff --git a/packages/sdk-python/src/stagehand/stagehand.py b/packages/sdk-python/src/stagehand/stagehand.py index 80a23615b..df142ff64 100644 --- a/packages/sdk-python/src/stagehand/stagehand.py +++ b/packages/sdk-python/src/stagehand/stagehand.py @@ -13,10 +13,8 @@ from pydantic import BaseModel from ._generated.models import ( - Action, ActOptions, ActResult, - ActResultData, BrowserbaseBrowserSettings, BrowserbaseProxyConfig, BrowserbaseRegion, @@ -25,7 +23,6 @@ EmptyParams, ExternalProxyConfig, ExtractOptions, - ExtractResult, LLMGenerateParams, LLMGenerateResult, ModelConfig, @@ -56,6 +53,7 @@ Cache, CdpBrowserSource, ClientLLM, + ExtractResult, LLMGenerateCallback, LocalBrowserSource, LocalProxyConfig, @@ -63,6 +61,7 @@ StagehandClientInitParams, StagehandClientLoggingConfig, _cache_config, + _ExtractWireResult, _model_config, ) from .page import Page @@ -472,7 +471,7 @@ async def act( timeout: float | None = None, locator: ProtocolLocator | None = None, cache: Cache | None = None, - ) -> ActResultData: + ) -> ActResult: options = ActOptions.model_validate({ name: value for name, value in ( @@ -491,7 +490,7 @@ async def act( if options.model_fields_set: params.options = options result = await self._connected_rpc_client.send("stagehand.act", params, ActResult) - return result.result + return result async def observe( self, @@ -505,7 +504,7 @@ async def observe( ignore_selectors: list[str] | None = None, locator: ProtocolLocator | None = None, cache: Cache | None = None, - ) -> list[Action]: + ) -> ObserveResult: options = ObserveOptions.model_validate({ name: value for name, value in ( @@ -526,7 +525,7 @@ async def observe( if options.model_fields_set: params.options = options result = await self._connected_rpc_client.send("stagehand.observe", params, ObserveResult) - return result.result + return result async def extract( self, @@ -541,7 +540,7 @@ async def extract( screenshot: bool | None = None, locator: ProtocolLocator | None = None, cache: Cache | None = None, - ) -> ResultModel: + ) -> ExtractResult[ResultModel]: options = ExtractOptions.model_validate({ name: value for name, value in ( @@ -565,11 +564,13 @@ async def extract( ) if options.model_fields_set: params.options = options - result = await self._connected_rpc_client.send("stagehand.extract", params, ExtractResult) - value = ( - result.result.model_dump() if isinstance(result.result, BaseModel) else result.result + result = await self._connected_rpc_client.send( + "stagehand.extract", params, _ExtractWireResult + ) + return ExtractResult( + data=schema.model_validate(result.data), + metadata=result.metadata, ) - return schema.model_validate(value) async def close(self) -> None: async with self._lifecycle_lock: diff --git a/packages/sdk-python/tests/test_stagehand.py b/packages/sdk-python/tests/test_stagehand.py index be9649b59..76e8d3063 100644 --- a/packages/sdk-python/tests/test_stagehand.py +++ b/packages/sdk-python/tests/test_stagehand.py @@ -6,7 +6,7 @@ from typing import TypeVar, cast import pytest -from pydantic import BaseModel +from pydantic import BaseModel, StrictInt from stagehand import LLMGenerateInput, LLMGenerateOutput, Page, ProtocolLocator, Stagehand from stagehand._generated.models import ( @@ -14,8 +14,8 @@ ActResult, ActResultData, BrowserGetVersionResult, + CacheStatus, ClientModelReference, - ExtractResult, KnownModelConfig, LLMGenerateParams, LLMGenerateResult, @@ -36,6 +36,7 @@ StagehandMetrics, StagehandObserveParams, StagehandPingResult, + StagehandResultMetadata, ) from stagehand.browser_source import ResolvedBrowserSource from stagehand.cdp_client import CDPConnectionClosedError @@ -56,6 +57,7 @@ class PageInfo(BaseModel): heading: str + count: StrictInt def test_stagehand_constructor_builds_private_browser_and_model_models() -> None: @@ -253,9 +255,20 @@ async def test_stagehand_ai_methods_resolve_pages_and_validate_results( recording = RecordingRPCClient({ "stagehand.init": StagehandInitResult(initialized=True, pages=[]), "context.active_page": PageRef(page_id="active-page"), - "stagehand.act": ActResult(result=act_result), - "stagehand.observe": ObserveResult(result=[action]), - "stagehand.extract": ExtractResult(result={"heading": "Example Domain"}), + "stagehand.act": ActResult.model_validate({ + "data": act_result, + "metadata": {"cache_status": "HIT"}, + }), + "stagehand.observe": ObserveResult.model_validate({ + "data": [action], + "metadata": {"cache_status": "MISS"}, + }), + # Keep this as raw wire JSON: extract() must preserve integer values + # until the caller's Pydantic schema validates them. + "stagehand.extract": { + "data": {"heading": "Example Domain", "count": 1}, + "metadata": StagehandResultMetadata(cache_status=CacheStatus.hit), + }, }) model = ModelConfig.model_validate({"model_name": "openai/gpt-4.1-mini"}) locator = ProtocolLocator(selector="main") @@ -289,9 +302,13 @@ async def connect(**_: object) -> RPCClient: locator=locator, ) - assert action_result == act_result - assert actions == [action] - assert page_info == PageInfo(heading="Example Domain") + assert action_result.data == act_result + assert action_result.metadata.cache_status == "HIT" + assert actions.data == [action] + assert actions.metadata.cache_status == "MISS" + assert page_info.data == PageInfo(heading="Example Domain", count=1) + assert isinstance(page_info.data.count, int) + assert page_info.metadata.cache_status == "HIT" assert [call[0] for call in recording.calls] == [ "stagehand.init", "stagehand.act", diff --git a/packages/sdk-ts/examples/act.ts b/packages/sdk-ts/examples/act.ts index 2c6213407..f1c0b0e6c 100644 --- a/packages/sdk-ts/examples/act.ts +++ b/packages/sdk-ts/examples/act.ts @@ -30,8 +30,8 @@ try { console.log(JSON.stringify(result, null, 2)); - if (!result.success) { - throw new Error(`act() failed: ${result.message}`); + if (!result.data.success) { + throw new Error(`act() failed: ${result.data.message}`); } } finally { await stagehand.close(); diff --git a/packages/sdk-ts/examples/caching.ts b/packages/sdk-ts/examples/caching.ts index 9f10631b9..7f413ab58 100644 --- a/packages/sdk-ts/examples/caching.ts +++ b/packages/sdk-ts/examples/caching.ts @@ -49,11 +49,13 @@ try { const first = await extractCompanies(); console.log(`First extraction (expected cache miss, ${first.durationMs}ms):`); - console.log(JSON.stringify(first.result, null, 2)); + console.log(JSON.stringify(first.result.data, null, 2)); + console.log(`Cache status: ${first.result.metadata.cacheStatus ?? "disabled"}`); const second = await extractCompanies(); console.log(`Second extraction (expected cache hit, ${second.durationMs}ms):`); - console.log(JSON.stringify(second.result, null, 2)); + console.log(JSON.stringify(second.result.data, null, 2)); + console.log(`Cache status: ${second.result.metadata.cacheStatus ?? "disabled"}`); } finally { await stagehand.close(); } diff --git a/packages/sdk-ts/examples/custom-llm.ts b/packages/sdk-ts/examples/custom-llm.ts index be14ca1d0..254c9c593 100644 --- a/packages/sdk-ts/examples/custom-llm.ts +++ b/packages/sdk-ts/examples/custom-llm.ts @@ -52,11 +52,11 @@ try { console.log(JSON.stringify({ pageInfo, actions, actionResult, generationNames }, null, 2)); - if (actions.length === 0) { + if (actions.data.length === 0) { throw new Error("observe() returned no matching actions"); } - if (!actionResult.success) { - throw new Error(`act() failed: ${actionResult.message}`); + if (!actionResult.data.success) { + throw new Error(`act() failed: ${actionResult.data.message}`); } } finally { await stagehand.close(); diff --git a/packages/sdk-ts/examples/observe.ts b/packages/sdk-ts/examples/observe.ts index 7ec28e076..46e936b0c 100644 --- a/packages/sdk-ts/examples/observe.ts +++ b/packages/sdk-ts/examples/observe.ts @@ -30,7 +30,7 @@ try { console.log(JSON.stringify(actions, null, 2)); - if (actions.length === 0) { + if (actions.data.length === 0) { throw new Error("observe() returned no matching actions"); } } finally { diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index e80fcd7a9..32261c877 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -13,12 +13,16 @@ export { export { Locator } from "./locator.js"; export { Page, type ScreenshotOptions } from "./page.js"; export type { InitScriptSource } from "./pageScripts.js"; -export { Stagehand } from "./stagehand.js"; +export { Stagehand, type ExtractResult } from "./stagehand.js"; export type { + ActResult, BrowserGetVersionResult, + CacheStatus, + ObserveResult, RuntimeLoopbackStatusResult, StagehandMetrics, StagehandPingResult, + StagehandResultMetadata, } from "../../protocol/types.js"; export { BrowserSourceSchema, diff --git a/packages/sdk-ts/src/stagehand.ts b/packages/sdk-ts/src/stagehand.ts index 2fa6cbb65..b77b81350 100644 --- a/packages/sdk-ts/src/stagehand.ts +++ b/packages/sdk-ts/src/stagehand.ts @@ -2,9 +2,9 @@ import { connectRPCClient, type RPCClient, type RPCClientOptions } from "./rpcCl import { StagehandInitParamsSchema } from "../../protocol/schemas.js"; import { StagehandMethods } from "../../protocol/schema-registry.js"; import type { - ActResultData, - Action, + ActResult, BrowserGetVersionResult, + ObserveResult, RuntimeLoopbackStatusResult, StagehandMetrics, StagehandPingResult, @@ -35,6 +35,12 @@ type StagehandAdapters = { const stagehandAdapters = new WeakMap(); +type ProtocolExtractResult = import("../../protocol/types.js").ExtractResult; + +export type ExtractResult = Omit & { + data: z.output; +}; + export class Stagehand { browserContext: BrowserContext | undefined; isInitialized = false; @@ -140,7 +146,7 @@ export class Stagehand { this.closePromise = undefined; } - async act(input: string, options?: StagehandClientActOptions): Promise { + async act(input: string, options?: StagehandClientActOptions): Promise { const { page, ...protocolOptions } = StagehandClientActOptionsSchema.parse(options ?? {}); const targetPage = page ?? (await this.context.activePage()); if (!targetPage) throw new Error("Stagehand has no active page."); @@ -150,10 +156,13 @@ export class Stagehand { ...(options === undefined ? {} : { options: protocolOptions }), }); - return response.result; + return response; } - async observe(instruction?: string, options?: StagehandClientObserveOptions): Promise { + async observe( + instruction?: string, + options?: StagehandClientObserveOptions, + ): Promise { const { page, ...protocolOptions } = StagehandClientObserveOptionsSchema.parse(options ?? {}); const targetPage = page ?? (await this.context.activePage()); if (!targetPage) throw new Error("Stagehand has no active page."); @@ -163,14 +172,14 @@ export class Stagehand { ...(options === undefined ? {} : { options: protocolOptions }), }); - return response.result; + return response; } async extract( instruction: string, schema: Schema, options?: StagehandClientExtractOptions, - ): Promise> { + ): Promise> { const { page, ...protocolOptions } = StagehandClientExtractOptionsSchema.parse(options ?? {}); const targetPage = page ?? (await this.context.activePage()); if (!targetPage) throw new Error("Stagehand has no active page."); @@ -182,7 +191,10 @@ export class Stagehand { ...(options === undefined ? {} : { options: protocolOptions }), }); - return schema.parse(response.result); + return { + ...response, + data: schema.parse(response.data), + }; } close(): Promise { diff --git a/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts b/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts index 7f3e21ce3..9fa70d90f 100644 --- a/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts +++ b/packages/sdk-ts/tests/browser-runtime/stagehand-launch-connect-smoke.test.ts @@ -252,7 +252,10 @@ describe("Stagehand TS SDK launch/connect smoke", () => { activeStagehand.extract("Extract the page heading", z.object({ heading: z.string() }), { page, }), - ).resolves.toStrictEqual({ heading: "Stagehand SDK Smoke" }); + ).resolves.toStrictEqual({ + data: { heading: "Stagehand SDK Smoke" }, + metadata: {}, + }); }); it("observes actionable elements on a real page through the connected SDK", async () => { @@ -264,8 +267,8 @@ describe("Stagehand TS SDK launch/connect smoke", () => { const actions = await activeStagehand.observe("Find the Submit button", { page }); - expect(actions).toHaveLength(1); - expect(actions[0]).toMatchObject({ + expect(actions.data).toHaveLength(1); + expect(actions.data[0]).toMatchObject({ selector: expect.stringMatching(/^xpath=/), description: "Submit button", method: "click", @@ -466,16 +469,19 @@ describe("Stagehand TS SDK launch/connect smoke", () => { const result = await activeStagehand.act("Click the Submit button", { page }); expect(result).toMatchObject({ - success: true, - actionDescription: "Submit button", - actions: [ - { - selector: expect.stringMatching(/^xpath=/), - description: "Submit button", - method: "click", - arguments: [], - }, - ], + data: { + success: true, + actionDescription: "Submit button", + actions: [ + { + selector: expect.stringMatching(/^xpath=/), + description: "Submit button", + method: "click", + arguments: [], + }, + ], + }, + metadata: {}, }); await expect(page.locator("#locator-output").textContent()).resolves.toBe("clicked:"); }); diff --git a/packages/sdk-ts/tests/object-wrapper.test.ts b/packages/sdk-ts/tests/object-wrapper.test.ts index 912c0cc50..ed25f9b5b 100644 --- a/packages/sdk-ts/tests/object-wrapper.test.ts +++ b/packages/sdk-ts/tests/object-wrapper.test.ts @@ -700,7 +700,7 @@ describe("Stagehand TS object wrapper", () => { it("routes stagehand.act with an explicit page and returns the action result", async () => { const client = new FakeProtocolClient(); client.queueResponse(StagehandMethods.stagehandAct, { - result: { + data: { success: true, message: "Clicked the submit button", actionDescription: "Click the submit button", @@ -713,6 +713,7 @@ describe("Stagehand TS object wrapper", () => { }, ], }, + metadata: { cacheStatus: "HIT" }, }); const stagehand = createStagehandWithClientForTest(client); await stagehand.init(); @@ -725,17 +726,20 @@ describe("Stagehand TS object wrapper", () => { variables: { accountEmail: "user@example.com" }, }), ).resolves.toStrictEqual({ - success: true, - message: "Clicked the submit button", - actionDescription: "Click the submit button", - actions: [ - { - selector: "xpath=/html/body/button", - description: "Submit button", - method: "click", - arguments: [], - }, - ], + data: { + success: true, + message: "Clicked the submit button", + actionDescription: "Click the submit button", + actions: [ + { + selector: "xpath=/html/body/button", + description: "Submit button", + method: "click", + arguments: [], + }, + ], + }, + metadata: { cacheStatus: "HIT" }, }); expect(client.calls).toStrictEqual([ stagehandInitCall, @@ -753,7 +757,7 @@ describe("Stagehand TS object wrapper", () => { it("routes stagehand.observe with an explicit page and options", async () => { const client = new FakeProtocolClient(); client.queueResponse(StagehandMethods.stagehandObserve, { - result: [ + data: [ { selector: "xpath=/html/body/button", description: "Submit button", @@ -761,6 +765,7 @@ describe("Stagehand TS object wrapper", () => { arguments: [], }, ], + metadata: { cacheStatus: "MISS" }, }); const stagehand = createStagehandWithClientForTest(client); await stagehand.init(); @@ -778,14 +783,17 @@ describe("Stagehand TS object wrapper", () => { }, }, }), - ).resolves.toStrictEqual([ - { - selector: "xpath=/html/body/button", - description: "Submit button", - method: "click", - arguments: [], - }, - ]); + ).resolves.toStrictEqual({ + data: [ + { + selector: "xpath=/html/body/button", + description: "Submit button", + method: "click", + arguments: [], + }, + ], + metadata: { cacheStatus: "MISS" }, + }); expect(client.calls).toStrictEqual([ stagehandInitCall, requestCall(StagehandMethods.stagehandObserve, { @@ -808,11 +816,11 @@ describe("Stagehand TS object wrapper", () => { it("uses the active page when stagehand.observe has no explicit page or instruction", async () => { const client = new FakeProtocolClient(); client.queueResponse(StagehandMethods.contextActivePage, { pageId: "page-1" }); - client.queueResponse(StagehandMethods.stagehandObserve, { result: [] }); + client.queueResponse(StagehandMethods.stagehandObserve, { data: [], metadata: {} }); const stagehand = createStagehandWithClientForTest(client); await stagehand.init(); - await expect(stagehand.observe()).resolves.toStrictEqual([]); + await expect(stagehand.observe()).resolves.toStrictEqual({ data: [], metadata: {} }); expect(client.calls).toStrictEqual([ stagehandInitCall, requestCall(StagehandMethods.contextActivePage, {}), @@ -838,7 +846,8 @@ describe("Stagehand TS object wrapper", () => { it("sends the caller's Zod schema through stagehand.extract", async () => { const client = new FakeProtocolClient(); client.queueResponse(StagehandMethods.stagehandExtract, { - result: { heading: "Example Domain" }, + data: { heading: "Example Domain" }, + metadata: { cacheStatus: "HIT" }, }); const stagehand = createStagehandWithClientForTest(client); await stagehand.init(); @@ -847,7 +856,10 @@ describe("Stagehand TS object wrapper", () => { await expect( stagehand.extract("Extract the page heading", schema, { page, selector: "main" }), - ).resolves.toStrictEqual({ heading: "Example Domain" }); + ).resolves.toStrictEqual({ + data: { heading: "Example Domain" }, + metadata: { cacheStatus: "HIT" }, + }); expect(client.calls).toStrictEqual([ stagehandInitCall, requestCall(StagehandMethods.stagehandExtract, { @@ -862,7 +874,8 @@ describe("Stagehand TS object wrapper", () => { it("validates stagehand.extract data with the caller's original Zod schema", async () => { const client = new FakeProtocolClient(); client.queueResponse(StagehandMethods.stagehandExtract, { - result: { heading: 42 }, + data: { heading: 42 }, + metadata: {}, }); const stagehand = createStagehandWithClientForTest(client); await stagehand.init(); diff --git a/packages/server/services/actService.ts b/packages/server/services/actService.ts index 9f806a68d..79000a044 100644 --- a/packages/server/services/actService.ts +++ b/packages/server/services/actService.ts @@ -93,9 +93,7 @@ export async function act({ return { result, cacheValue: - result.result.success && result.result.actions.length > 0 - ? result.result.actions - : undefined, + result.data.success && result.data.actions.length > 0 ? result.data.actions : undefined, }; }, }); @@ -459,7 +457,7 @@ function successfulActionResult( } function actResult(result: ActResultData): ActResult { - return { result }; + return { data: result, metadata: {} }; } function describeAction(action: Action): string { diff --git a/packages/server/services/cacheService.ts b/packages/server/services/cacheService.ts index 20a33d6b7..feeb11ba4 100644 --- a/packages/server/services/cacheService.ts +++ b/packages/server/services/cacheService.ts @@ -155,7 +155,7 @@ export interface CacheExecuteOutcome { * or whenever any cache step fails, including `onHit` itself — falls back to * `execute` and then persists the outcome's `cacheValue`. */ -export async function withCache({ +export async function withCache({ method, page, data, @@ -212,7 +212,7 @@ export async function withCache({ if (getResponse?.hit && getResponse.value !== undefined && getResponse.value !== null) { try { const result = await onHit(getResponse.value); - result.cacheStatus = "HIT"; + result.metadata.cacheStatus = "HIT"; logger.debug("Cache hit", { category: "cache", method, @@ -239,7 +239,7 @@ export async function withCache({ } const outcome = await execute(); - outcome.result.cacheStatus = "MISS"; + outcome.result.metadata.cacheStatus = "MISS"; if (outcome.cacheValue !== undefined && outcome.cacheValue !== null) { try { diff --git a/packages/server/services/extractService.ts b/packages/server/services/extractService.ts index 0880cc8a6..873d17ed9 100644 --- a/packages/server/services/extractService.ts +++ b/packages/server/services/extractService.ts @@ -73,7 +73,7 @@ export async function extract({ caching: options?.cache, context: cache, logger, - onHit: (value) => ({ result: value }), + onHit: (value) => ({ data: z.json().parse(value), metadata: {} }), execute: () => runExtraction(), }); @@ -147,7 +147,7 @@ export async function extract({ ); return { - result: { result: output }, + result: { data: z.json().parse(output), metadata: {} }, cacheValue: output, llmUsage: { inputTokens: prompt_tokens, diff --git a/packages/server/services/observeService.ts b/packages/server/services/observeService.ts index 13359118d..95a4b7efc 100644 --- a/packages/server/services/observeService.ts +++ b/packages/server/services/observeService.ts @@ -63,7 +63,7 @@ export async function observe({ if (actions.length === 0) { throw new Error("Cached observe value contained no usable actions"); } - return { result: actions }; + return { data: actions, metadata: {} }; }, execute: () => runObservation(), }); @@ -147,7 +147,7 @@ export async function observe({ }); return { - result: { result: actions }, + result: { data: actions, metadata: {} }, cacheValue: actions.length > 0 ? actions : undefined, llmUsage: { inputTokens: observation.prompt_tokens, diff --git a/packages/server/tests/act.test.ts b/packages/server/tests/act.test.ts index 677f31724..e9afc6dc3 100644 --- a/packages/server/tests/act.test.ts +++ b/packages/server/tests/act.test.ts @@ -2,6 +2,7 @@ import { trace } from "@opentelemetry/api"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod/v4"; import type { LLMGenerateParams, LLMGenerateResult } from "../../protocol/types.js"; +import type { CacheClient } from "../clients/cacheClient.js"; import { performUnderstudyMethod, waitForDomNetworkQuiet, @@ -141,7 +142,7 @@ describe("act service", () => { 2_000, ); expect(result).toStrictEqual({ - result: { + data: { success: true, message: "Action [fill] performed successfully on selector: xpath=/html/body/input", actionDescription: "Email field", @@ -154,6 +155,7 @@ describe("act service", () => { }, ], }, + metadata: {}, }); }); @@ -196,8 +198,8 @@ describe("act service", () => { expect(clientLLMGenerate).toHaveBeenCalledTimes(2); expect(performAction).toHaveBeenCalledTimes(2); - expect(result.result.success).toBe(true); - expect(result.result.actions).toHaveLength(2); + expect(result.data.success).toBe(true); + expect(result.data.actions).toHaveLength(2); }); it("retries with a fresh selector when self-healing is enabled", async () => { @@ -246,7 +248,7 @@ describe("act service", () => { expect.any(StagehandLogger), undefined, ); - expect(result.result).toMatchObject({ + expect(result.data).toMatchObject({ success: true, actions: [{ selector: "xpath=/html/body/button[2]" }], }); @@ -267,12 +269,69 @@ describe("act service", () => { }); expect(performAction).not.toHaveBeenCalled(); - expect(result.result).toMatchObject({ + expect(result.data).toMatchObject({ success: false, message: "Failed to perform act: No action found", }); }); + it("persists successful actions and replays them from cache", async () => { + const frame = { + frameId: "frame-1", + getAccessibilityTree: vi.fn(async () => []), + }; + const captureSnapshot = vi.fn(async () => snapshot("0-12", "/html/body/button")); + const page = { + ...actPage(frame, captureSnapshot), + url: () => "https://example.com", + frames: () => [frame], + } as unknown as Page; + const get = vi.fn().mockResolvedValueOnce({ hit: false, cacheKey: "key" }); + const set = vi.fn().mockResolvedValue({ written: true, cacheKey: "key" }); + const cache = { + sessionId: "session-1", + client: { get, set } as unknown as CacheClient, + defaultCaching: true as const, + }; + const clientLLMGenerate = vi.fn( + async (): Promise => + actGeneration({ + elementId: "0-12", + description: "Submit button", + method: "click", + arguments: [], + }), + ); + + const miss = await actService.act({ + params: { pageId: "page-1", input: "Click submit" }, + page, + model: { source: "client" }, + clientLLMGenerate, + logger: testLogger(), + cache, + }); + + expect(miss.metadata.cacheStatus).toBe("MISS"); + expect(set).toHaveBeenCalledWith(expect.objectContaining({ value: miss.data.actions })); + + get.mockResolvedValueOnce({ hit: true, value: miss.data.actions, cacheKey: "key" }); + clientLLMGenerate.mockClear(); + + const hit = await actService.act({ + params: { pageId: "page-1", input: "Click submit" }, + page, + model: { source: "client" }, + clientLLMGenerate, + logger: testLogger(), + cache, + }); + + expect(hit.metadata.cacheStatus).toBe("HIT"); + expect(hit.data.actions).toStrictEqual(miss.data.actions); + expect(clientLLMGenerate).not.toHaveBeenCalled(); + }); + it("respects the act timeout across page preparation", async () => { const now = vi .spyOn(Date, "now") diff --git a/packages/server/tests/cache-service.test.ts b/packages/server/tests/cache-service.test.ts new file mode 100644 index 000000000..457c8bd7c --- /dev/null +++ b/packages/server/tests/cache-service.test.ts @@ -0,0 +1,74 @@ +import { trace } from "@opentelemetry/api"; +import { describe, expect, it, vi } from "vitest"; +import type { CacheClient } from "../clients/cacheClient.js"; +import { StagehandLogger } from "../logger.js"; +import * as cacheService from "../services/cacheService.js"; +import type { Frame } from "../understudy/frame.js"; + +describe("cache service", () => { + it("marks a cache hit without executing the live request", async () => { + const get = vi.fn().mockResolvedValue({ hit: true, value: { answer: 42 }, cacheKey: "key" }); + const execute = vi.fn(); + + const result = await cacheService.withCache({ + method: "extract", + page: cachePage(), + data: { instruction: "Extract the answer" }, + caching: true, + context: cacheContext(get, vi.fn()), + logger: testLogger(), + onHit: (value) => ({ data: value, metadata: {} }), + execute, + }); + + expect(result).toStrictEqual({ data: { answer: 42 }, metadata: { cacheStatus: "HIT" } }); + expect(execute).not.toHaveBeenCalled(); + }); + + it("marks a miss and persists the cache value", async () => { + const get = vi.fn().mockResolvedValue({ hit: false, cacheKey: "key" }); + const set = vi.fn().mockResolvedValue({ written: true, cacheKey: "key" }); + const execute = vi.fn().mockResolvedValue({ + result: { data: { answer: 42 }, metadata: {} }, + cacheValue: { answer: 42 }, + }); + + const result = await cacheService.withCache({ + method: "extract", + page: cachePage(), + data: { instruction: "Extract the answer" }, + caching: true, + context: cacheContext(get, set), + logger: testLogger(), + onHit: (value) => ({ data: value, metadata: {} }), + execute, + }); + + expect(result).toStrictEqual({ data: { answer: 42 }, metadata: { cacheStatus: "MISS" } }); + expect(set).toHaveBeenCalledWith(expect.objectContaining({ value: { answer: 42 } })); + }); +}); + +function cacheContext(get: ReturnType, set: ReturnType) { + return { + sessionId: "session-1", + client: { get, set } as unknown as CacheClient, + defaultCaching: false, + }; +} + +function cachePage() { + const frame = { + frameId: "frame-1", + getAccessibilityTree: async () => [], + } as unknown as Frame; + return { + url: () => "https://example.com", + frames: () => [frame], + mainFrame: () => frame, + }; +} + +function testLogger(): StagehandLogger { + return new StagehandLogger({ tracer: trace.getTracer("cache-service-test") }, () => {}); +} diff --git a/packages/server/tests/extract.test.ts b/packages/server/tests/extract.test.ts index 6a9903c53..b4ff11362 100644 --- a/packages/server/tests/extract.test.ts +++ b/packages/server/tests/extract.test.ts @@ -1,7 +1,10 @@ +import { trace } from "@opentelemetry/api"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod/v4"; import type { LLMGenerateParams, LLMGenerateResult } from "../../protocol/types.js"; import { extract } from "../inference.js"; +import { StagehandLogger } from "../logger.js"; +import * as extractService from "../services/extractService.js"; describe("extract inference", () => { it("runs extraction and completion metadata through structured LLM calls", async () => { @@ -93,3 +96,47 @@ describe("extract inference", () => { ).rejects.toThrow(); }); }); + +describe("extract service", () => { + it("returns the standard data and metadata result shape", async () => { + const generate = vi.fn(async (params: LLMGenerateParams): Promise => { + const name = params.responseFormat?.type === "json_schema" && params.responseFormat.name; + if (name === "Extraction") { + return { + role: "assistant", + content: { type: "text", text: "structured extraction" }, + outputFormat: "json_schema", + structuredContent: { count: 1 }, + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }; + } + return { + role: "assistant", + content: { type: "text", text: "metadata" }, + outputFormat: "json_schema", + structuredContent: { progress: "Extracted the count", completed: true }, + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }; + }); + + const result = await extractService.extract({ + params: { + pageId: "page-1", + instruction: "Extract the count", + schema: z.json().parse(z.toJSONSchema(z.object({ count: z.number() }))), + }, + page: { + captureSnapshot: async () => ({ + combinedTree: "[0-1] text: 1", + combinedXpathMap: {}, + combinedUrlMap: {}, + }), + }, + model: { source: "client" }, + clientLLMGenerate: generate, + logger: new StagehandLogger({ tracer: trace.getTracer("extract-service-test") }, () => {}), + }); + + expect(result).toStrictEqual({ data: { count: 1 }, metadata: {} }); + }); +}); diff --git a/packages/server/tests/observe.test.ts b/packages/server/tests/observe.test.ts index 64bee10ff..6c15df57d 100644 --- a/packages/server/tests/observe.test.ts +++ b/packages/server/tests/observe.test.ts @@ -152,7 +152,7 @@ describe("observe service", () => { }); expect(clientLLMGenerate).toHaveBeenCalledTimes(1); expect(result).toStrictEqual({ - result: [ + data: [ { selector: "xpath=/html/body/main/input", description: "Email field", @@ -166,6 +166,7 @@ describe("observe service", () => { arguments: ["xpath=/html/body/main/ul/li[2]"], }, ], + metadata: {}, }); expect(logs).toEqual( expect.arrayContaining([ diff --git a/rules/ast-grep/sdk-parity.test.ts b/rules/ast-grep/sdk-parity.test.ts index ad04a69e5..6f57fdfed 100644 --- a/rules/ast-grep/sdk-parity.test.ts +++ b/rules/ast-grep/sdk-parity.test.ts @@ -32,6 +32,12 @@ const goSource = new URL("../../packages/sdk-go/", import.meta.url); const protocolUrl = new URL("../../packages/protocol/stagehand.v4.json", import.meta.url); const registryUrl = new URL("../../packages/protocol/schema-registry.ts", import.meta.url); +// Extracted JSON is intentionally decoded through a dedicated wire model so +// Pydantic receives raw JSON values instead of the generated JSON union. +const pythonWireResultModels: Readonly> = { + "stagehand.extract": "_ExtractWireResult", +}; + type SdkLanguage = "go" | "typescript" | "python"; type ProtocolMethod = { @@ -329,7 +335,9 @@ describe("All language SDK operations remain in sync", () => { if (!protocolMethod) continue; const expectedParams = referencedModel(protocolMethod.properties.params.$ref); - const expectedResult = referencedModel(protocolMethod.properties.result.$ref); + const expectedResult = + pythonWireResultModels[call.method] ?? + referencedModel(protocolMethod.properties.result.$ref); const paramsModel = pythonModelName(call.params, call.scope, call.module); const resultModel = pythonModelName(call.result, call.scope, call.module); @@ -880,16 +888,16 @@ function pythonModelName( .replace(/\.model_validate$/, "") .split(".") .at(-1); - return model && /^[A-Z]/u.test(model) ? model : undefined; + return model && /^_?[A-Z]/u.test(model) ? model : undefined; } if (expression.kind() === "attribute") { const model = expression.text().split(".").at(-1); - return model && /^[A-Z]/u.test(model) ? model : undefined; + return model && /^_?[A-Z]/u.test(model) ? model : undefined; } if (expression.kind() !== "identifier") return undefined; - if (/^[A-Z]/u.test(expression.text())) return expression.text(); + if (/^_?[A-Z]/u.test(expression.text())) return expression.text(); if (seen.has(expression.text())) return undefined; seen.add(expression.text()); const assignment = [