This commit is contained in:
Christian Bromann
2026-04-03 23:16:22 -07:00
parent a21969543c
commit 5973fea296
17 changed files with 4541 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
OPENAI_API_KEY=sk-...
# Optional override (default: openai:gpt-4o-mini)
# OPENAI_MODEL=openai:gpt-4o-mini

View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.egg-info/
.langgraph_api/
frontend/node_modules/
frontend/dist/

View File

@@ -0,0 +1,87 @@
# Headless tool + LangGraph dev server (E2E)
This example runs a **local LangGraph API** (`langgraph dev`) with a graph built via
[`create_agent`](https://docs.langchain.com/oss/python/langchain/agents) and a **headless
tool** from the branch (`langchain.tools.tool` with `name`, `description`, and
`args_schema` only). The tool calls LangGraph `interrupt()` so you can handle execution in
a browser or other client and **resume** with the result.
## Prerequisites
- [uv](https://docs.astral.sh/uv/)
- An `OPENAI_API_KEY` in `.env` (the demo model defaults to `openai:gpt-4o-mini`)
## 1. Configure environment
```bash
cd examples/headless-langgraph-e2e
cp .env.example .env
# Edit .env and set OPENAI_API_KEY
```
## 2. Install dependencies
```bash
uv sync --group dev
```
## 3. Start the LangGraph dev server
Default API URL is `http://127.0.0.1:2024` (CORS allows `*` by default).
```bash
uv run langgraph dev --config langgraph.json --no-browser
```
The dev server listens on **port 2024** by default (see the banner for the exact API URL).
### Port 2024 already in use
Another process (often a previous `langgraph dev`) is bound to 2024. Either stop it or use a free port:
```bash
# See what is using 2024 (macOS / Linux)
lsof -i :2024
```
Then quit that process, or start on another port:
```bash
uv run langgraph dev --config langgraph.json --no-browser --port 2025
```
Set the **LangGraph API base URL** in the frontend to match (for example `http://127.0.0.1:2025`).
## 4. Run the React frontend (Vite + `useStream`)
The UI mirrors the headless-tools pattern from `ui-playground` (`tool({ name, description, schema })` +
`.implement(...)` passed to `useStream({ tools: [...] })`).
In another shell:
```bash
cd examples/headless-langgraph-e2e/frontend
npm install
npm run dev
```
Open `http://127.0.0.1:8765`. The **LangGraph API base URL** field defaults to
`http://127.0.0.1:2024`; change it if you used `--port` with a different value.
Send a message that asks to open a URL or for your location; the model should call the
matching headless tool, the client runs the implementation in `src/tools.ts`, and streaming
continues after the tool result is applied.
## Notes
- **Checkpointer:** For a plain Python script you normally pass `checkpointer=InMemorySaver()`
to `create_agent` so `interrupt()` / headless tools persist. The **LangGraph dev server**
injects its own persistence and **rejects** a custom checkpointer on the graph, so this
example omits it.
- **Studio:** The server banner prints a link to open **LangSmith Studio** against your
local API (useful for debugging the same graph).
## Graph id
The graph is registered as **`agent`** in `langgraph.json` (this is the assistant / graph
id passed to the SDK).

View File

@@ -0,0 +1,65 @@
"""LangGraph dev graph: `create_agent` with a headless (interrupting) tool.
Headless tools (`browser_navigate`, `geolocation_get`) have no local implementation;
they call LangGraph `interrupt()` so the client can execute them and resume with a result.
Run the API: ``uv run langgraph dev`` from this directory (see README).
"""
from __future__ import annotations
import os
from langchain.agents import create_agent
from langchain.tools import tool
from pydantic import BaseModel, Field
# Model id for init_chat_model (requires OPENAI_API_KEY in .env for OpenAI models).
_model = os.environ.get("OPENAI_MODEL", "openai:gpt-4o-mini")
class BrowserNavigateArgs(BaseModel):
"""Arguments for the headless browser navigation tool."""
url: str = Field(..., description="URL to open in the headless browser.")
class GeolocationGetArgs(BaseModel):
"""Arguments for the headless geolocation tool."""
high_accuracy: bool | None = Field(
default=None,
description="If true, request high-accuracy GPS from the browser when supported.",
)
browser_navigate = tool(
name="browser_navigate",
description=(
"Navigate a headless browser to a URL. Execution is delegated to the client: "
"the graph pauses until the client resumes with the page result."
),
args_schema=BrowserNavigateArgs,
)
geolocation_get = tool(
name="geolocation_get",
description=(
"Get the user's current GPS coordinates using the browser Geolocation API. "
"The client shows their position on a map (OpenStreetMap). "
"The browser may prompt for permission the first time."
),
args_schema=GeolocationGetArgs,
)
graph = create_agent(
_model,
tools=[browser_navigate, geolocation_get],
system_prompt=(
"You are a helpful assistant. When the user asks to open, visit, or browse "
"a URL, call browser_navigate with that URL. When they ask where they are, "
"their location, or to show them on a map, call geolocation_get. "
"After the client returns out-of-process results, reply briefly using that information."
),
# No custom checkpointer: LangGraph API (`langgraph dev`) supplies persistence.
)

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headless tool demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "headless-langgraph-e2e-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 8765",
"build": "vite build",
"preview": "vite preview --port 8765"
},
"dependencies": {
"@langchain/react": "^0.3.2",
"langchain": "^1.3.0",
"react": "^19",
"react-dom": "^19",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6",
"typescript": "^5.8",
"vite": "^8"
}
}

