From c2ed35d08474d6969df4d4647b309c0921f8a548 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 11:43:21 -0700 Subject: [PATCH 1/8] feat(langchain): add Stagehand code-mode MCP example --- .../workflows/codemode-framework-examples.yml | 28 ++++++ packages/integrations/README.md | 1 + .../integrations/examples/langchain/README.md | 50 ++++++++++ .../integrations/examples/langchain/agent.py | 97 +++++++++++++++++++ .../examples/langchain/requirements.txt | 4 + .../integrations/examples/langchain/smoke.py | 94 ++++++++++++++++++ 6 files changed, 274 insertions(+) create mode 100644 packages/integrations/examples/langchain/README.md create mode 100644 packages/integrations/examples/langchain/agent.py create mode 100644 packages/integrations/examples/langchain/requirements.txt create mode 100644 packages/integrations/examples/langchain/smoke.py diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 6648c62646..0846df5084 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -100,3 +100,31 @@ jobs: env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} STAGEHAND_BROWSER: local + + langchain: + name: LangChain Deep Agents + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: ./.github/actions/setup-node-pnpm + with: + use-prebuilt-artifacts: "false" + + - uses: ./.github/actions/setup-chrome-verified + id: setup-chrome + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: packages/integrations/examples/langchain/requirements.txt + + - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations + - run: python -m pip install -r packages/integrations/examples/langchain/requirements.txt + - run: python packages/integrations/examples/langchain/smoke.py + env: + CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} + STAGEHAND_BROWSER: local diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 63292c383c..34bd0affb9 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -49,6 +49,7 @@ The process stays alive across calls and closes when its input stream ends. `SIG - [Vercel AI SDK](./examples/vercel) launches the stdio server through the AI SDK MCP client and keeps one process alive for the complete agent run. - [Mastra](./examples/mastra) discovers the canonical MCP toolset once and reuses one client and browser for the complete agent run. - [CrewAI](./examples/crewai) keeps its context-managed MCP adapter open across every tool call in one crew execution. +- [LangChain Deep Agents](./examples/langchain) uses one explicit MCP session so every tool call reaches the same browser. ### Configuration diff --git a/packages/integrations/examples/langchain/README.md b/packages/integrations/examples/langchain/README.md new file mode 100644 index 0000000000..60bd445add --- /dev/null +++ b/packages/integrations/examples/langchain/README.md @@ -0,0 +1,50 @@ +# LangChain Deep Agents + Stagehand code mode + +This example connects LangChain Deep Agents to the canonical Stagehand code-mode MCP server. The +server runs as a local stdio child process and exposes exactly one tool, `code_execute`. + +The explicit `client.session("stagehand")` context in [`agent.py`](./agent.py) is required. Do not +replace it with `client.get_tools()`: that convenience API creates a new session for each tool call, +which would start a new stdio process and lose the browser state created by the previous call. + +## Setup + +Build the Stagehand-local MCP server from the repository root: + +```bash +pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations +``` + +Create an isolated Python 3.12 environment from this directory: + +```bash +uv venv --python 3.12 .venv +uv pip install --python .venv/bin/python -r requirements.txt +``` + +## Prove persistent local-browser state + +```bash +STAGEHAND_BROWSER=local .venv/bin/python smoke.py +``` + +The smoke launches the compiled MCP server through LangChain, discovers exactly `code_execute`, +then invokes it twice inside one explicit MCP session. The first call opens `example.com` and writes +a DOM marker; the second call proves that the same page and marker are still present. + +## Run a Deep Agent + +Provider credentials and Stagehand configuration are inherited by the MCP child. For example: + +```bash +export OPENAI_API_KEY= +export STAGEHAND_BROWSER=local +.venv/bin/python agent.py "Open example.com and return its heading and title." +``` + +To use Browserbase instead, set `STAGEHAND_BROWSER=browserbase`, `BROWSERBASE_API_KEY`, and +optionally `BROWSERBASE_PROJECT_ID`. You can select another supported model with +`STAGEHAND_LANGCHAIN_MODEL`; it defaults to `openai:gpt-5-mini` for the Deep Agent. + +The canonical Stagehand V4 syntax guide from `packages/integrations/codemode/SKILL.md` is loaded as +the agent system prompt. This example does not copy the executor, schema, skill, or runtime. diff --git a/packages/integrations/examples/langchain/agent.py b/packages/integrations/examples/langchain/agent.py new file mode 100644 index 0000000000..552fdea5ac --- /dev/null +++ b/packages/integrations/examples/langchain/agent.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import asyncio +import os +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from deepagents import create_deep_agent +from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_mcp_adapters.tools import load_mcp_tools +from mcp import ClientSession + +REPOSITORY_ROOT = Path(__file__).resolve().parents[4] +STDIO_SERVER_PATH = ( + REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" +) +SKILL_PATH = REPOSITORY_ROOT / "packages/integrations/codemode/SKILL.md" +STAGEHAND_CODEMODE_SKILL = SKILL_PATH.read_text(encoding="utf-8").strip() + + +def create_stagehand_mcp_client( + env: Mapping[str, str] | None = None, +) -> MultiServerMCPClient: + """Create a client that forwards Stagehand local/Browserbase configuration.""" + if not STDIO_SERVER_PATH.is_file(): + raise RuntimeError( + "Build the Stagehand integrations package before running this example: " + "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations" + ) + + child_env = dict(os.environ if env is None else env) + return MultiServerMCPClient( + { + "stagehand": { + "transport": "stdio", + "command": "node", + "args": [str(STDIO_SERVER_PATH)], + "env": child_env, + } + }, + tool_name_prefix=False, + handle_tool_errors=True, + ) + + +async def load_stagehand_code_tool(session: ClientSession) -> Any: + """Discover the one canonical tool without creating another MCP session.""" + tools = await load_mcp_tools(session) + tool_names = [tool.name for tool in tools] + if tool_names != ["code_execute"]: + raise RuntimeError(f"Expected exactly code_execute, got {tool_names}") + return tools[0] + + +async def run_stagehand_agent( + prompt: str, + model: str | Any = "openai:gpt-5-mini", +) -> dict[str, Any]: + client = create_stagehand_mcp_client() + + # Keep discovery, agent construction, and the complete invocation inside one + # explicit session. A convenience get_tools() call would create fresh stdio + # sessions and discard Stagehand browser state between tool calls. + async with client.session("stagehand") as session: + code_tool = await load_stagehand_code_tool(session) + agent = create_deep_agent( + model=model, + tools=[code_tool], + system_prompt=STAGEHAND_CODEMODE_SKILL, + ) + return await agent.ainvoke( + {"messages": [{"role": "user", "content": prompt}]}, + config={"recursion_limit": 20}, + ) + + +def _last_message_text(result: dict[str, Any]) -> str: + messages = 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} ""') + prompt = " ".join(sys.argv[1:]) + model = os.environ.get("STAGEHAND_LANGCHAIN_MODEL", "openai:gpt-5-mini") + result = await run_stagehand_agent(prompt, model=model) + print(_last_message_text(result)) + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/packages/integrations/examples/langchain/requirements.txt b/packages/integrations/examples/langchain/requirements.txt new file mode 100644 index 0000000000..cea0bccede --- /dev/null +++ b/packages/integrations/examples/langchain/requirements.txt @@ -0,0 +1,4 @@ +deepagents>=0.7.1,<1 +langchain-mcp-adapters>=0.3.1,<1 +langchain-openai>=1.2.1,<2 +mcp>=1.24.0,<2 diff --git a/packages/integrations/examples/langchain/smoke.py b/packages/integrations/examples/langchain/smoke.py new file mode 100644 index 0000000000..39a25b506e --- /dev/null +++ b/packages/integrations/examples/langchain/smoke.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any + +from agent import create_stagehand_mcp_client, load_stagehand_code_tool + +FIRST_CALL = """ +await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); +await page.evaluate(() => { + document.documentElement.dataset.stagehandLangchainSmoke = "persistent"; +}); +return { + pageId: page.pageId, + title: await page.title(), + marker: await page.evaluate( + () => document.documentElement.dataset.stagehandLangchainSmoke ?? null, + ), +}; +""" + +SECOND_CALL = """ +return { + pageId: page.pageId, + title: await page.title(), + marker: await page.evaluate( + () => document.documentElement.dataset.stagehandLangchainSmoke ?? null, + ), +}; +""" + + +def parse_code_execute_result(raw_result: Any) -> dict[str, Any]: + """Normalize the text result returned by the LangChain MCP adapter.""" + if isinstance(raw_result, str): + parsed = json.loads(raw_result) + elif isinstance(raw_result, dict): + structured = raw_result.get("structuredContent") + parsed = structured if isinstance(structured, dict) else raw_result + elif isinstance(raw_result, list) and len(raw_result) == 1: + block = raw_result[0] + text = ( + block.get("text") + if isinstance(block, dict) + else getattr(block, "text", None) + ) + if not isinstance(text, str): + raise TypeError(f"Unexpected MCP content block: {type(block).__name__}") + parsed = json.loads(text) + else: + raise TypeError(f"Unexpected code_execute result: {type(raw_result).__name__}") + + if not isinstance(parsed, dict): + raise TypeError(f"Expected an object result, got {type(parsed).__name__}") + return parsed + + +async def main() -> None: + child_env = dict(os.environ) + child_env.setdefault("STAGEHAND_BROWSER", "local") + client = create_stagehand_mcp_client(child_env) + + async with client.session("stagehand") as session: + code_tool = await load_stagehand_code_tool(session) + first = parse_code_execute_result(await code_tool.ainvoke({"code": FIRST_CALL})) + second = parse_code_execute_result( + await code_tool.ainvoke({"code": SECOND_CALL}) + ) + + assert first.get("ok") is True, first + assert second.get("ok") is True, second + first_value = first.get("value") + second_value = second.get("value") + assert isinstance(first_value, dict), first + assert isinstance(second_value, dict), second + assert first_value["marker"] == "persistent", first_value + assert second_value["marker"] == "persistent", second_value + assert first_value["pageId"] == second_value["pageId"], (first_value, second_value) + assert first_value["title"] == second_value["title"] == "Example Domain", ( + first_value, + second_value, + ) + + print( + "LangChain persistent Stagehand session PASS: " + f"browser={child_env['STAGEHAND_BROWSER']}, code_execute -> code_execute, " + "same pageId, marker=persistent, title=Example Domain" + ) + + +if __name__ == "__main__": + asyncio.run(main()) From 8d9cc5f2cf8ab274b351bf13cd9c52c32c20b17d Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:10:48 -0700 Subject: [PATCH 2/8] docs: sandbox the Deep Agents code-mode MCP --- .../workflows/codemode-framework-examples.yml | 5 ++ .../integrations/examples/langchain/README.md | 86 ++++++++++++++----- .../integrations/examples/langchain/agent.py | 69 +++++++++++---- .../examples/langchain/requirements.txt | 1 + .../integrations/examples/langchain/smoke.py | 57 +++++++++--- 5 files changed, 166 insertions(+), 52 deletions(-) diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 0c3f52314c..941fc6edce 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -129,6 +129,11 @@ jobs: - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations - run: python -m pip install -r packages/integrations/examples/langchain/requirements.txt + - run: >- + python -m py_compile + packages/integrations/examples/shared/modal_stdio_bridge.py + packages/integrations/examples/langchain/agent.py + packages/integrations/examples/langchain/smoke.py - run: python packages/integrations/examples/langchain/smoke.py env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} diff --git a/packages/integrations/examples/langchain/README.md b/packages/integrations/examples/langchain/README.md index 60bd445add..833709f599 100644 --- a/packages/integrations/examples/langchain/README.md +++ b/packages/integrations/examples/langchain/README.md @@ -1,19 +1,26 @@ -# LangChain Deep Agents + Stagehand code mode +# LangChain Deep Agents + sandboxed Stagehand code mode -This example connects LangChain Deep Agents to the canonical Stagehand code-mode MCP server. The -server runs as a local stdio child process and exposes exactly one tool, `code_execute`. +Use Stagehand code mode from a Deep Agent without running generated JavaScript on the agent host. +LangChain connects to a local stdio child, but that child is a small trusted bridge. The bridge +starts `stdio-server.mjs` as the primary process in a [Modal Sandbox](https://modal.com/docs/guide/sandboxes) +and forwards MCP bytes unchanged: -The explicit `client.session("stagehand")` context in [`agent.py`](./agent.py) is required. Do not -replace it with `client.get_tools()`: that convenience API creates a new session for each tool call, -which would start a new stdio process and lose the browser state created by the previous call. +```text +Deep Agent -> local stdio -> trusted bridge -> Modal Sandbox -> Stagehand MCP + `-> generated JavaScript +``` -## Setup +The agent's model credential remains in the host process. The bridge gives the sandbox only +`STAGEHAND_BROWSER=browserbase`, `BROWSERBASE_API_KEY`, optional `BROWSERBASE_PROJECT_ID`, and the +optional Stagehand-specific `STAGEHAND_MODEL_NAME` and `STAGEHAND_MODEL_API_KEY` pair. Set that pair +only when generated code needs AI-backed Stagehand methods such as `act` or `extract`. -Build the Stagehand-local MCP server from the repository root: +> The proposed `ghcr.io/browserbase/stagehand-codemode` image is not published yet. Until it is, +> maintainers can set `STAGEHAND_CODEMODE_MODAL_IMAGE_ID` to an image built from the same Stagehand +> commit. Once published, pin `STAGEHAND_CODEMODE_IMAGE` to a version or digest instead of a mutable +> `latest` tag. -```bash -pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations -``` +## Setup Create an isolated Python 3.12 environment from this directory: @@ -22,29 +29,62 @@ uv venv --python 3.12 .venv uv pip install --python .venv/bin/python -r requirements.txt ``` -## Prove persistent local-browser state +Configure Modal, Browserbase, and an immutable code-mode image. Configure the provider key for the +outer Deep Agent separately in the host environment: ```bash -STAGEHAND_BROWSER=local .venv/bin/python smoke.py +export MODAL_TOKEN_ID="..." +export MODAL_TOKEN_SECRET="..." +export BROWSERBASE_API_KEY="..." +export STAGEHAND_CODEMODE_IMAGE="ghcr.io/browserbase/stagehand-codemode:" +export OPENAI_API_KEY="..." ``` -The smoke launches the compiled MCP server through LangChain, discovers exactly `code_execute`, -then invokes it twice inside one explicit MCP session. The first call opens `example.com` and writes -a DOM marker; the second call proves that the same page and marker are still present. +`BROWSERBASE_PROJECT_ID` is optional. Modal can also use its normal local profile instead of token +environment variables. `STAGEHAND_LANGCHAIN_MODEL` selects the host model and defaults to +`openai:gpt-5-mini`. ## Run a Deep Agent -Provider credentials and Stagehand configuration are inherited by the MCP child. For example: - ```bash -export OPENAI_API_KEY= -export STAGEHAND_BROWSER=local .venv/bin/python agent.py "Open example.com and return its heading and title." ``` -To use Browserbase instead, set `STAGEHAND_BROWSER=browserbase`, `BROWSERBASE_API_KEY`, and -optionally `BROWSERBASE_PROJECT_ID`. You can select another supported model with -`STAGEHAND_LANGCHAIN_MODEL`; it defaults to `openai:gpt-5-mini` for the Deep Agent. +The explicit `client.session("stagehand")` context and `load_mcp_tools(session)` call in +[`agent.py`](./agent.py) are required. Keep discovery, agent construction, and the complete +`ainvoke` inside that context. The same session then owns the same MCP process, Modal sandbox, and +browser across every `code_execute` call. Leaving the context sends EOF to the MCP, waits briefly +for Stagehand to close the browser, and terminates the sandbox if it is still running. The canonical Stagehand V4 syntax guide from `packages/integrations/codemode/SKILL.md` is loaded as the agent system prompt. This example does not copy the executor, schema, skill, or runtime. + +## Sandbox policy + +The bridge defaults to a 10-minute hard timeout, a 5-minute idle timeout, and outbound access only +to `*.browserbase.com`. Adjust them only when the task requires it: + +```bash +export STAGEHAND_CODEMODE_TIMEOUT_SECONDS=900 +export STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS=300 +export STAGEHAND_CODEMODE_OUTBOUND_DOMAINS="*.browserbase.com,api.example.com" +``` + +Each extra domain is reachable by arbitrary generated JavaScript, so keep the list task-specific. +The Browserbase credential is intentionally present inside the sandbox and should be scoped and +rotated accordingly. The hard timeout is the final cleanup backstop if generated synchronous code +cannot be interrupted cooperatively. + +## Local CI smoke + +The deterministic smoke starts the MCP directly on the CI runner with a local browser. It is useful +for secret-free protocol and persistence coverage, but it is not the recommended boundary for +production or untrusted prompts: + +```bash +pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations +.venv/bin/python smoke.py +``` + +The smoke discovers exactly `code_execute`, invokes it twice inside one explicit MCP session, and +verifies that the second call sees the page and DOM marker created by the first. diff --git a/packages/integrations/examples/langchain/agent.py b/packages/integrations/examples/langchain/agent.py index 552fdea5ac..85ef6f60d3 100644 --- a/packages/integrations/examples/langchain/agent.py +++ b/packages/integrations/examples/langchain/agent.py @@ -4,40 +4,79 @@ import os import sys from collections.abc import Mapping +from functools import lru_cache from pathlib import Path from typing import Any from deepagents import create_deep_agent from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_mcp_adapters.tools import load_mcp_tools + from mcp import ClientSession REPOSITORY_ROOT = Path(__file__).resolve().parents[4] -STDIO_SERVER_PATH = ( - REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" +MODAL_STDIO_BRIDGE_PATH = ( + REPOSITORY_ROOT / "packages/integrations/examples/shared/modal_stdio_bridge.py" ) SKILL_PATH = REPOSITORY_ROOT / "packages/integrations/codemode/SKILL.md" -STAGEHAND_CODEMODE_SKILL = SKILL_PATH.read_text(encoding="utf-8").strip() +DEFAULT_STAGEHAND_MODEL = "openai:gpt-5-mini" + +BRIDGE_ENV_KEYS = ( + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "MODAL_TOKEN_ID", + "MODAL_TOKEN_SECRET", + "MODAL_PROFILE", + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "STAGEHAND_MODEL_NAME", + "STAGEHAND_MODEL_API_KEY", + "STAGEHAND_CODEMODE_IMAGE", + "STAGEHAND_CODEMODE_MODAL_IMAGE_ID", + "STAGEHAND_CODEMODE_MODAL_APP", + "STAGEHAND_CODEMODE_ENTRYPOINT", + "STAGEHAND_CODEMODE_TIMEOUT_SECONDS", + "STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS", + "STAGEHAND_CODEMODE_OUTBOUND_DOMAINS", +) + + +@lru_cache(maxsize=1) +def load_stagehand_codemode_skill() -> str: + return SKILL_PATH.read_text(encoding="utf-8").strip() + + +def modal_bridge_env(overrides: Mapping[str, str] | None = None) -> dict[str, str]: + """Merge proxy overrides without inheriting host model credentials.""" + child_env = { + key: value for key in BRIDGE_ENV_KEYS if (value := os.environ.get(key)) is not None + } + if overrides: + child_env.update(overrides) + return child_env def create_stagehand_mcp_client( env: Mapping[str, str] | None = None, ) -> MultiServerMCPClient: - """Create a client that forwards Stagehand local/Browserbase configuration.""" - if not STDIO_SERVER_PATH.is_file(): - raise RuntimeError( - "Build the Stagehand integrations package before running this example: " - "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations" + """Create a stdio client for the trusted Modal sandbox bridge.""" + if not MODAL_STDIO_BRIDGE_PATH.is_file(): + raise FileNotFoundError( + f"Stagehand Modal stdio bridge not found: {MODAL_STDIO_BRIDGE_PATH}" ) - child_env = dict(os.environ if env is None else env) return MultiServerMCPClient( { "stagehand": { "transport": "stdio", - "command": "node", - "args": [str(STDIO_SERVER_PATH)], - "env": child_env, + "command": sys.executable, + "args": [str(MODAL_STDIO_BRIDGE_PATH)], + "env": modal_bridge_env(env), } }, tool_name_prefix=False, @@ -56,7 +95,7 @@ async def load_stagehand_code_tool(session: ClientSession) -> Any: async def run_stagehand_agent( prompt: str, - model: str | Any = "openai:gpt-5-mini", + model: str | Any = DEFAULT_STAGEHAND_MODEL, ) -> dict[str, Any]: client = create_stagehand_mcp_client() @@ -68,7 +107,7 @@ async def run_stagehand_agent( agent = create_deep_agent( model=model, tools=[code_tool], - system_prompt=STAGEHAND_CODEMODE_SKILL, + system_prompt=load_stagehand_codemode_skill(), ) return await agent.ainvoke( {"messages": [{"role": "user", "content": prompt}]}, @@ -88,7 +127,7 @@ async def _main() -> None: if len(sys.argv) < 2: raise SystemExit(f'Usage: {Path(sys.argv[0]).name} ""') prompt = " ".join(sys.argv[1:]) - model = os.environ.get("STAGEHAND_LANGCHAIN_MODEL", "openai:gpt-5-mini") + model = os.environ.get("STAGEHAND_LANGCHAIN_MODEL", DEFAULT_STAGEHAND_MODEL) result = await run_stagehand_agent(prompt, model=model) print(_last_message_text(result)) diff --git a/packages/integrations/examples/langchain/requirements.txt b/packages/integrations/examples/langchain/requirements.txt index cea0bccede..cd3cebdfc8 100644 --- a/packages/integrations/examples/langchain/requirements.txt +++ b/packages/integrations/examples/langchain/requirements.txt @@ -2,3 +2,4 @@ deepagents>=0.7.1,<1 langchain-mcp-adapters>=0.3.1,<1 langchain-openai>=1.2.1,<2 mcp>=1.24.0,<2 +modal>=1.5.3,<2 diff --git a/packages/integrations/examples/langchain/smoke.py b/packages/integrations/examples/langchain/smoke.py index 39a25b506e..91fc59f2e0 100644 --- a/packages/integrations/examples/langchain/smoke.py +++ b/packages/integrations/examples/langchain/smoke.py @@ -5,7 +5,15 @@ import os from typing import Any -from agent import create_stagehand_mcp_client, load_stagehand_code_tool +from langchain_mcp_adapters.client import MultiServerMCPClient + +from agent import ( + REPOSITORY_ROOT, + load_stagehand_code_tool, + load_stagehand_codemode_skill, +) + +LOCAL_STDIO_SERVER_PATH = REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" FIRST_CALL = """ await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); @@ -41,11 +49,7 @@ def parse_code_execute_result(raw_result: Any) -> dict[str, Any]: parsed = structured if isinstance(structured, dict) else raw_result elif isinstance(raw_result, list) and len(raw_result) == 1: block = raw_result[0] - text = ( - block.get("text") - if isinstance(block, dict) - else getattr(block, "text", None) - ) + text = block.get("text") if isinstance(block, dict) else getattr(block, "text", None) if not isinstance(text, str): raise TypeError(f"Unexpected MCP content block: {type(block).__name__}") parsed = json.loads(text) @@ -57,17 +61,42 @@ def parse_code_execute_result(raw_result: Any) -> dict[str, Any]: return parsed +def local_stagehand_mcp_client_for_ci() -> MultiServerMCPClient: + """Launch the MCP directly only for the credential-free local CI smoke.""" + if not LOCAL_STDIO_SERVER_PATH.is_file(): + raise FileNotFoundError( + "Build @browserbasehq/stagehand-integrations before running the smoke" + ) + child_env = { + "PATH": os.environ.get("PATH", ""), + "STAGEHAND_BROWSER": "local", + } + for name in ("HOME", "TMPDIR", "CHROME_PATH"): + if value := os.environ.get(name): + child_env[name] = value + return MultiServerMCPClient( + { + "stagehand": { + "transport": "stdio", + "command": "node", + "args": [str(LOCAL_STDIO_SERVER_PATH)], + "cwd": str(REPOSITORY_ROOT), + "env": child_env, + } + }, + tool_name_prefix=False, + handle_tool_errors=True, + ) + + async def main() -> None: - child_env = dict(os.environ) - child_env.setdefault("STAGEHAND_BROWSER", "local") - client = create_stagehand_mcp_client(child_env) + assert "stagehand.extract" in load_stagehand_codemode_skill() + client = local_stagehand_mcp_client_for_ci() async with client.session("stagehand") as session: code_tool = await load_stagehand_code_tool(session) first = parse_code_execute_result(await code_tool.ainvoke({"code": FIRST_CALL})) - second = parse_code_execute_result( - await code_tool.ainvoke({"code": SECOND_CALL}) - ) + second = parse_code_execute_result(await code_tool.ainvoke({"code": SECOND_CALL})) assert first.get("ok") is True, first assert second.get("ok") is True, second @@ -84,8 +113,8 @@ async def main() -> None: ) print( - "LangChain persistent Stagehand session PASS: " - f"browser={child_env['STAGEHAND_BROWSER']}, code_execute -> code_execute, " + "LangChain local CI-only persistent Stagehand session PASS: " + "browser=local, code_execute -> code_execute, " "same pageId, marker=persistent, title=Example Domain" ) From 0c89f77c5b46fd6b5f42aae956e8f5c5795e0cc9 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:13:34 -0700 Subject: [PATCH 3/8] fix: preserve CI Chrome launch settings --- packages/integrations/examples/langchain/smoke.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/integrations/examples/langchain/smoke.py b/packages/integrations/examples/langchain/smoke.py index 91fc59f2e0..6ac2ed0300 100644 --- a/packages/integrations/examples/langchain/smoke.py +++ b/packages/integrations/examples/langchain/smoke.py @@ -71,7 +71,8 @@ def local_stagehand_mcp_client_for_ci() -> MultiServerMCPClient: "PATH": os.environ.get("PATH", ""), "STAGEHAND_BROWSER": "local", } - for name in ("HOME", "TMPDIR", "CHROME_PATH"): + # Stagehand's local launcher uses CI to add Chrome's --no-sandbox flag. + for name in ("HOME", "TMPDIR", "CHROME_PATH", "CI"): if value := os.environ.get(name): child_env[name] = value return MultiServerMCPClient( From a16cb8ac398d75d84d36d4fa9a109f7e4bd3e874 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:31:36 -0700 Subject: [PATCH 4/8] fix: filter Deep Agents bridge overrides --- packages/integrations/examples/langchain/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/examples/langchain/agent.py b/packages/integrations/examples/langchain/agent.py index 85ef6f60d3..d45e400420 100644 --- a/packages/integrations/examples/langchain/agent.py +++ b/packages/integrations/examples/langchain/agent.py @@ -57,7 +57,7 @@ def modal_bridge_env(overrides: Mapping[str, str] | None = None) -> dict[str, st key: value for key in BRIDGE_ENV_KEYS if (value := os.environ.get(key)) is not None } if overrides: - child_env.update(overrides) + child_env.update({key: value for key, value in overrides.items() if key in BRIDGE_ENV_KEYS}) return child_env From 0b5bccfc6bd569a38a3aaadbf309b744e9c114ce Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 03:47:13 +0000 Subject: [PATCH 5/8] refactor(langchain): use sandboxed code-mode MCP --- .../workflows/codemode-framework-examples.yml | 31 +++- .../integrations/examples/langchain/README.md | 105 +++++------ .../integrations/examples/langchain/agent.py | 136 +++++++-------- .../integrations/examples/langchain/e2e.py | 163 ++++++++++++++++++ .../examples/langchain/requirements.txt | 9 +- .../examples/langchain/sandbox.py | 21 +++ .../integrations/examples/langchain/smoke.py | 124 ------------- .../examples/langchain/test_agent.py | 60 +++++++ 8 files changed, 375 insertions(+), 274 deletions(-) create mode 100644 packages/integrations/examples/langchain/e2e.py create mode 100644 packages/integrations/examples/langchain/sandbox.py delete mode 100644 packages/integrations/examples/langchain/smoke.py create mode 100644 packages/integrations/examples/langchain/test_agent.py diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 46f7ede64d..c775c1c219 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -153,6 +153,10 @@ jobs: langchain: name: LangChain Deep Agents + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'safe-to-test') runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -162,9 +166,6 @@ jobs: with: use-prebuilt-artifacts: "false" - - uses: ./.github/actions/setup-chrome-verified - id: setup-chrome - - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: @@ -172,14 +173,26 @@ jobs: cache: pip cache-dependency-path: packages/integrations/examples/langchain/requirements.txt - - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations - run: python -m pip install -r packages/integrations/examples/langchain/requirements.txt - run: >- python -m py_compile - packages/integrations/examples/shared/modal_stdio_bridge.py + packages/integrations/examples/shared/vercel_sandbox_lease.py + packages/integrations/examples/shared/test_vercel_sandbox_lease.py packages/integrations/examples/langchain/agent.py - packages/integrations/examples/langchain/smoke.py - - run: python packages/integrations/examples/langchain/smoke.py + packages/integrations/examples/langchain/sandbox.py + packages/integrations/examples/langchain/test_agent.py + packages/integrations/examples/langchain/e2e.py + - run: python packages/integrations/examples/shared/test_vercel_sandbox_lease.py + - run: python packages/integrations/examples/langchain/test_agent.py + - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode + - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts + - run: python packages/integrations/examples/langchain/e2e.py env: - CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} - STAGEHAND_BROWSER: local + STAGEHAND_SANDBOX_ARTIFACTS: ${{ github.workspace }}/packages/integrations/examples/vercel-sandbox/.artifacts + BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} + BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/packages/integrations/examples/langchain/README.md b/packages/integrations/examples/langchain/README.md index 833709f599..c29e7a5233 100644 --- a/packages/integrations/examples/langchain/README.md +++ b/packages/integrations/examples/langchain/README.md @@ -1,90 +1,71 @@ -# LangChain Deep Agents + sandboxed Stagehand code mode +# LangChain Deep Agents with Stagehand code mode -Use Stagehand code mode from a Deep Agent without running generated JavaScript on the agent host. -LangChain connects to a local stdio child, but that child is a small trusted bridge. The bridge -starts `stdio-server.mjs` as the primary process in a [Modal Sandbox](https://modal.com/docs/guide/sandboxes) -and forwards MCP bytes unchanged: +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 -> local stdio -> trusted bridge -> Modal Sandbox -> Stagehand MCP - `-> generated JavaScript +Deep Agent -> persistent LangChain MCP session -> authenticated HTTPS -> Vercel Sandbox + `-> Stagehand MCP + `-> generated JavaScript ``` -The agent's model credential remains in the host process. The bridge gives the sandbox only -`STAGEHAND_BROWSER=browserbase`, `BROWSERBASE_API_KEY`, optional `BROWSERBASE_PROJECT_ID`, and the -optional Stagehand-specific `STAGEHAND_MODEL_NAME` and `STAGEHAND_MODEL_API_KEY` pair. Set that pair -only when generated code needs AI-backed Stagehand methods such as `act` or `extract`. - -> The proposed `ghcr.io/browserbase/stagehand-codemode` image is not published yet. Until it is, -> maintainers can set `STAGEHAND_CODEMODE_MODAL_IMAGE_ID` to an image built from the same Stagehand -> commit. Once published, pin `STAGEHAND_CODEMODE_IMAGE` to a version or digest instead of a mutable -> `latest` tag. +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 an isolated Python 3.12 environment from this directory: +Create a Python 3.12 environment and install the exact tested dependencies: ```bash -uv venv --python 3.12 .venv -uv pip install --python .venv/bin/python -r requirements.txt +python3.12 -m venv .venv +. .venv/bin/activate +python -m pip install -r packages/integrations/examples/langchain/requirements.txt ``` -Configure Modal, Browserbase, and an immutable code-mode image. Configure the provider key for the -outer Deep Agent separately in the host environment: +Build and pack the exact Stagehand packages under review: ```bash -export MODAL_TOKEN_ID="..." -export MODAL_TOKEN_SECRET="..." -export BROWSERBASE_API_KEY="..." -export STAGEHAND_CODEMODE_IMAGE="ghcr.io/browserbase/stagehand-codemode:" -export OPENAI_API_KEY="..." +pnpm install +pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode +pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts ``` -`BROWSERBASE_PROJECT_ID` is optional. Modal can also use its normal local profile instead of token -environment variables. `STAGEHAND_LANGCHAIN_MODEL` selects the host model and defaults to -`openai:gpt-5-mini`. - -## Run a Deep Agent +## Run the end-to-end proof ```bash -.venv/bin/python agent.py "Open example.com and return its heading and title." +STAGEHAND_SANDBOX_ARTIFACTS="$PWD/packages/integrations/examples/vercel-sandbox/.artifacts" \ +BROWSERBASE_API_KEY= \ +BROWSERBASE_PROJECT_ID= \ +VERCEL_OIDC_TOKEN= \ +OPENAI_API_KEY= \ +python packages/integrations/examples/langchain/e2e.py ``` -The explicit `client.session("stagehand")` context and `load_mcp_tools(session)` call in -[`agent.py`](./agent.py) are required. Keep discovery, agent construction, and the complete -`ainvoke` inside that context. The same session then owns the same MCP process, Modal sandbox, and -browser across every `code_execute` call. Leaving the context sends EOF to the MCP, waits briefly -for Stagehand to close the browser, and terminates the sandbox if it is still running. +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 canonical Stagehand V4 syntax guide from `packages/integrations/codemode/SKILL.md` is loaded as -the agent system prompt. This example does not copy the executor, schema, skill, or runtime. +The proof uses one live package-installed sandbox and one explicit LangChain MCP session. It: -## Sandbox policy +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 bridge defaults to a 10-minute hard timeout, a 5-minute idle timeout, and outbound access only -to `*.browserbase.com`. Adjust them only when the task requires it: +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. -```bash -export STAGEHAND_CODEMODE_TIMEOUT_SECONDS=900 -export STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS=300 -export STAGEHAND_CODEMODE_OUTBOUND_DOMAINS="*.browserbase.com,api.example.com" -``` - -Each extra domain is reachable by arbitrary generated JavaScript, so keep the list task-specific. -The Browserbase credential is intentionally present inside the sandbox and should be scoped and -rotated accordingly. The hard timeout is the final cleanup backstop if generated synchronous code -cannot be interrupted cooperatively. - -## Local CI smoke - -The deterministic smoke starts the MCP directly on the CI runner with a local browser. It is useful -for secret-free protocol and persistence coverage, but it is not the recommended boundary for -production or untrusted prompts: +## Run a Deep Agent ```bash -pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations -.venv/bin/python smoke.py +python packages/integrations/examples/langchain/agent.py \ + "Open https://example.com and return its title and URL." ``` -The smoke discovers exactly `code_execute`, invokes it twice inside one explicit MCP session, and -verifies that the second call sees the page and DOM marker created by the first. +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. diff --git a/packages/integrations/examples/langchain/agent.py b/packages/integrations/examples/langchain/agent.py index d45e400420..3f23fd32b3 100644 --- a/packages/integrations/examples/langchain/agent.py +++ b/packages/integrations/examples/langchain/agent.py @@ -3,80 +3,35 @@ import asyncio import os import sys -from collections.abc import Mapping -from functools import lru_cache +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager from pathlib import Path -from typing import Any +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 -REPOSITORY_ROOT = Path(__file__).resolve().parents[4] -MODAL_STDIO_BRIDGE_PATH = ( - REPOSITORY_ROOT / "packages/integrations/examples/shared/modal_stdio_bridge.py" -) -SKILL_PATH = REPOSITORY_ROOT / "packages/integrations/codemode/SKILL.md" DEFAULT_STAGEHAND_MODEL = "openai:gpt-5-mini" -BRIDGE_ENV_KEYS = ( - "PATH", - "HOME", - "TMPDIR", - "LANG", - "LC_ALL", - "SSL_CERT_FILE", - "REQUESTS_CA_BUNDLE", - "MODAL_TOKEN_ID", - "MODAL_TOKEN_SECRET", - "MODAL_PROFILE", - "BROWSERBASE_API_KEY", - "BROWSERBASE_PROJECT_ID", - "STAGEHAND_MODEL_NAME", - "STAGEHAND_MODEL_API_KEY", - "STAGEHAND_CODEMODE_IMAGE", - "STAGEHAND_CODEMODE_MODAL_IMAGE_ID", - "STAGEHAND_CODEMODE_MODAL_APP", - "STAGEHAND_CODEMODE_ENTRYPOINT", - "STAGEHAND_CODEMODE_TIMEOUT_SECONDS", - "STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS", - "STAGEHAND_CODEMODE_OUTBOUND_DOMAINS", -) - - -@lru_cache(maxsize=1) -def load_stagehand_codemode_skill() -> str: - return SKILL_PATH.read_text(encoding="utf-8").strip() - - -def modal_bridge_env(overrides: Mapping[str, str] | None = None) -> dict[str, str]: - """Merge proxy overrides without inheriting host model credentials.""" - child_env = { - key: value for key in BRIDGE_ENV_KEYS if (value := os.environ.get(key)) is not None - } - if overrides: - child_env.update({key: value for key, value in overrides.items() if key in BRIDGE_ENV_KEYS}) - return child_env + +class StagehandSandboxConnection(Protocol): + url: str + token: str def create_stagehand_mcp_client( - env: Mapping[str, str] | None = None, + connection: StagehandSandboxConnection, ) -> MultiServerMCPClient: - """Create a stdio client for the trusted Modal sandbox bridge.""" - if not MODAL_STDIO_BRIDGE_PATH.is_file(): - raise FileNotFoundError( - f"Stagehand Modal stdio bridge not found: {MODAL_STDIO_BRIDGE_PATH}" - ) - + """Create a native authenticated Streamable HTTP client.""" return MultiServerMCPClient( { "stagehand": { - "transport": "stdio", - "command": sys.executable, - "args": [str(MODAL_STDIO_BRIDGE_PATH)], - "env": modal_bridge_env(env), + "transport": "streamable_http", + "url": connection.url, + "headers": {"Authorization": f"Bearer {connection.token}"}, } }, tool_name_prefix=False, @@ -84,39 +39,69 @@ def create_stagehand_mcp_client( ) -async def load_stagehand_code_tool(session: ClientSession) -> Any: +async def load_stagehand_code_tool(session: ClientSession) -> BaseTool: """Discover the one canonical tool without creating another MCP session.""" tools = await load_mcp_tools(session) tool_names = [tool.name for tool in tools] if tool_names != ["code_execute"]: raise RuntimeError(f"Expected exactly code_execute, got {tool_names}") + if "# Stagehand V4 code-mode syntax" not in tools[0].description: + raise RuntimeError("code_execute did not include the canonical guidance") 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.""" + client = create_stagehand_mcp_client(connection) + async with client.session("stagehand") as session: + yield await load_stagehand_code_tool(session) + + +def build_stagehand_agent( + code_tool: BaseTool, + model: str | Any = DEFAULT_STAGEHAND_MODEL, +) -> Any: + if code_tool.name != "code_execute": + raise ValueError("LangChain Stagehand agent requires code_execute") + return create_deep_agent( + model=model, + tools=[code_tool], + system_prompt=code_tool.description, + ) + + +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]: - client = create_stagehand_mcp_client() - - # Keep discovery, agent construction, and the complete invocation inside one - # explicit session. A convenience get_tools() call would create fresh stdio - # sessions and discard Stagehand browser state between tool calls. - async with client.session("stagehand") as session: - code_tool = await load_stagehand_code_tool(session) - agent = create_deep_agent( - model=model, - tools=[code_tool], - system_prompt=load_stagehand_codemode_skill(), - ) - return await agent.ainvoke( + async with stagehand_code_session(connection) as code_tool: + return await build_stagehand_agent(code_tool, model).ainvoke( {"messages": [{"role": "user", "content": prompt}]}, config={"recursion_limit": 20}, ) def _last_message_text(result: dict[str, Any]) -> str: - messages = result.get("messages", []) + messages: Sequence[Any] = result.get("messages", []) if not messages: return str(result) content = getattr(messages[-1], "content", messages[-1]) @@ -126,9 +111,12 @@ def _last_message_text(result: dict[str, Any]) -> str: async def _main() -> None: if len(sys.argv) < 2: raise SystemExit(f'Usage: {Path(sys.argv[0]).name} ""') + from sandbox import StagehandSandboxLease + prompt = " ".join(sys.argv[1:]) model = os.environ.get("STAGEHAND_LANGCHAIN_MODEL", DEFAULT_STAGEHAND_MODEL) - result = await run_stagehand_agent(prompt, model=model) + with StagehandSandboxLease() as connection: + result = await run_stagehand_agent(connection, prompt, model=model) print(_last_message_text(result)) diff --git a/packages/integrations/examples/langchain/e2e.py b/packages/integrations/examples/langchain/e2e.py new file mode 100644 index 0000000000..f7e8287e43 --- /dev/null +++ b/packages/integrations/examples/langchain/e2e.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any +from uuid import uuid4 + +from agent import ( + build_stagehand_agent, + stagehand_code_session, + stagehand_tool_call_count, +) +from sandbox import StagehandSandboxLease + + +def successful_result(raw_result: Any) -> dict[str, Any]: + if isinstance(raw_result, str): + result = json.loads(raw_result) + elif isinstance(raw_result, dict): + structured = raw_result.get("structuredContent") + result = structured if isinstance(structured, dict) else raw_result + elif isinstance(raw_result, list) and len(raw_result) == 1: + block = raw_result[0] + text = ( + block.get("text") + if isinstance(block, dict) + else getattr(block, "text", None) + ) + if not isinstance(text, str): + raise TypeError(f"Unexpected MCP content block: {type(block).__name__}") + result = json.loads(text) + else: + raise TypeError(f"Unexpected code_execute result: {type(raw_result).__name__}") + + assert isinstance(result, dict), result + assert result["ok"] is True, result + value = result.get("value") + assert isinstance(value, dict), result + return value + + +async def main() -> None: + direct_marker = f"langchain-direct-{uuid4()}" + model_marker = f"langchain-model-{uuid4()}" + os.environ["LANGCHAIN_HOST_ONLY_MARKER"] = f"host-{uuid4()}" + final_state: dict[str, Any] + model_tool_calls = 0 + + with StagehandSandboxLease() as connection: + async with stagehand_code_session(connection) as code_tool: + first = successful_result( + await code_tool.ainvoke( + { + "code": f""" +await page.goto("https://example.com", {{ waitUntil: "domcontentloaded" }}); +await page.evaluate((marker) => {{ + document.documentElement.dataset.langchainDirectMarker = marker; +}}, {json.dumps(direct_marker)}); +return {{ + pageId: page.pageId, + title: await page.title(), + directMarker: await page.evaluate( + () => document.documentElement.dataset.langchainDirectMarker, + ), + modelKeyVisible: process.env.OPENAI_API_KEY ?? null, + hostMarkerVisible: process.env.LANGCHAIN_HOST_ONLY_MARKER ?? null, +}}; +""" + } + ) + ) + assert first["title"] == "Example Domain" + assert first["directMarker"] == direct_marker + assert first["modelKeyVisible"] is None + assert first["hostMarkerVisible"] is None + + second = successful_result( + await code_tool.ainvoke( + { + "code": """ +return { + pageId: page.pageId, + title: await page.title(), + directMarker: await page.evaluate( + () => document.documentElement.dataset.langchainDirectMarker, + ), +}; +""" + } + ) + ) + assert second["title"] == "Example Domain" + assert second["pageId"] == first["pageId"] + assert second["directMarker"] == direct_marker + + agent = build_stagehand_agent(code_tool) + agent_result = await agent.ainvoke( + { + "messages": [ + { + "role": "user", + "content": " ".join( + ( + "Use code_execute to modify the already-open page.", + "Set document.documentElement.dataset.langchainModelMarker to", + f"{json.dumps(model_marker)}.", + "Then read that value and the current pageId and report them.", + "You must call code_execute; do not merely describe JavaScript.", + ) + ), + } + ] + }, + config={"recursion_limit": 20}, + ) + model_tool_calls = stagehand_tool_call_count(agent_result) + assert model_tool_calls, ( + "the real Deep Agent model must select code_execute" + ) + + final_state = successful_result( + await code_tool.ainvoke( + { + "code": """ +return { + pageId: page.pageId, + title: await page.title(), + directMarker: await page.evaluate( + () => document.documentElement.dataset.langchainDirectMarker, + ), + modelMarker: await page.evaluate( + () => document.documentElement.dataset.langchainModelMarker, + ), +}; +""" + } + ) + ) + assert final_state["title"] == "Example Domain" + assert final_state["pageId"] == first["pageId"] + assert final_state["directMarker"] == direct_marker + assert final_state["modelMarker"] == model_marker + + print( + json.dumps( + { + "status": "PASS", + "framework": "langchain-deep-agents", + "directToolCalls": 3, + "modelToolCalls": model_tool_calls, + "sessionPersisted": True, + "modelCredentialIsolated": True, + "finalState": final_state, + "cleanup": ["langchain-mcp", "vercel-sandbox"], + }, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/integrations/examples/langchain/requirements.txt b/packages/integrations/examples/langchain/requirements.txt index cd3cebdfc8..62bf74adb0 100644 --- a/packages/integrations/examples/langchain/requirements.txt +++ b/packages/integrations/examples/langchain/requirements.txt @@ -1,5 +1,4 @@ -deepagents>=0.7.1,<1 -langchain-mcp-adapters>=0.3.1,<1 -langchain-openai>=1.2.1,<2 -mcp>=1.24.0,<2 -modal>=1.5.3,<2 +deepagents==0.7.5 +langchain-mcp-adapters==0.3.2 +langchain-openai==1.4.2 +mcp==1.29.0 diff --git a/packages/integrations/examples/langchain/sandbox.py b/packages/integrations/examples/langchain/sandbox.py new file mode 100644 index 0000000000..95811b1b8e --- /dev/null +++ b/packages/integrations/examples/langchain/sandbox.py @@ -0,0 +1,21 @@ +"""Expose the shared Vercel Sandbox lease from the LangChain example directory.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SHARED_EXAMPLES_DIRECTORY = Path(__file__).resolve().parents[1] / "shared" +sys.path.insert(0, str(SHARED_EXAMPLES_DIRECTORY)) + +from vercel_sandbox_lease import ( # noqa: E402 + StagehandSandboxConnection, + StagehandSandboxLease, + StagehandSandboxLeaseError, +) + +__all__ = [ + "StagehandSandboxConnection", + "StagehandSandboxLease", + "StagehandSandboxLeaseError", +] diff --git a/packages/integrations/examples/langchain/smoke.py b/packages/integrations/examples/langchain/smoke.py deleted file mode 100644 index 6ac2ed0300..0000000000 --- a/packages/integrations/examples/langchain/smoke.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import os -from typing import Any - -from langchain_mcp_adapters.client import MultiServerMCPClient - -from agent import ( - REPOSITORY_ROOT, - load_stagehand_code_tool, - load_stagehand_codemode_skill, -) - -LOCAL_STDIO_SERVER_PATH = REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" - -FIRST_CALL = """ -await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); -await page.evaluate(() => { - document.documentElement.dataset.stagehandLangchainSmoke = "persistent"; -}); -return { - pageId: page.pageId, - title: await page.title(), - marker: await page.evaluate( - () => document.documentElement.dataset.stagehandLangchainSmoke ?? null, - ), -}; -""" - -SECOND_CALL = """ -return { - pageId: page.pageId, - title: await page.title(), - marker: await page.evaluate( - () => document.documentElement.dataset.stagehandLangchainSmoke ?? null, - ), -}; -""" - - -def parse_code_execute_result(raw_result: Any) -> dict[str, Any]: - """Normalize the text result returned by the LangChain MCP adapter.""" - if isinstance(raw_result, str): - parsed = json.loads(raw_result) - elif isinstance(raw_result, dict): - structured = raw_result.get("structuredContent") - parsed = structured if isinstance(structured, dict) else raw_result - elif isinstance(raw_result, list) and len(raw_result) == 1: - block = raw_result[0] - text = block.get("text") if isinstance(block, dict) else getattr(block, "text", None) - if not isinstance(text, str): - raise TypeError(f"Unexpected MCP content block: {type(block).__name__}") - parsed = json.loads(text) - else: - raise TypeError(f"Unexpected code_execute result: {type(raw_result).__name__}") - - if not isinstance(parsed, dict): - raise TypeError(f"Expected an object result, got {type(parsed).__name__}") - return parsed - - -def local_stagehand_mcp_client_for_ci() -> MultiServerMCPClient: - """Launch the MCP directly only for the credential-free local CI smoke.""" - if not LOCAL_STDIO_SERVER_PATH.is_file(): - raise FileNotFoundError( - "Build @browserbasehq/stagehand-integrations before running the smoke" - ) - child_env = { - "PATH": os.environ.get("PATH", ""), - "STAGEHAND_BROWSER": "local", - } - # Stagehand's local launcher uses CI to add Chrome's --no-sandbox flag. - for name in ("HOME", "TMPDIR", "CHROME_PATH", "CI"): - if value := os.environ.get(name): - child_env[name] = value - return MultiServerMCPClient( - { - "stagehand": { - "transport": "stdio", - "command": "node", - "args": [str(LOCAL_STDIO_SERVER_PATH)], - "cwd": str(REPOSITORY_ROOT), - "env": child_env, - } - }, - tool_name_prefix=False, - handle_tool_errors=True, - ) - - -async def main() -> None: - assert "stagehand.extract" in load_stagehand_codemode_skill() - client = local_stagehand_mcp_client_for_ci() - - async with client.session("stagehand") as session: - code_tool = await load_stagehand_code_tool(session) - first = parse_code_execute_result(await code_tool.ainvoke({"code": FIRST_CALL})) - second = parse_code_execute_result(await code_tool.ainvoke({"code": SECOND_CALL})) - - assert first.get("ok") is True, first - assert second.get("ok") is True, second - first_value = first.get("value") - second_value = second.get("value") - assert isinstance(first_value, dict), first - assert isinstance(second_value, dict), second - assert first_value["marker"] == "persistent", first_value - assert second_value["marker"] == "persistent", second_value - assert first_value["pageId"] == second_value["pageId"], (first_value, second_value) - assert first_value["title"] == second_value["title"] == "Example Domain", ( - first_value, - second_value, - ) - - print( - "LangChain local CI-only persistent Stagehand session PASS: " - "browser=local, code_execute -> code_execute, " - "same pageId, marker=persistent, title=Example Domain" - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/packages/integrations/examples/langchain/test_agent.py b/packages/integrations/examples/langchain/test_agent.py new file mode 100644 index 0000000000..27ea30031b --- /dev/null +++ b/packages/integrations/examples/langchain/test_agent.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import agent + + +class LangChainStagehandAgentTest(unittest.IsolatedAsyncioTestCase): + def test_client_uses_authenticated_streamable_http(self) -> None: + client = agent.create_stagehand_mcp_client( + SimpleNamespace(url="https://sandbox.example/mcp", token="secret") + ) + + self.assertEqual( + client.connections, + { + "stagehand": { + "transport": "streamable_http", + "url": "https://sandbox.example/mcp", + "headers": {"Authorization": "Bearer secret"}, + } + }, + ) + + async def test_tool_discovery_requires_canonical_code_execute(self) -> None: + tool = SimpleNamespace( + name="code_execute", + description="# Stagehand V4 code-mode syntax\nUse Stagehand.", + ) + with patch.object(agent, "load_mcp_tools", AsyncMock(return_value=[tool])): + self.assertIs(await agent.load_stagehand_code_tool(SimpleNamespace()), tool) + + async def test_tool_discovery_rejects_noncanonical_description(self) -> None: + tool = SimpleNamespace( + name="code_execute", description="Execute arbitrary code." + ) + with ( + patch.object(agent, "load_mcp_tools", AsyncMock(return_value=[tool])), + self.assertRaisesRegex(RuntimeError, "canonical guidance"), + ): + await agent.load_stagehand_code_tool(SimpleNamespace()) + + def test_model_tool_call_counter_ignores_other_tools(self) -> None: + result = { + "messages": [ + SimpleNamespace( + tool_calls=[ + {"name": "write_todos"}, + {"name": "code_execute"}, + ] + ) + ] + } + self.assertEqual(agent.stagehand_tool_call_count(result), 1) + + +if __name__ == "__main__": + unittest.main() From 0d5c95a33fc0b8a3243f3289c6d0c9fd7f9987ff Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:05:41 +0000 Subject: [PATCH 6/8] fix(langchain): gate live proof on credentials --- .../workflows/codemode-framework-examples.yml | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 25e97121a5..547f7d1ac4 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -242,7 +242,26 @@ jobs: - run: python packages/integrations/examples/langchain/test_agent.py - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts - - run: python packages/integrations/examples/langchain/e2e.py + - name: Detect LangChain live test credentials + id: langchain-live-credentials + env: + BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} + BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" && -n "$OPENAI_API_KEY" ]] && \ + [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + - name: Run LangChain live sandbox proof + if: steps.langchain-live-credentials.outputs.available == 'true' + run: python packages/integrations/examples/langchain/e2e.py env: STAGEHAND_SANDBOX_ARTIFACTS: ${{ github.workspace }}/packages/integrations/examples/vercel-sandbox/.artifacts BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} From c29ce4a8596adeab0e53c4ddf4606c2ffe449849 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:23:51 +0000 Subject: [PATCH 7/8] fix(langchain): harden session and agent contracts --- .../workflows/codemode-framework-examples.yml | 13 +- .../integrations/examples/langchain/README.md | 6 +- .../integrations/examples/langchain/agent.py | 115 +++++++++++++++--- .../integrations/examples/langchain/e2e.py | 73 +++++++---- .../examples/langchain/sandbox.py | 2 +- .../examples/langchain/test_agent.py | 106 +++++++++++++++- .../examples/langchain/test_e2e.py | 35 ++++++ 7 files changed, 301 insertions(+), 49 deletions(-) create mode 100644 packages/integrations/examples/langchain/test_e2e.py diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 2f9f957757..c6ca1a1eb4 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -225,13 +225,17 @@ jobs: packages/integrations/examples/langchain/agent.py packages/integrations/examples/langchain/sandbox.py packages/integrations/examples/langchain/test_agent.py + packages/integrations/examples/langchain/test_e2e.py packages/integrations/examples/langchain/e2e.py - run: python packages/integrations/examples/shared/test_vercel_sandbox_lease.py - run: python packages/integrations/examples/langchain/test_agent.py - - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode + - run: python packages/integrations/examples/langchain/test_e2e.py - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts - name: Detect LangChain live test credentials id: langchain-live-credentials + uses: ./.github/actions/detect-codemode-live-credentials + with: + require-openai: "true" env: BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} @@ -240,13 +244,6 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" && -n "$OPENAI_API_KEY" ]] && \ - [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - name: Run LangChain live sandbox proof if: steps.langchain-live-credentials.outputs.available == 'true' run: python packages/integrations/examples/langchain/e2e.py diff --git a/packages/integrations/examples/langchain/README.md b/packages/integrations/examples/langchain/README.md index c29e7a5233..8ad85508fb 100644 --- a/packages/integrations/examples/langchain/README.md +++ b/packages/integrations/examples/langchain/README.md @@ -28,7 +28,6 @@ Build and pack the exact Stagehand packages under review: ```bash pnpm install -pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts ``` @@ -62,6 +61,11 @@ 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= \ +BROWSERBASE_PROJECT_ID= \ +VERCEL_OIDC_TOKEN= \ +OPENAI_API_KEY= \ python packages/integrations/examples/langchain/agent.py \ "Open https://example.com and return its title and URL." ``` diff --git a/packages/integrations/examples/langchain/agent.py b/packages/integrations/examples/langchain/agent.py index 3f23fd32b3..2bb768ee64 100644 --- a/packages/integrations/examples/langchain/agent.py +++ b/packages/integrations/examples/langchain/agent.py @@ -3,6 +3,7 @@ import asyncio import os import sys +from builtins import BaseExceptionGroup from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from pathlib import Path @@ -17,6 +18,26 @@ 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 @@ -41,12 +62,21 @@ def create_stagehand_mcp_client( async def load_stagehand_code_tool(session: ClientSession) -> BaseTool: """Discover the one canonical tool without creating another MCP session.""" - tools = await load_mcp_tools(session) + try: + tools = await load_mcp_tools(session) + except Exception: # noqa: BLE001 -- MCP discovery is an untyped boundary. + raise StagehandLangChainConnectionError( + "Could not discover the Stagehand MCP tool." + ) from None tool_names = [tool.name for tool in tools] if tool_names != ["code_execute"]: - raise RuntimeError(f"Expected exactly code_execute, got {tool_names}") + raise StagehandLangChainToolContractError( + "The Stagehand MCP server returned an invalid tool contract." + ) if "# Stagehand V4 code-mode syntax" not in tools[0].description: - raise RuntimeError("code_execute did not include the canonical guidance") + raise StagehandLangChainToolContractError( + "The Stagehand MCP server returned an invalid tool contract." + ) return tools[0] @@ -55,9 +85,45 @@ async def stagehand_code_session( connection: StagehandSandboxConnection, ) -> AsyncIterator[BaseTool]: """Keep one remote MCP session open for every tool call in an agent run.""" - client = create_stagehand_mcp_client(connection) - async with client.session("stagehand") as session: - yield await load_stagehand_code_tool(session) + 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 + try: + async with client.session("stagehand") as session: + 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 + raise StagehandLangChainConnectionError( + "Could not connect LangChain to the Stagehand MCP server." + ) from None def build_stagehand_agent( @@ -65,12 +131,19 @@ def build_stagehand_agent( model: str | Any = DEFAULT_STAGEHAND_MODEL, ) -> Any: if code_tool.name != "code_execute": - raise ValueError("LangChain Stagehand agent requires code_execute") - return create_deep_agent( - model=model, - tools=[code_tool], - system_prompt=code_tool.description, - ) + 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: @@ -94,10 +167,20 @@ async def run_stagehand_agent( model: str | Any = DEFAULT_STAGEHAND_MODEL, ) -> dict[str, Any]: async with stagehand_code_session(connection) as code_tool: - return await build_stagehand_agent(code_tool, model).ainvoke( - {"messages": [{"role": "user", "content": prompt}]}, - config={"recursion_limit": 20}, - ) + 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: diff --git a/packages/integrations/examples/langchain/e2e.py b/packages/integrations/examples/langchain/e2e.py index f7e8287e43..3f0d04b2bd 100644 --- a/packages/integrations/examples/langchain/e2e.py +++ b/packages/integrations/examples/langchain/e2e.py @@ -14,29 +14,53 @@ from sandbox import StagehandSandboxLease +class StagehandLangChainResultError(RuntimeError): + """code_execute returned a malformed or unsuccessful result.""" + + +class StagehandLangChainIsolationError(RuntimeError): + """A host-only value crossed the Vercel Sandbox boundary.""" + + def successful_result(raw_result: Any) -> dict[str, Any]: - if isinstance(raw_result, str): - result = json.loads(raw_result) - elif isinstance(raw_result, dict): - structured = raw_result.get("structuredContent") - result = structured if isinstance(structured, dict) else raw_result - elif isinstance(raw_result, list) and len(raw_result) == 1: - block = raw_result[0] - text = ( - block.get("text") - if isinstance(block, dict) - else getattr(block, "text", None) + try: + if isinstance(raw_result, str): + result = json.loads(raw_result) + elif isinstance(raw_result, dict): + structured = raw_result.get("structuredContent") + result = structured if isinstance(structured, dict) else raw_result + elif isinstance(raw_result, list) and len(raw_result) == 1: + block = raw_result[0] + text = ( + block.get("text") + if isinstance(block, dict) + else getattr(block, "text", None) + ) + if not isinstance(text, str): + raise StagehandLangChainResultError( + "Stagehand code_execute returned an invalid result." + ) + result = json.loads(text) + else: + raise StagehandLangChainResultError( + "Stagehand code_execute returned an invalid result." + ) + except StagehandLangChainResultError: + raise + except Exception: # noqa: BLE001 -- MCP result objects are untyped. + raise StagehandLangChainResultError( + "Stagehand code_execute returned an invalid result." + ) from None + + if not isinstance(result, dict) or result.get("ok") is not True: + raise StagehandLangChainResultError( + "Stagehand code_execute returned an invalid result." ) - if not isinstance(text, str): - raise TypeError(f"Unexpected MCP content block: {type(block).__name__}") - result = json.loads(text) - else: - raise TypeError(f"Unexpected code_execute result: {type(raw_result).__name__}") - - assert isinstance(result, dict), result - assert result["ok"] is True, result value = result.get("value") - assert isinstance(value, dict), result + if not isinstance(value, dict): + raise StagehandLangChainResultError( + "Stagehand code_execute returned an invalid result." + ) return value @@ -72,8 +96,13 @@ async def main() -> None: ) assert first["title"] == "Example Domain" assert first["directMarker"] == direct_marker - assert first["modelKeyVisible"] is None - assert first["hostMarkerVisible"] is None + if ( + first["modelKeyVisible"] is not None + or first["hostMarkerVisible"] is not None + ): + raise StagehandLangChainIsolationError( + "A host-only value crossed the LangChain sandbox boundary." + ) second = successful_result( await code_tool.ainvoke( diff --git a/packages/integrations/examples/langchain/sandbox.py b/packages/integrations/examples/langchain/sandbox.py index 95811b1b8e..95cfd9d80e 100644 --- a/packages/integrations/examples/langchain/sandbox.py +++ b/packages/integrations/examples/langchain/sandbox.py @@ -8,7 +8,7 @@ SHARED_EXAMPLES_DIRECTORY = Path(__file__).resolve().parents[1] / "shared" sys.path.insert(0, str(SHARED_EXAMPLES_DIRECTORY)) -from vercel_sandbox_lease import ( # noqa: E402 +from vercel_sandbox_lease import ( StagehandSandboxConnection, StagehandSandboxLease, StagehandSandboxLeaseError, diff --git a/packages/integrations/examples/langchain/test_agent.py b/packages/integrations/examples/langchain/test_agent.py index 27ea30031b..e9f90841bf 100644 --- a/packages/integrations/examples/langchain/test_agent.py +++ b/packages/integrations/examples/langchain/test_agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import unittest +from contextlib import asynccontextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -38,10 +39,113 @@ async def test_tool_discovery_rejects_noncanonical_description(self) -> None: ) with ( patch.object(agent, "load_mcp_tools", AsyncMock(return_value=[tool])), - self.assertRaisesRegex(RuntimeError, "canonical guidance"), + self.assertRaises(agent.StagehandLangChainToolContractError), ): await agent.load_stagehand_code_tool(SimpleNamespace()) + async def test_tool_discovery_sanitizes_adapter_failure(self) -> None: + secret = "https://sandbox.example.test/mcp?token=do-not-reflect" + with ( + patch.object( + agent, + "load_mcp_tools", + AsyncMock(side_effect=RuntimeError(secret)), + ), + self.assertRaises(agent.StagehandLangChainConnectionError) as raised, + ): + await agent.load_stagehand_code_tool(SimpleNamespace()) + + self.assertNotIn(secret, str(raised.exception)) + + async def test_session_scopes_loaded_tool_to_one_client_session(self) -> None: + connection = SimpleNamespace( + url="https://sandbox.example.test/mcp", token="secret" + ) + session = SimpleNamespace() + code_tool = SimpleNamespace( + name="code_execute", + description="# Stagehand V4 code-mode syntax", + ) + session_names: list[str] = [] + + @asynccontextmanager + async def session_context(name: str): + session_names.append(name) + yield session + + client = SimpleNamespace(session=session_context) + load_tool = AsyncMock(return_value=code_tool) + with ( + patch.object(agent, "create_stagehand_mcp_client", return_value=client), + patch.object(agent, "load_stagehand_code_tool", load_tool), + ): + async with agent.stagehand_code_session(connection) as loaded: + self.assertIs(loaded, code_tool) + + self.assertEqual(session_names, ["stagehand"]) + load_tool.assert_awaited_once_with(session) + + def test_build_agent_uses_canonical_tool_description_as_system_prompt(self) -> None: + code_tool = SimpleNamespace( + name="code_execute", + description="# Stagehand V4 code-mode syntax\nCanonical instructions.", + ) + built_agent = SimpleNamespace() + with patch.object( + agent, "create_deep_agent", return_value=built_agent + ) as create_agent: + result = agent.build_stagehand_agent(code_tool, model="test-model") + + self.assertIs(result, built_agent) + create_agent.assert_called_once_with( + model="test-model", + tools=[code_tool], + system_prompt=code_tool.description, + ) + + def test_build_agent_rejects_non_code_execute_tool_without_reflection(self) -> None: + secret = "unexpected-secret-tool" + code_tool = SimpleNamespace(name=secret, description="untrusted") + + with self.assertRaises(agent.StagehandLangChainToolContractError) as raised: + agent.build_stagehand_agent(code_tool) + + self.assertNotIn(secret, str(raised.exception)) + + async def test_run_agent_uses_one_session_tool_and_bounded_recursion(self) -> None: + connection = SimpleNamespace( + url="https://sandbox.example.test/mcp", token="secret" + ) + code_tool = SimpleNamespace(name="code_execute", description="canonical") + expected = {"messages": []} + deep_agent = SimpleNamespace(ainvoke=AsyncMock(return_value=expected)) + + @asynccontextmanager + async def code_session(actual_connection): + self.assertIs(actual_connection, connection) + yield code_tool + + with ( + patch.object(agent, "stagehand_code_session", code_session), + patch.object( + agent, "build_stagehand_agent", return_value=deep_agent + ) as build_agent, + ): + result = await agent.run_stagehand_agent( + connection, "Open example.com", model="test-model" + ) + + self.assertIs(result, expected) + build_agent.assert_called_once_with(code_tool, "test-model") + deep_agent.ainvoke.assert_awaited_once_with( + { + "messages": [ + {"role": "user", "content": "Open example.com"}, + ] + }, + config={"recursion_limit": 20}, + ) + def test_model_tool_call_counter_ignores_other_tools(self) -> None: result = { "messages": [ diff --git a/packages/integrations/examples/langchain/test_e2e.py b/packages/integrations/examples/langchain/test_e2e.py new file mode 100644 index 0000000000..2cfc84b848 --- /dev/null +++ b/packages/integrations/examples/langchain/test_e2e.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import unittest + +from e2e import StagehandLangChainResultError, successful_result + + +class SuccessfulResultTest(unittest.TestCase): + def test_accepts_successful_structured_result(self) -> None: + self.assertEqual( + successful_result( + {"structuredContent": {"ok": True, "value": {"title": "Example"}}} + ), + {"title": "Example"}, + ) + + def test_rejects_invalid_json_without_reflecting_payload(self) -> None: + secret = "invalid-json-secret-do-not-reflect" + with self.assertRaises(StagehandLangChainResultError) as raised: + successful_result(secret) + self.assertNotIn(secret, str(raised.exception)) + + def test_rejects_failed_result_without_reflecting_payload(self) -> None: + secret = "failure-secret-do-not-reflect" + with self.assertRaises(StagehandLangChainResultError) as raised: + successful_result({"ok": False, "error": secret}) + self.assertNotIn(secret, str(raised.exception)) + + def test_rejects_non_object_value(self) -> None: + with self.assertRaises(StagehandLangChainResultError): + successful_result({"ok": True, "value": ["unexpected"]}) + + +if __name__ == "__main__": + unittest.main() From a05413b9c24bac3665e697d1454e7166945d6fec Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:37:27 +0000 Subject: [PATCH 8/8] fix(langchain): classify session cleanup failures --- .../integrations/examples/langchain/agent.py | 6 +++ .../examples/langchain/test_agent.py | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/integrations/examples/langchain/agent.py b/packages/integrations/examples/langchain/agent.py index 2bb768ee64..cc09d60e10 100644 --- a/packages/integrations/examples/langchain/agent.py +++ b/packages/integrations/examples/langchain/agent.py @@ -93,8 +93,10 @@ async def stagehand_code_session( ) 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 @@ -121,6 +123,10 @@ async def stagehand_code_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 diff --git a/packages/integrations/examples/langchain/test_agent.py b/packages/integrations/examples/langchain/test_agent.py index e9f90841bf..442a52cfa2 100644 --- a/packages/integrations/examples/langchain/test_agent.py +++ b/packages/integrations/examples/langchain/test_agent.py @@ -85,6 +85,43 @@ async def session_context(name: str): self.assertEqual(session_names, ["stagehand"]) load_tool.assert_awaited_once_with(session) + async def test_session_reports_cleanup_only_failure_without_reflection(self) -> None: + secret = "https://sandbox.example.test/mcp?token=do-not-reflect" + connection = SimpleNamespace( + url="https://sandbox.example.test/mcp", token="secret" + ) + session = SimpleNamespace() + code_tool = SimpleNamespace( + name="code_execute", + description="# Stagehand V4 code-mode syntax", + ) + + class CleanupFailingSession: + async def __aenter__(self): + return session + + async def __aexit__(self, *_args): + raise RuntimeError(secret) + + client = SimpleNamespace(session=lambda _name: CleanupFailingSession()) + with ( + patch.object(agent, "create_stagehand_mcp_client", return_value=client), + patch.object( + agent, + "load_stagehand_code_tool", + AsyncMock(return_value=code_tool), + ), + self.assertRaises(agent.StagehandLangChainCleanupError) as raised, + ): + async with agent.stagehand_code_session(connection) as loaded: + self.assertIs(loaded, code_tool) + + self.assertEqual( + str(raised.exception), + "Could not close the LangChain Stagehand MCP session.", + ) + self.assertNotIn(secret, str(raised.exception)) + def test_build_agent_uses_canonical_tool_description_as_system_prompt(self) -> None: code_tool = SimpleNamespace( name="code_execute",