Skip to content

Commit 574b4cd

Browse files
andreolfclaudedavidmckayv
authored
Add a Pydantic AI example Bot (#56)
* Add a Pydantic AI example Bot A third framework example beside LangGraph and Mastra, and the first in another language. A real Pydantic AI agent served over AG-UI: the surface's tools arrive per run and their calls stream back to OpenBot to run through the gateway, so the process drives a governed browser it has no direct access to, the same contract as the Bot in the box. Self-contained (its own pyproject.toml, outside the Bun workspaces), imports the AG-UI helper from whichever module path the installed pydantic-ai exposes, and answers /health like the other examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use the AG-UI adapter that pydantic-ai actually ships The first version imported handle_ag_ui_request from pydantic_ai.ui.ag_ui with a fallback to pydantic_ai.ag_ui. Neither exists in current pydantic-ai: the module exposes AGUIAdapter, and there is no top-level ag_ui module, so the Bot failed at import before serving a single request. Serve with AGUIAdapter.dispatch_request(request, agent=agent) instead, verified against pydantic-ai 2.33.0: the server boots, /health answers, and a RunAgentInput POST with a granted tool streams RUN_STARTED then the model call (frontend tools accepted, AG-UI events emitted as SSE). Pin the dependency to the tested release and correct the README note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Give the Pydantic example a key, a free port, and the shipped model default The endpoint read no header, which is the one thing an example must not teach now that the server sends a Bot's key on every run: it takes a run from anyone who can reach the port. Guarded by REQUIRE_KEY, the same optional guard the LangGraph example carries. 4202 sits in the band the in-box agents use, 4200 and 4201. The examples step by a hundred, 4300 and 4400, and 4500 is the supervisor, so 4600. gpt-4.1 is not what this deployment ships; gpt-5.5 is the default everywhere else. Also record that Pydantic AI calls /responses rather than /v1/chat/completions, which decides whether a given gateway can serve this at all, and ignore the egg-info that the README's own install step leaves behind. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent a6fe829 commit 574b4cd

4 files changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
__pycache__/
2+
*.pyc
3+
.venv/
4+
*.egg-info/

examples/pydantic-ai-bot/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Pydantic AI Bot
2+
3+
A Bot written in [Pydantic AI](https://ai.pydantic.dev), served over AG-UI. It sits beside the
4+
[LangGraph](../langgraph-bot) and [Mastra](../mastra-bot) examples and proves the same point in a
5+
third language: OpenBot knows a Bot only as an AG-UI endpoint URL, so a Python agent arrives exactly
6+
the way a TypeScript one does.
7+
8+
The browser and file tools arrive in each run's `tools` from the surface. Pydantic AI exposes them
9+
to the model as external tools whose calls stream back to OpenBot to run through the governed gateway
10+
— so this process drives a real browser it has no direct access to, and the tool loop stays on the
11+
client, the same as the Bot in the box.
12+
13+
## Run it
14+
15+
Requires Python 3.10+. With [uv](https://docs.astral.sh/uv):
16+
17+
```sh
18+
cd examples/pydantic-ai-bot
19+
uv run --env-file ../../.env src/app.py
20+
```
21+
22+
Or with a plain virtualenv:
23+
24+
```sh
25+
cd examples/pydantic-ai-bot
26+
python -m venv .venv && . .venv/bin/activate
27+
pip install -e .
28+
OPENAI_API_KEY=... python src/app.py
29+
```
30+
31+
It listens on `http://localhost:4600/ag-ui` (`PORT` to change) and answers `GET /health`.
32+
33+
| Variable | Default | Meaning |
34+
| ---------------- | --------- | ---------------------------------------------------- |
35+
| `OPENAI_API_KEY` | required | Read by Pydantic AI's OpenAI provider. |
36+
| `BOT_MODEL` | `gpt-5.5` | Model the agent runs. Any tool-calling model. |
37+
| `PORT` | `4600` | Port the AG-UI endpoint listens on. |
38+
| `REQUIRE_KEY` | unset | When set, `/ag-ui` refuses a run whose `authorization` header does not match. |
39+
40+
`OPENAI_BASE_URL` points the OpenAI provider at a compatible gateway, the same way the rest of the
41+
deployment is configured (see [docs/configuration.md](../../docs/configuration.md)). Note which API
42+
that gateway has to serve: Pydantic AI calls `/responses`, not `/v1/chat/completions`. A gateway
43+
offering only chat completions will not answer this example, and the reverse of the constraint the
44+
two TypeScript Bots carry, which is that they speak chat completions and so cannot use the models
45+
that require Responses.
46+
47+
## Register it
48+
49+
Give a coworker this endpoint, either from `/agents` in the UI or as a `remote-ag-ui` agent in a
50+
tenant package:
51+
52+
```yaml
53+
agents:
54+
- id: pydantic-analyst
55+
name: Pydantic Analyst
56+
title: Research
57+
role_description: Research on a governed computer, written in Pydantic AI.
58+
type: remote-ag-ui
59+
endpoint: ${PYDANTIC_BOT_AG_UI_URL:-http://localhost:4600/ag-ui}
60+
```
61+
62+
## Notes
63+
64+
- Serving is done with `AGUIAdapter.dispatch_request` from `pydantic_ai.ui.ag_ui`, which reads the
65+
`RunAgentInput`, exposes its `tools` to the model as external (frontend) tools, and returns a
66+
streaming AG-UI response. Verified against `pydantic-ai` 2.33.0; if yours predates the
67+
`pydantic_ai.ui.ag_ui` module, upgrade it.
68+
- Only tool-calling models can drive the computer. A model without tool calling will chat but never
69+
open a page.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "openbot-example-pydantic-ai-bot"
3+
version = "0.0.0"
4+
description = "An example OpenBot Bot written in Pydantic AI, served over AG-UI."
5+
requires-python = ">=3.10"
6+
dependencies = [
7+
"pydantic-ai[ag-ui]>=2.33",
8+
"starlette>=0.37",
9+
"uvicorn>=0.30",
10+
]
11+
12+
[tool.uv]
13+
package = false
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
A Bot written in Pydantic AI.
3+
4+
Like the LangGraph and Mastra examples, this shares no OpenBot-specific code beyond the AG-UI
5+
protocol. The browser and file tools arrive in each run's ``tools`` from the surface, and Pydantic AI
6+
exposes them to the model as external tools whose calls stream back to OpenBot rather than executing
7+
here. So this process drives a governed browser it has no direct access to.
8+
9+
Unlike those two, it is Python. OpenBot knows a Bot only as an AG-UI endpoint URL, so the language
10+
and framework behind that URL are the deployment's business, not the surface's. This is the same
11+
contract as ``agent-bot``, the LangGraph example, and the Mastra example, in a third language.
12+
"""
13+
14+
import os
15+
16+
from pydantic_ai import Agent
17+
from pydantic_ai.ui.ag_ui import AGUIAdapter
18+
from starlette.applications import Starlette
19+
from starlette.requests import Request
20+
from starlette.responses import JSONResponse, Response
21+
from starlette.routing import Route
22+
23+
MODEL = os.environ.get("BOT_MODEL", "gpt-5.5")
24+
25+
# A real Pydantic AI agent with its own model client. It defines no tools of its own: the tools it
26+
# may call arrive per run from the surface (see below), so this file never names `computer_navigate`
27+
# and still drives a governed browser.
28+
agent = Agent(
29+
f"openai:{MODEL}",
30+
instructions=(
31+
"You are a Bot running on Pydantic AI inside OpenBot. You have a real web browser available "
32+
"through the tools you are given.\n\n"
33+
# Same guard as the LangGraph and Mastra examples: page contents require a fresh tool result.
34+
"NEVER state what a page contains unless you have just read it with a tool in this "
35+
"conversation. You cannot know a page's contents from memory, and a plausible guess is a "
36+
"wrong answer. If you have not read it, call the tool first, and report exactly what the "
37+
"tool returned."
38+
),
39+
)
40+
41+
42+
async def ag_ui(request: Request) -> Response:
43+
"""One POST carrying a ``RunAgentInput``, a stream of AG-UI events back.
44+
45+
Guarded by ``REQUIRE_KEY`` when it is set, the same as the LangGraph example: OpenBot sends the
46+
Bot's key on every run, and an endpoint that never reads the header accepts a run from anyone who
47+
can reach the port. Optional because a developer running this by hand has no key to send, and
48+
refusing them would teach nothing.
49+
50+
``AGUIAdapter.dispatch_request`` reads the run input, exposes ``input.tools`` to the model as
51+
external (frontend) tools, runs the agent, and returns a streaming AG-UI Server-Sent-Events
52+
response. The tool loop stays on the client, exactly as it does for the Bot in the box: a tool
53+
call is emitted, this run ends, and OpenBot executes it through the policy gateway before starting
54+
the next run with the result. That is why this file can drive a browser it has no access to.
55+
"""
56+
required_key = os.environ.get("REQUIRE_KEY")
57+
if required_key and request.headers.get("authorization") != required_key:
58+
print("refused a run: wrong or missing key")
59+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
60+
return await AGUIAdapter.dispatch_request(request, agent=agent)
61+
62+
63+
async def health(_: Request) -> Response:
64+
return JSONResponse({"status": "ok", "framework": "pydantic-ai"})
65+
66+
67+
app = Starlette(
68+
routes=[
69+
Route("/health", health),
70+
Route("/ag-ui", ag_ui, methods=["POST"]),
71+
],
72+
)
73+
74+
75+
if __name__ == "__main__":
76+
import uvicorn
77+
78+
port = int(os.environ.get("PORT", "4600"))
79+
print(f"pydantic-ai-bot listening on http://localhost:{port}/ag-ui (model {MODEL})")
80+
uvicorn.run(app, host="0.0.0.0", port=port)

0 commit comments

Comments
 (0)