View File

@@ -0,0 +1,243 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { AIMessage, HumanMessage } from "langchain";
import {
useStream,
type DefaultToolCall,
type ToolCallWithResult,
} from "@langchain/react";
import { LocationMap } from "./LocationMap";
import { browserNavigateClient, geolocationGetClient } from "./tools";
const ASSISTANT_ID = "agent";
const PRESETS = [
"Please open https://example.com in the browser.",
"Visit https://langchain.com and tell me the page title if you can.",
"Where am I right now? Show me on a map.",
];
function parseResultContent(result: ToolCallWithResult<DefaultToolCall>["result"]): unknown {
if (!result) return undefined;
const c = result.content;
const raw = typeof c === "string" ? c : JSON.stringify(c);
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
function HeadlessToolCallCard({
toolCall,
}: {
toolCall: ToolCallWithResult<DefaultToolCall>;
}) {
const { call, result, state } = toolCall;
const pending = state === "pending";
const parsed = parseResultContent(result);
const title =
call.name === "geolocation_get"
? "geolocation_get"
: call.name === "browser_navigate"
? "browser_navigate"
: call.name;
const icon =
call.name === "geolocation_get" ? "📍" : call.name === "browser_navigate" ? "🌐" : "🔧";
let map: ReactNode = null;
if (
call.name === "geolocation_get" &&
parsed &&
typeof parsed === "object" &&
parsed !== null &&
"success" in parsed &&
(parsed as { success?: boolean }).success === true &&
"latitude" in parsed &&
"longitude" in parsed
) {
const p = parsed as { latitude: number; longitude: number; accuracy?: number };
map = <LocationMap latitude={p.latitude} longitude={p.longitude} accuracy={p.accuracy} />;
}
let resultText = "";
if (result) {
const c = result.content;
resultText = typeof c === "string" ? c : JSON.stringify(c);
}
return (
<div className="tool-card">
<header>
<span aria-hidden="true">{icon}</span>
<span>{pending ? `Running ${call.name}` : title}</span>
</header>
<pre>{JSON.stringify(call.args, null, 2)}</pre>
{map}
{resultText ? (
<pre>
{map ? "---\n" : ""}
{resultText}
</pre>
) : null}
</div>
);
}
export function App() {
const [apiUrl, setApiUrl] = useState("http://127.0.0.1:2024");
const tools = useMemo(() => [browserNavigateClient, geolocationGetClient], []);
const stream = useStream({
apiUrl: apiUrl.replace(/\/$/, ""),
assistantId: ASSISTANT_ID,
tools,
});
const handleSubmit = useCallback(
(text: string) => {
void stream.submit({
messages: [{ type: "human" as const, content: text }],
});
},
[stream],
);
return (
<>
<h1>Headless tool (useStream)</h1>
<p className="hint">
Start the graph API from the parent directory:{" "}
<code>uv run langgraph dev --config langgraph.json --no-browser</code>. Tool name{" "}
<code>browser_navigate</code> and <code>geolocation_get</code> match <code>agent.py</code>
; the client runs implementations passed to <code>useStream</code> (see{" "}
<code>ui-playground/.../headless-tools</code>). Geolocation uses OpenStreetMap embeds.
</p>
<div className="row">
<div style={{ flex: 1, minWidth: "12rem" }}>
<label htmlFor="apiUrl">LangGraph API base URL</label>
<input
id="apiUrl"
type="url"
value={apiUrl}
onChange={(e) => {
setApiUrl(e.target.value);
}}
autoComplete="off"
/>
</div>
{stream.messages.length > 0 ? (
<button
type="button"
className="secondary"
onClick={() => {
stream.switchThread(null);
}}
>
New thread
</button>
) : null}
</div>
<label htmlFor="msg">Message</label>
<textarea
id="msg"
rows={3}
defaultValue={PRESETS[0]}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit((e.target as HTMLTextAreaElement).value);
}
}}
/>
<button
type="button"
disabled={stream.isLoading}
onClick={() => {
const ta = document.getElementById("msg") as HTMLTextAreaElement | null;
handleSubmit(ta?.value ?? "");
}}
>
{stream.isLoading ? "Running…" : "Send"}
</button>
{stream.messages.length === 0 ? (
<div style={{ marginTop: "1rem" }}>
<span className="hint">Try a preset:</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" }}>
{PRESETS.map((p) => (
<button
key={p}
type="button"
className="secondary"
style={{ width: "auto" }}
onClick={() => {
handleSubmit(p);
}}
>
{p.slice(0, 42)}
{p.length > 42 ? "…" : ""}
</button>
))}
</div>
</div>
) : null}
<div style={{ marginTop: "1.25rem" }}>
{stream.messages.map((msg, idx) => {
if (HumanMessage.isInstance(msg)) {
return (
<div key={msg.id ?? idx} className="bubble-user">
{msg.text}
</div>
);
}
if (AIMessage.isInstance(msg)) {
const msgToolCalls = stream.toolCalls.filter((tc) =>
msg.tool_calls?.some((t) => t.id === tc.call.id),
);
if (msgToolCalls.length > 0) {
return (
<div key={msg.id ?? idx}>
{msgToolCalls.map((tc) => (
<HeadlessToolCallCard
key={tc.id}
toolCall={tc as ToolCallWithResult<DefaultToolCall>}
/>
))}
</div>
);
}
if (!msg.text) return null;
return (
<div key={msg.id ?? idx} className="bubble-ai">
{msg.text}
</div>
);
}
return null;
})}
{stream.isLoading &&
!stream.messages.some((m) => AIMessage.isInstance(m) && m.text) &&
stream.toolCalls.length === 0 && <div className="typing">Thinking</div>}
{stream.error ? (
<div className="error" role="alert">
{stream.error instanceof Error ? stream.error.message : String(stream.error)}
</div>
) : null}
</div>
</>
);
}

