-
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
Open
shrey150
wants to merge
23
commits into
shrey/stg-2765-codemode-crewai
Choose a base branch
from
shrey/stg-2765-codemode-langchain
base: shrey/stg-2765-codemode-crewai
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
c2ed35d
feat(langchain): add Stagehand code-mode MCP example
shrey150 d57bce1
Merge updated CrewAI integration parent
shrey150 9c819cd
Merge updated CrewAI integration parent
shrey150 c9fa671
Merge updated CrewAI integration parent
shrey150 ced3e2c
Merge refreshed CrewAI integration parent
shrey150 3f6107f
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-crewai' …
shrey150 8d9cc5f
docs: sandbox the Deep Agents code-mode MCP
shrey150 2e20aa4
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-crewai' …
shrey150 0c89f77
fix: preserve CI Chrome launch settings
shrey150 da13c9b
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-crewai' …
shrey150 a16cb8a
fix: filter Deep Agents bridge overrides
shrey150 2a6d8cc
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-crewai' …
shrey150 0b5bccf
refactor(langchain): use sandboxed code-mode MCP
shrey150 095b04b
Merge latest CrewAI parent into LangChain
shrey150 0d5c95a
fix(langchain): gate live proof on credentials
shrey150 e6cff47
Merge latest CrewAI review fixes into LangChain
shrey150 c29ce4a
fix(langchain): harden session and agent contracts
shrey150 2fe3165
Merge latest CrewAI review fixes into LangChain
shrey150 fddca9e
Merge latest CrewAI contract tests into LangChain
shrey150 a05413b
fix(langchain): classify session cleanup failures
shrey150 dde6fd4
Merge latest CrewAI lifecycle fixes into LangChain
shrey150 1dbce0b
Merge deterministic CrewAI stack test into LangChain
shrey150 c91a195
Merge latest CrewAI review fixes into LangChain
shrey150 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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=<your-provider-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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| 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} "<browser task>"') | ||
| 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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.