Skip to content

Replace the Python Stagehand lifecycle with Stagehand.create - #2545

Merged
miguelg719 merged 21 commits into
v4-spikefrom
feat/stagehand-py-create-lifecycle
Aug 3, 2026
Merged

Replace the Python Stagehand lifecycle with Stagehand.create#2545
miguelg719 merged 21 commits into
v4-spikefrom
feat/stagehand-py-create-lifecycle

Conversation

@miguelg719

@miguelg719 miguelg719 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Completes the Python port of the browser-lifecycle stack (#2517#2523) by making await Stagehand.create(browser=..., ...) the sole construction path, mirroring the TypeScript end state.

  • makes Stagehand() unconstructible directly (module-private token; TypeError points at Stagehand.create)
  • removes init(), async-with, the flattened browser constructor kwargs, the BrowserSource union models, and browser_source.py
  • exports local_browser, browserbase, and StagehandBrowser from the package root
  • stagehand.browser returns the exact handle passed to create; a failed create releases the claim so the same handle can be retried
  • stagehand.close() never touches the browser or CDP socket — browser/session lifetime is exclusively browser.close()
  • worker init keeps the exact StagehandInitParams wire shape; handle metadata overrides caller api_key, and local handles omit browser entirely
  • migrates all 7 examples, README, and the CI wheel-smoke script to launch → create → stagehand.close()browser.close() (browser close in the outermost finally)
  • updates the Python ast-grep example-parity patterns to the Stagehand.create shape

Reviewer focus

  1. stagehand.close() stops the runtime; browser.close() owns browser cleanup — under no configuration does Stagehand close the transport or process.
  2. Browser acquisition options stay client-side and never cross the RPC boundary.
  3. One-shot asyncio semantics: memoized close(), claim release under failed create, concurrent-close safety.

Stack

Verification

  • full Python package gates green: generate.py --check, ruff format --check, ruff check, ty check, pytest (rewritten lifecycle suite incl. wire-shape, claim-retry, and concurrent-close tests)
  • repo pnpm run test:unit green (ast-grep example-parity + sdk-parity against the migrated sources)
  • changeset check passed; wheel smoke exercised by CI python-wheel-smoke

Summary by cubic

Make await Stagehand.create(browser=...) the only way to start the Python client. Adds local_browser and browserbase factories, moves browser lifetime to the browser handle, and hardens close/cancel and launch validation (incl. Python 3.12’s eager task factory).

  • Refactors

    • Stagehand() is no longer constructible; use Stagehand.create.
    • Removed init(), async-with usage, and BrowserSource models; deleted browser_source.py. ResolvedBrowserSource now lives in browser.py with dead fields removed.
    • Exported local_browser, browserbase, and StagehandBrowser from stagehand.
    • stagehand.browser returns the exact handle passed to create. Failed or canceled create releases the claim and detaches the RPC client with close_transport=False.
    • stagehand.close() stops the runtime only, is safe under Python 3.12’s eager task factory, and never closes the browser or CDP socket.
    • Removed connect_rpc_client; updated README, examples (incl. model_gateway.py), tests, smoke script, and ast-grep rules (Python now requires Stagehand.create).
    • Hardened browser handle: stricter launch validation (viewport pairing, list arg types), preserves explicit viewport when default flags are ignored, and treats a vanished Chrome as exited on close.
  • Migration

    • Launch a browser: browser = await local_browser.launch(...) or browser = await browserbase.launch(api_key=...).
    • Create the client: stagehand = await Stagehand.create(browser=browser, ...).
    • Close in order: await stagehand.close() then await browser.close() (put browser close in the outermost finally).
    • Remove any async with Stagehand(...) and calls to init().

Written for commit 9fdb16d. Summary will update on new commits.

Review in cubic

…review

- Mark the handle closed synchronously when close() is requested so a
  pending close cannot race a claim (close() now memoizes the task on
  call and returns a shielded awaitable).
- Run CDP-client and owned-source cleanup independently on connect
  failure, aggregating failures (BaseExceptionGroup specializes to
  ExceptionGroup for ordinary errors), and guarantee owned-source close
  in the handle close path via try/finally.
- Catch BaseException in _connect_browser so cancellation during CDP
  connect/download configuration no longer leaks a launched Chrome.
- Raise TypeError (matching the legacy constructor) for unpaired
  viewport_width/viewport_height.
- Validate nested proxy/viewport values strictly so bool ints and
  bytes strings are rejected instead of coerced.
…ad source fields

Review follow-ups on the create-lifecycle swap:
- Stagehand.close() no longer memoizes via a recursive asyncio.create_task(self.close())
  call, which infinitely recursed (RecursionError) under asyncio.eager_task_factory on
  Python 3.12+; the body now runs in a nested close_impl coroutine, mirroring the TS
  memoized-promise shape while keeping the stagehand.close RPC inside the public method
  for the ast-grep sdk-parity rule.
- Remove the dead ResolvedBrowserSource fields (resident_browser_connection, cdp_headers,
  write-only connect_timeout_ms) left over from the browser_source.py deletion.
- Restore cancellation coverage: cancelling Stagehand.create releases the claim, detaches
  the RPC client with close_transport=False, and leaves the browser open for retry; add a
  3.12+-gated regression test running close() under the eager task factory.
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9fdb16d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@miguelg719
miguelg719 marked this pull request as ready for review August 1, 2026 04:12

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 22 files

Confidence score: 2/5

  • In packages/sdk-python/src/stagehand/stagehand.py, the browserbase.launch()/browserbase.connect() path raises NotImplementedError before Stagehand.create() runs, which blocks Browserbase users from the required handle-based migration and causes immediate runtime failure — implement the Browserbase factory methods (or gate this path) before release.
  • In packages/sdk-python/examples/caching.py, the first call to browserbase.launch(api_key=...) crashes because BrowserbaseBrowser is still a stub, so the example is currently broken and can mislead adopters validating caching flows — update the example to a working path or finish the Browserbase session implementation first.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk-python/src/stagehand/stagehand.py">

<violation number="1" location="packages/sdk-python/src/stagehand/stagehand.py:91">
P1: Browserbase users cannot migrate to this required handle-based path: both `browserbase.launch()` and `browserbase.connect()` always raise before `Stagehand.create()` can run. Implement the Browserbase factories before removing the prior Browserbase construction route, or retain a working migration path.</violation>
</file>

<file name="packages/sdk-python/examples/caching.py">

<violation number="1" location="packages/sdk-python/examples/caching.py:29">
P1: This example will crash on its first line. `browserbase.launch(api_key=...)` is a stub in `BrowserbaseBrowser` that unconditionally raises `NotImplementedError("Browserbase sessions are not implemented yet")`, so `await browserbase.launch(...)` in caching.py fails before any browser is created — no session, and the subsequent `Stagehand.create`, page navigation, and caching demo never run. Browserbase session acquisition is not yet implemented in this client: local handles work fine (`local_browser.launch`), but this example was migrated to a factory that doesn't exist yet. Consider keeping caching.py on the local path, or leave the example using the not-yet-available browserbase path out of the migrated set until Browserbase acquisition lands.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant User as User Code
    participant BrowserF as Browser Factory
    participant Stagehand as Stagehand
    participant RPC as RPC Client
    participant Worker as Stagehand Worker
    participant CDP as CDP/Transport

    Note over User,CDP: NEW: Exclusive Stagehand.create() lifecycle

    User->>BrowserF: await local_browser.launch() or browserbase.launch()
    BrowserF-->>User: StagehandBrowser handle

    User->>Stagehand: await Stagehand.create(browser=browser, ...)
    Stagehand->>Stagehand: Validate handle via _BROWSER_TOKEN
    alt Invalid handle
        Stagehand-->>User: TypeError
    end

    Stagehand->>Stagehand: Build StagehandClientCreateConfig
    Stagehand->>BrowserF: _claim_browser(handle)
    BrowserF-->>Stagehand: _ClaimedBrowser (cdp_client, worker_init_metadata)

    Stagehand->>CDP: Create CDPClient from claimed transport
    Stagehand->>RPC: Create RPCClient(cdp_client, timeout)
    Stagehand->>RPC: Send stagehand.init

    Note over RPC,Worker: NEW: Wire shape uses worker_init_metadata
    RPC->>Worker: stagehand.init(protocol_version, browser_cdp_url, api_key, model, log_level, browser_metadata)
    alt Worker metadata defined
        Note over Worker: Worker api_key overrides caller api_key
        Note over Worker: Local browser omits browser field entirely
    end
    Worker-->>RPC: StagehandInitResult
    RPC-->>Stagehand: Initialized

    alt Init fails or is cancelled
        Stagehand->>BrowserF: _release_browser(handle)
        Stagehand->>RPC: Close RPC (detaches, no transport close)
        Stagehand-->>User: Propagates error
    else Success
        Stagehand-->>User: Stagehand instance
    end

    Note over User,CDP: Runtime operations (unchanged)

    User->>Stagehand: stagehand.act/extract/observe()
    Stagehand->>RPC: stagehand.act/etc.
    RPC->>Worker: RPC method call
    Worker-->>RPC: Result
    RPC-->>Stagehand: Result
    Stagehand-->>User: Response

    Note over User,CDP: NEW: Clean shutdown (explicit ordering)

    User->>Stagehand: await stagehand.close()
    Stagehand->>Stagehand: Memoized close (one-shot)
    Stagehand->>RPC: stagehand.close
    RPC->>Worker: stagehand.close
    Worker-->>RPC: StagehandCloseResult
    Stagehand->>RPC: Close RPC client (no transport close)
    RPC->>CDP: close(close_transport=False)
    Stagehand-->>User: done

    User->>BrowserF: await browser.close()
    BrowserF->>CDP: close transport/process
    CDP-->>BrowserF: done
    BrowserF-->>User: done

    Note over User,CDP: NEW: stagehand.close() never touches browser or CDP socket
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/stagehand.py
Comment thread packages/sdk-python/examples/caching.py
…d-py-create-lifecycle

# Conflicts:
#	packages/sdk-python/src/stagehand/client_models.py
#	packages/sdk-python/src/stagehand/stagehand.py
#	packages/sdk-python/tests/test_stagehand.py
Base automatically changed from feat/stagehand-py-browser-factories to v4-spike August 3, 2026 19:36
miguelg719 added a commit that referenced this pull request Aug 3, 2026
# why

Ports the merged TypeScript browser-lifecycle stack (#2517#2523) to the
Python SDK. This bottom PR lands the entire new-lifecycle machinery as
internal, unexported code so it can be reviewed in isolation while the
legacy constructor/`init()` lifecycle stays fully green.

# what changed

- adds `stagehand/browser.py`: nominal `StagehandBrowser` handle
(token-guarded construction, one-time Stagehand claim, idempotent
memoized `close()`)
- implements internal `local_browser.launch()` /
`local_browser.connect()` factories that resolve only after the
Stagehand extension service worker is ready
- enforces launched-versus-connected ownership (`owns_source = launched
and not keep_alive`) with `ExceptionGroup` cleanup semantics mirroring
the TS `connectBrowser`
- implements local downloads via root-session
`Browser.setDownloadBehavior` (previously `NotImplementedError`)
- reserves `browserbase.launch()` / `browserbase.connect()` API shape as
`NotImplementedError` stubs — the Python SDK has never had Browserbase
support; worker metadata is shaped `{api_key, browser:
BrowserSessionMetadata}` so a future implementation needs no
`Stagehand.create` changes
- adds `RPCClient.close(close_transport=False)` so Stagehand can later
detach from a browser-owned transport
- slims `browser_source.py` to a legacy adapter re-binding the moved
Chrome launcher; all existing monkeypatch seams keep working

# intentionally not included

- no public API change; `__init__.py` exports untouched
- no changes to the `Stagehand` constructor, `init()`, or async-with
- no changes under `_generated/` (already synced by the merged TS stack)

# stack

1. **this PR — browser handle, factories, and transport detach
(internal)**
2. #2545 — replace the constructor/`init()` lifecycle with
`Stagehand.create` + browser factories
3. #2552 — implement Browserbase sessions

# test plan

- new `tests/test_browser.py` (374 lines): claim/release, ownership
matrix, launch flags, downloads, connect extension routing
- `RPCClient` `close_transport=False` coverage; existing legacy suites
pass unchanged
- full package gates green: `uv sync --locked`, `generate.py --check`,
`ruff format --check`, `ruff check`, `ty check`, `pytest`
…-create-lifecycle

# Conflicts:
#	packages/sdk-python/src/stagehand/browser.py
#	packages/sdk-python/src/stagehand/browser_source.py
#	packages/sdk-python/src/stagehand/client_models.py
#	packages/sdk-python/tests/test_rpc_client.py
#	rules/ast-grep/example-parity.test.ts
#	rules/ast-grep/sdk-parity.test.ts
@miguelg719
miguelg719 merged commit c827b5a into v4-spike Aug 3, 2026
22 checks passed
miguelg719 added a commit that referenced this pull request Aug 3, 2026
# why

#2544 reserved `browserbase.launch` / `browserbase.connect` as
`NotImplementedError` stubs. This PR makes them real, using the official
`browserbase` PyPI SDK — the Python counterpart of the TS factories'
`@browserbasehq/sdk` usage.

# what changed

- adds `browserbase>=1.15,<2` as a runtime dependency
(`AsyncBrowserbase`), the SDK's first beyond pydantic + websockets
- `browserbase.launch(api_key=..., ...)`: creates a session,
provisioning the packaged Stagehand extension via an in-memory
deterministic zip upload; caller-supplied extension IDs (top-level or
`browser_settings`) suppress provisioning **and** cleanup
- merges `user_metadata` with `{"stagehand": "true",
"stagehand_sdk_language": "python"}` (Stagehand keys win)
- ownership mirrors TS: `keep_alive ?? False` decides release-on-close;
session release and owned-extension delete are idempotent per resource
with retry-on-next-close; release errors take precedence
- `browserbase.connect(api_key=..., session_id=...)`:
`sessions.retrieve`-based, errors `Browserbase session is not available
for connection` when no connect URL, never takes release ownership
- adds a preloaded-extension discovery mode to `CDPClient.connect`
(Browserbase sessions have the extension preinstalled; mirrors the TS/Go
service-worker readiness polling)
- worker init metadata carries `{api_key, browser: {session_id,
region?}}`, overriding the caller's `api_key` per the TS spread order
- sanitized `BrowserbaseSessionError` messages; official-SDK calls
verified against the installed package (camelCase `fingerprint` wire
mapping, `api_timeout` kwarg, `Content-Type` omitted on the body-less
extension DELETE)
- `examples/caching.py`'s existing `browserbase.launch` call now works
as written — no example inventory changes (ast-grep parity)

# stack

1. #2544 — browser handle, factories, and transport detach (internal)
2. #2545 — replace the constructor/`init()` lifecycle with
`Stagehand.create`
3. **this PR — implement Browserbase sessions**

# test plan

- hand-rolled fake `_BrowserbaseAPI` seam tests:
provisioning/suppression matrix, create-failure and cancellation
cleanup, empty-id/URL handling, close idempotence + retry, connect
paths, kwargs wire-shape tests (fingerprint camelCase, `api_timeout`),
preloaded-extension CDP discovery tests
- full package gates green: `uv lock --check`, `generate.py --check`,
`ruff format --check`, `ruff check`, `ty check`, `pytest` (239 passed),
sdist+wheel build, changeset + python-version sync checks, and every
`just check`/`just test` recipe command run manually
- live validation against the real Browserbase API (2026-08-02):
extension upload -> session create -> preloaded-worker discovery ->
`Stagehand.create` -> `stagehand.close` -> `browser.close` -> session
`COMPLETED`, uploaded extension deleted (404 on retrieve). Caught and
fixed one wire bug: `region: null` was rejected by the worker; the key
is now omitted when unset (87369b4)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds Browserbase session support to the Python browser factories via
`browserbase.launch(...)` and `browserbase.connect(...)`. Improves
preloaded extension discovery, deterministic extension packaging, and
omits an unset Browserbase region from the `stagehand.init` payload.

- **New Features**
- `browserbase.launch(api_key, ...)` creates a session, uploads the
Stagehand extension unless an `extension_id` is given, respects
`keep_alive`, and sets worker metadata `{api_key, session_id, region}`.
- `browserbase.connect(api_key, session_id, ...)` attaches to an
existing session; uses a caller `extension_id` or the preloaded
extension; never releases the session.
- CDP discovers preloaded extensions with service worker readiness
polling, detaches stale/incompatible or malformed workers, and exposes
the discovered extension ID.
  - Adds `browserbase>=1.15,<2` as a runtime dependency.

- **Bug Fixes**
- Correct Browserbase wire mapping: camelCase fingerprint keys,
`api_timeout`; reject empty `extension_id` (top-level or nested); omit
`Content-Type` on extension delete; clearer errors.
- Omit unset `region` from the `stagehand.init` payload (worker schema
rejects `region: null`).
- More robust cleanup and packaging: deterministic extension archives;
idempotent release/delete with retry; delete owned extensions on create
failure or cancellation. Docs clarify `keep_alive` behavior and
extension retention.

<sup>Written for commit 8234a67.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/browserbase/stagehand/pull/2552?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants