-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(langchain): add Stagehand code-mode MCP example #2629
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: shrey/stg-2765-codemode-crewai
Are you sure you want to change the base?
Changes from all commits
c2ed35d
d57bce1
9c819cd
c9fa671
ced3e2c
3f6107f
8d9cc5f
2e20aa4
0c89f77
da13c9b
a16cb8a
2a6d8cc
0b5bccf
095b04b
0d5c95a
e6cff47
c29ce4a
2fe3165
fddca9e
a05413b
dde6fd4
1dbce0b
c91a195
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # LangChain Deep Agents with Stagehand code mode | ||
|
|
||
| This example gives a Deep Agent one browser tool, `code_execute`, without running generated | ||
| JavaScript in the Python agent process. A small trusted Python lease owner starts the | ||
| package-installed [Vercel Sandbox example](../vercel-sandbox) and receives its `{ url, token }` | ||
| connection. LangChain then connects directly through authenticated Streamable HTTP: | ||
|
|
||
| ```text | ||
| Deep Agent -> persistent LangChain MCP session -> authenticated HTTPS -> Vercel Sandbox | ||
| `-> Stagehand MCP | ||
| `-> generated JavaScript | ||
| ``` | ||
|
|
||
| The integration discovers the package's canonical tool description and uses it as the agent system | ||
| prompt. It does not copy the executor, schema, or code-mode skill. | ||
|
|
||
| ## Setup | ||
|
|
||
| Create a Python 3.12 environment and install the exact tested dependencies: | ||
|
|
||
| ```bash | ||
| python3.12 -m venv .venv | ||
| . .venv/bin/activate | ||
| python -m pip install -r packages/integrations/examples/langchain/requirements.txt | ||
| ``` | ||
|
|
||
| Build and pack the exact Stagehand packages under review: | ||
|
|
||
| ```bash | ||
| pnpm install | ||
| pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts | ||
| ``` | ||
|
|
||
| ## Run the end-to-end proof | ||
|
|
||
| ```bash | ||
| STAGEHAND_SANDBOX_ARTIFACTS="$PWD/packages/integrations/examples/vercel-sandbox/.artifacts" \ | ||
| BROWSERBASE_API_KEY=<api-key> \ | ||
| BROWSERBASE_PROJECT_ID=<project-id> \ | ||
| VERCEL_OIDC_TOKEN=<oidc-token> \ | ||
| OPENAI_API_KEY=<openai-key> \ | ||
| python packages/integrations/examples/langchain/e2e.py | ||
| ``` | ||
|
|
||
| For external CI, replace `VERCEL_OIDC_TOKEN` with `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, and | ||
| `VERCEL_TOKEN`. `STAGEHAND_LANGCHAIN_MODEL` selects the host model for the standalone agent and | ||
| defaults to `openai:gpt-5-mini`. | ||
|
|
||
| The proof uses one live package-installed sandbox and one explicit LangChain MCP session. It: | ||
|
|
||
| 1. invokes `code_execute` twice directly and requires the same page ID and DOM marker; | ||
| 2. requires a real Deep Agent model to select `code_execute` inside that same session; | ||
| 3. invokes the tool again to independently verify the model's browser-side change; | ||
| 4. proves the model key and a host-only marker are absent inside generated code; and | ||
| 5. closes the LangChain MCP session before ending the sandbox lease, emitting `PASS` only afterward. | ||
|
|
||
| The explicit `client.session("stagehand")` context and `load_mcp_tools(session)` call are important. | ||
| LangChain's convenience tool loader is stateless by default and would otherwise create a fresh MCP | ||
| session for each invocation, losing Stagehand's browser state. | ||
|
|
||
| ## Run a Deep Agent | ||
|
|
||
| ```bash | ||
| STAGEHAND_SANDBOX_ARTIFACTS="$PWD/packages/integrations/examples/vercel-sandbox/.artifacts" \ | ||
| BROWSERBASE_API_KEY=<api-key> \ | ||
| BROWSERBASE_PROJECT_ID=<project-id> \ | ||
| VERCEL_OIDC_TOKEN=<oidc-token> \ | ||
| OPENAI_API_KEY=<openai-key> \ | ||
| python packages/integrations/examples/langchain/agent.py \ | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| "Open https://example.com and return its title and URL." | ||
| ``` | ||
|
|
||
| The lease subprocess receives only Browserbase, Vercel, artifact, and runtime variables from its | ||
| allowlist. Outer model-provider credentials are intentionally excluded, and the sandbox foundation | ||
| brokers the Browserbase credential at its egress boundary. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import os | ||
| import sys | ||
| from builtins import BaseExceptionGroup | ||
| from collections.abc import AsyncIterator, Sequence | ||
| from contextlib import asynccontextmanager | ||
| from pathlib import Path | ||
| from typing import Any, Protocol | ||
|
|
||
| from deepagents import create_deep_agent | ||
| from langchain_core.tools import BaseTool | ||
| from langchain_mcp_adapters.client import MultiServerMCPClient | ||
| from langchain_mcp_adapters.tools import load_mcp_tools | ||
| from mcp import ClientSession | ||
|
|
||
| DEFAULT_STAGEHAND_MODEL = "openai:gpt-5-mini" | ||
|
|
||
|
|
||
| class StagehandLangChainConnectionError(RuntimeError): | ||
| """LangChain could not establish the authenticated Stagehand MCP connection.""" | ||
|
|
||
|
|
||
| class StagehandLangChainToolContractError(RuntimeError): | ||
| """The remote Stagehand MCP tool contract was not the expected code-mode API.""" | ||
|
|
||
|
|
||
| class StagehandLangChainCleanupError(RuntimeError): | ||
| """LangChain could not close its Stagehand MCP session.""" | ||
|
|
||
|
|
||
| class StagehandLangChainSetupError(RuntimeError): | ||
| """LangChain could not construct the Stagehand browser agent.""" | ||
|
|
||
|
|
||
| class StagehandLangChainRunError(RuntimeError): | ||
| """The LangChain Stagehand browser run failed.""" | ||
|
|
||
|
|
||
| class StagehandSandboxConnection(Protocol): | ||
| url: str | ||
| token: str | ||
|
|
||
|
|
||
| def create_stagehand_mcp_client( | ||
| connection: StagehandSandboxConnection, | ||
| ) -> MultiServerMCPClient: | ||
| """Create a native authenticated Streamable HTTP client.""" | ||
| return MultiServerMCPClient( | ||
| { | ||
| "stagehand": { | ||
| "transport": "streamable_http", | ||
| "url": connection.url, | ||
| "headers": {"Authorization": f"Bearer {connection.token}"}, | ||
| } | ||
| }, | ||
| tool_name_prefix=False, | ||
| handle_tool_errors=True, | ||
| ) | ||
|
|
||
|
|
||
| async def load_stagehand_code_tool(session: ClientSession) -> BaseTool: | ||
| """Discover the one canonical tool without creating another MCP session.""" | ||
| try: | ||
| tools = await load_mcp_tools(session) | ||
| except Exception: # noqa: BLE001 -- MCP discovery is an untyped boundary. | ||
| raise StagehandLangChainConnectionError( | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| "Could not discover the Stagehand MCP tool." | ||
| ) from None | ||
| tool_names = [tool.name for tool in tools] | ||
| if tool_names != ["code_execute"]: | ||
| raise StagehandLangChainToolContractError( | ||
| "The Stagehand MCP server returned an invalid tool contract." | ||
| ) | ||
| if "# Stagehand V4 code-mode syntax" not in tools[0].description: | ||
| raise StagehandLangChainToolContractError( | ||
| "The Stagehand MCP server returned an invalid tool contract." | ||
| ) | ||
| return tools[0] | ||
|
|
||
|
|
||
| @asynccontextmanager | ||
| async def stagehand_code_session( | ||
| connection: StagehandSandboxConnection, | ||
| ) -> AsyncIterator[BaseTool]: | ||
| """Keep one remote MCP session open for every tool call in an agent run.""" | ||
| try: | ||
| client = create_stagehand_mcp_client(connection) | ||
| except Exception: # noqa: BLE001 -- client construction is an untyped boundary. | ||
| raise StagehandLangChainConnectionError( | ||
| "Could not connect LangChain to the Stagehand MCP server." | ||
| ) from None | ||
|
|
||
| primary_error: BaseException | None = None | ||
| session_entered = False | ||
| try: | ||
| async with client.session("stagehand") as session: | ||
| session_entered = True | ||
| try: | ||
| code_tool = await load_stagehand_code_tool(session) | ||
| yield code_tool | ||
| except BaseException as error: | ||
| primary_error = error | ||
| raise | ||
| except ( | ||
| StagehandLangChainConnectionError, | ||
| StagehandLangChainToolContractError, | ||
| ): | ||
| raise | ||
| except BaseException as error: | ||
| if primary_error is not None: | ||
| if error is primary_error: | ||
| raise | ||
| raise BaseExceptionGroup( | ||
| "LangChain run and MCP cleanup both failed", | ||
| [ | ||
| primary_error, | ||
| StagehandLangChainCleanupError( | ||
| "Could not close the LangChain Stagehand MCP session." | ||
| ), | ||
| ], | ||
| ) from None | ||
| if not isinstance(error, Exception): | ||
| raise | ||
| if session_entered: | ||
| raise StagehandLangChainCleanupError( | ||
| "Could not close the LangChain Stagehand MCP session." | ||
| ) from None | ||
| raise StagehandLangChainConnectionError( | ||
| "Could not connect LangChain to the Stagehand MCP server." | ||
| ) from None | ||
|
|
||
|
|
||
| def build_stagehand_agent( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The newly added agent-construction and session-scoping paths ( Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in |
||
| code_tool: BaseTool, | ||
| model: str | Any = DEFAULT_STAGEHAND_MODEL, | ||
| ) -> Any: | ||
| if code_tool.name != "code_execute": | ||
| raise StagehandLangChainToolContractError( | ||
| "The Stagehand MCP server returned an invalid tool contract." | ||
| ) | ||
| try: | ||
| return create_deep_agent( | ||
| model=model, | ||
| tools=[code_tool], | ||
| system_prompt=code_tool.description, | ||
| ) | ||
| except Exception: # noqa: BLE001 -- Deep Agent construction is untyped. | ||
| raise StagehandLangChainSetupError( | ||
| "Could not configure the LangChain Stagehand agent." | ||
| ) from None | ||
|
|
||
|
|
||
| def stagehand_tool_call_count(result: dict[str, Any]) -> int: | ||
| """Count model-selected code_execute calls in a LangGraph result.""" | ||
| count = 0 | ||
| for message in result.get("messages", []): | ||
| for call in getattr(message, "tool_calls", []): | ||
| name = ( | ||
| call.get("name") | ||
| if isinstance(call, dict) | ||
| else getattr(call, "name", None) | ||
| ) | ||
| if name == "code_execute": | ||
| count += 1 | ||
| return count | ||
|
|
||
|
|
||
| async def run_stagehand_agent( | ||
| connection: StagehandSandboxConnection, | ||
| prompt: str, | ||
| model: str | Any = DEFAULT_STAGEHAND_MODEL, | ||
| ) -> dict[str, Any]: | ||
| async with stagehand_code_session(connection) as code_tool: | ||
| try: | ||
| return await build_stagehand_agent(code_tool, model).ainvoke( | ||
| {"messages": [{"role": "user", "content": prompt}]}, | ||
| config={"recursion_limit": 20}, | ||
| ) | ||
| except ( | ||
| StagehandLangChainSetupError, | ||
| StagehandLangChainToolContractError, | ||
| ): | ||
| raise | ||
| except Exception: # noqa: BLE001 -- Deep Agent execution is untyped. | ||
| raise StagehandLangChainRunError( | ||
| "The LangChain Stagehand agent run failed." | ||
| ) from None | ||
|
|
||
|
|
||
| def _last_message_text(result: dict[str, Any]) -> str: | ||
| messages: Sequence[Any] = result.get("messages", []) | ||
| if not messages: | ||
| return str(result) | ||
| content = getattr(messages[-1], "content", messages[-1]) | ||
| return content if isinstance(content, str) else str(content) | ||
|
|
||
|
|
||
| async def _main() -> None: | ||
| if len(sys.argv) < 2: | ||
| raise SystemExit(f'Usage: {Path(sys.argv[0]).name} "<browser task>"') | ||
| from sandbox import StagehandSandboxLease | ||
|
|
||
| prompt = " ".join(sys.argv[1:]) | ||
| model = os.environ.get("STAGEHAND_LANGCHAIN_MODEL", DEFAULT_STAGEHAND_MODEL) | ||
| with StagehandSandboxLease() as connection: | ||
| result = await run_stagehand_agent(connection, prompt, model=model) | ||
| print(_last_message_text(result)) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| asyncio.run(_main()) | ||
Uh oh!
There was an error while loading. Please reload this page.