View File

@@ -0,0 +1,40 @@
/**
* OpenStreetMap embed (same approach as ui-playground headless-tools/components.tsx).
*/
type LocationMapProps = {
latitude: number;
longitude: number;
accuracy?: number;
};
export function LocationMap({ latitude, longitude, accuracy }: LocationMapProps) {
const delta = 0.005;
const bbox = `${longitude - delta},${latitude - delta},${longitude + delta},${latitude + delta}`;
const src = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${latitude},${longitude}`;
const externalHref = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<div className="location-map">
<div className="location-map-frame">
<iframe
src={src}
title="Your location on OpenStreetMap"
loading="lazy"
referrerPolicy="no-referrer"
/>
</div>
<div className="location-map-meta">
<span className="location-map-coords">
{latitude.toFixed(5)}, {longitude.toFixed(5)}
{accuracy !== undefined ? (
<span className="location-map-acc"> ±{Math.round(accuracy)} m</span>
) : null}
</span>
<a href={externalHref} target="_blank" rel="noopener noreferrer" className="location-map-link">
Open in OSM
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
:root {
font-family: system-ui, sans-serif;
line-height: 1.45;
color: #e8e8ef;
background: #12141a;
}
body {
margin: 0;
}
#root {
max-width: 52rem;
margin: 0 auto;
padding: 2rem 1rem;
}
h1 {
font-weight: 600;
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
.hint {
font-size: 0.85rem;
color: #8b92a6;
margin: 0 0 1rem;
}
label {
display: block;
margin-top: 1rem;
font-size: 0.85rem;
color: #a8adbc;
}
input,
textarea,
button {
width: 100%;
box-sizing: border-box;
margin-top: 0.35rem;
padding: 0.5rem 0.65rem;
border-radius: 6px;
border: 1px solid #2c3140;
background: #1a1d26;
color: #e8e8ef;
font: inherit;
}
button {
cursor: pointer;
background: #3b5bdb;
border-color: #3b5bdb;
font-weight: 500;
margin-top: 0.75rem;
}
button.secondary {
background: #2c3140;
border-color: #3d4455;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: flex-end;
}
.row input {
flex: 1;
min-width: 12rem;
}
.error {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid rgba(220, 38, 38, 0.45);
background: rgba(127, 29, 29, 0.25);
color: #fecaca;
font-size: 0.875rem;
}
.tool-card {
border-radius: 8px;
border: 1px solid #2c3140;
background: #1a1d26;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
}
.tool-card header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
}
.tool-card pre {
margin: 0.5rem 0 0;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
color: #c8ccd8;
}
.bubble-user {
background: #2c3140;
border-radius: 8px;
padding: 0.65rem 0.85rem;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.bubble-ai {
background: #1e2433;
border: 1px solid #2c3140;
border-radius: 8px;
padding: 0.65rem 0.85rem;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.typing {
opacity: 0.7;
font-size: 0.85rem;
margin: 0.5rem 0;
}
.location-map {
margin-top: 0.5rem;
}
.location-map-frame {
overflow: hidden;
border-radius: 8px;
border: 1px solid #2c3140;
height: 200px;
}
.location-map-frame iframe {
width: 100%;
height: 100%;
border: 0;
}
.location-map-meta {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: #a8adbc;
}
.location-map-coords {
font-family: ui-monospace, monospace;
}
.location-map-acc {
opacity: 0.75;
}
.location-map-link {
color: #7c9cff;
text-decoration: none;
}
.location-map-link:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./index.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("Missing #root");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,97 @@
/**
* Headless tool definitions + client implementations.
*
* Mirrors `agent.py`: schema-only headless tools; execution runs here via
* `useStream({ tools: [...] })`.
*
* Pattern: https://github.com/langchain-ai/langchainjs (headless `tool` + `.implement()`)
*/
import { tool } from "langchain";
import { z } from "zod";
/** Must match `browser_navigate` in `agent.py`. */
export const browserNavigate = tool({
name: "browser_navigate",
description:
"Navigate a headless browser to a URL. The real browser runs out-of-process; " +
"the graph pauses until the client resumes with a result.",
schema: z.object({
url: z.string().describe("URL to open in the headless browser."),
}),
});
/**
* Client-side implementation: try `fetch` for same-origin or CORS-friendly URLs;
* otherwise return a simulated result (many sites block browser cross-origin fetch).
*/
/** Must match `geolocation_get` in `agent.py`. */
export const geolocationGet = tool({
name: "geolocation_get",
description:
"Get the user's current GPS coordinates using the browser's Geolocation API. " +
"The client can show the position on OpenStreetMap.",
schema: z.object({
high_accuracy: z
.boolean()
.optional()
.describe("Request high-accuracy GPS when supported."),
}),
});
export const geolocationGetClient = geolocationGet.implement(async ({ high_accuracy }) => {
if (!navigator.geolocation) {
return JSON.stringify({
success: false,
message: "Geolocation is not supported by this browser.",
});
}
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: high_accuracy ?? true,
timeout: 10_000,
maximumAge: 5 * 60 * 1000,
});
});
const { latitude, longitude, accuracy } = position.coords;
const timestamp = new Date(position.timestamp).toISOString();
return JSON.stringify({
success: true,
latitude,
longitude,
accuracy,
timestamp,
message: `Location: ${latitude.toFixed(5)}, ${longitude.toFixed(5)}${Math.round(accuracy)} m)`,
});
});
export const browserNavigateClient = browserNavigate.implement(async ({ url }) => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return JSON.stringify({ ok: false, error: "Invalid URL", url });
}
try {
const res = await fetch(parsed.href, { mode: "cors", credentials: "omit" });
const text = await res.text();
const titleMatch = text.match(/<title>([^<]*)<\/title>/i);
return JSON.stringify({
ok: true,
url: parsed.href,
status: res.status,
title: titleMatch?.[1]?.trim() ?? null,
});
} catch {
return JSON.stringify({
ok: true,
url: parsed.href,
simulated: true,
note:
"Could not fetch page (often cross-origin). Simulated successful navigation for E2E.",
});
}
});

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,9 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
port: 8765,
},
});

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://langgra.ph/schema.json",
"dependencies": ["."],
"graphs": {
"agent": "./agent.py:graph"
},
"env": ".env"
}

View File

@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "headless-langgraph-e2e"
version = "0.1.0"
description = "Local LangGraph dev server + headless tool demo (create_agent)."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain",
"langchain-openai>=0.3.0",
"python-dotenv>=1.0.0",
]
[tool.uv.sources]
langchain = { path = "../../libs/langchain_v1", editable = true }
[dependency-groups]
dev = ["langgraph-cli[inmem]>=0.4.14"]
[tool.setuptools]
py-modules = ["agent"]

2241
examples/headless-langgraph-e2e/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff