From c2efa7605a1d224fc6bfd7f02505ac3b1bdf631a Mon Sep 17 00:00:00 2001 From: andreolf Date: Thu, 20 Aug 2026 13:38:35 +0200 Subject: [PATCH 1/2] 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 --- examples/pydantic-ai-bot/.gitignore | 3 + examples/pydantic-ai-bot/README.md | 63 +++++++++++++++++++++ examples/pydantic-ai-bot/pyproject.toml | 13 +++++ examples/pydantic-ai-bot/src/app.py | 75 +++++++++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 examples/pydantic-ai-bot/.gitignore create mode 100644 examples/pydantic-ai-bot/README.md create mode 100644 examples/pydantic-ai-bot/pyproject.toml create mode 100644 examples/pydantic-ai-bot/src/app.py diff --git a/examples/pydantic-ai-bot/.gitignore b/examples/pydantic-ai-bot/.gitignore new file mode 100644 index 0000000..00f2d38 --- /dev/null +++ b/examples/pydantic-ai-bot/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.venv/ diff --git a/examples/pydantic-ai-bot/README.md b/examples/pydantic-ai-bot/README.md new file mode 100644 index 0000000..98146f2 --- /dev/null +++ b/examples/pydantic-ai-bot/README.md @@ -0,0 +1,63 @@ +# Pydantic AI Bot + +A Bot written in [Pydantic AI](https://ai.pydantic.dev), served over AG-UI. It sits beside the +[LangGraph](../langgraph-bot) and [Mastra](../mastra-bot) examples and proves the same point in a +third language: OpenBot knows a Bot only as an AG-UI endpoint URL, so a Python agent arrives exactly +the way a TypeScript one does. + +The browser and file tools arrive in each run's `tools` from the surface. Pydantic AI exposes them +to the model as external tools whose calls stream back to OpenBot to run through the governed gateway +— so this process drives a real browser it has no direct access to, and the tool loop stays on the +client, the same as the Bot in the box. + +## Run it + +Requires Python 3.10+. With [uv](https://docs.astral.sh/uv): + +```sh +cd examples/pydantic-ai-bot +uv run --env-file ../../.env src/app.py +``` + +Or with a plain virtualenv: + +```sh +cd examples/pydantic-ai-bot +python -m venv .venv && . .venv/bin/activate +pip install -e . +OPENAI_API_KEY=... python src/app.py +``` + +It listens on `http://localhost:4202/ag-ui` (`PORT` to change) and answers `GET /health`. + +| Variable | Default | Meaning | +| ---------------- | --------- | ---------------------------------------------------- | +| `OPENAI_API_KEY` | required | Read by Pydantic AI's OpenAI provider. | +| `BOT_MODEL` | `gpt-4.1` | Model the agent runs. Any tool-calling model. | +| `PORT` | `4202` | Port the AG-UI endpoint listens on. | + +`OPENAI_BASE_URL` points the OpenAI provider at a compatible gateway, the same way the rest of the +deployment is configured (see [docs/configuration.md](../../docs/configuration.md)). + +## Register it + +Give a coworker this endpoint, either from `/agents` in the UI or as a `remote-ag-ui` agent in a +tenant package: + +```yaml +agents: + - id: pydantic-analyst + name: Pydantic Analyst + title: Research + role_description: Research on a governed computer, written in Pydantic AI. + type: remote-ag-ui + endpoint: ${PYDANTIC_BOT_AG_UI_URL:-http://localhost:4202/ag-ui} +``` + +## Notes + +- The AG-UI helpers live at `pydantic_ai.ui.ag_ui` in current releases and `pydantic_ai.ag_ui` in + earlier ones; `src/app.py` imports whichever is present. If your `pydantic-ai` predates AG-UI + support, upgrade it. +- Only tool-calling models can drive the computer. A model without tool calling will chat but never + open a page. diff --git a/examples/pydantic-ai-bot/pyproject.toml b/examples/pydantic-ai-bot/pyproject.toml new file mode 100644 index 0000000..cc5af93 --- /dev/null +++ b/examples/pydantic-ai-bot/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "openbot-example-pydantic-ai-bot" +version = "0.0.0" +description = "An example OpenBot Bot written in Pydantic AI, served over AG-UI." +requires-python = ">=3.10" +dependencies = [ + "pydantic-ai[ag-ui]>=0.4", + "starlette>=0.37", + "uvicorn>=0.30", +] + +[tool.uv] +package = false diff --git a/examples/pydantic-ai-bot/src/app.py b/examples/pydantic-ai-bot/src/app.py new file mode 100644 index 0000000..ddcbb35 --- /dev/null +++ b/examples/pydantic-ai-bot/src/app.py @@ -0,0 +1,75 @@ +""" +A Bot written in Pydantic AI. + +Like the LangGraph and Mastra examples, this shares no OpenBot-specific code beyond the AG-UI +protocol. The browser and file tools arrive in each run's ``tools`` from the surface, and Pydantic AI +exposes them to the model as external tools whose calls stream back to OpenBot rather than executing +here. So this process drives a governed browser it has no direct access to. + +Unlike those two, it is Python. OpenBot knows a Bot only as an AG-UI endpoint URL, so the language +and framework behind that URL are the deployment's business, not the surface's. This is the same +contract as ``agent-bot``, the LangGraph example, and the Mastra example, in a third language. +""" + +import os + +from pydantic_ai import Agent +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +try: # pydantic-ai moved the AG-UI helpers under `ui` in later releases. + from pydantic_ai.ui.ag_ui import handle_ag_ui_request +except ImportError: # earlier layout + from pydantic_ai.ag_ui import handle_ag_ui_request + +MODEL = os.environ.get("BOT_MODEL", "gpt-4.1") + +# A real Pydantic AI agent with its own model client. It defines no tools of its own: the tools it +# may call arrive per run from the surface (see below), so this file never names `computer_navigate` +# and still drives a governed browser. +agent = Agent( + f"openai:{MODEL}", + instructions=( + "You are a Bot running on Pydantic AI inside OpenBot. You have a real web browser available " + "through the tools you are given.\n\n" + # Same guard as the LangGraph and Mastra examples: page contents require a fresh tool result. + "NEVER state what a page contains unless you have just read it with a tool in this " + "conversation. You cannot know a page's contents from memory, and a plausible guess is a " + "wrong answer. If you have not read it, call the tool first, and report exactly what the " + "tool returned." + ), +) + + +async def ag_ui(request: Request) -> Response: + """One POST carrying a ``RunAgentInput``, a stream of AG-UI events back. + + Pydantic AI reads the run input, exposes ``input.tools`` to the model as external tools, runs the + agent, and streams AG-UI events as Server-Sent Events. The tool loop stays on the client, exactly + as it does for the Bot in the box: a tool call is emitted, this run ends, and OpenBot executes it + through the policy gateway before starting the next run with the result. That is why this file can + drive a browser it has no access to. + """ + return await handle_ag_ui_request(agent, request) + + +async def health(_: Request) -> Response: + return JSONResponse({"status": "ok", "framework": "pydantic-ai"}) + + +app = Starlette( + routes=[ + Route("/health", health), + Route("/ag-ui", ag_ui, methods=["POST"]), + ], +) + + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("PORT", "4202")) + print(f"pydantic-ai-bot listening on http://localhost:{port}/ag-ui (model {MODEL})") + uvicorn.run(app, host="0.0.0.0", port=port) From 4595dd1ddf9458e5553db9756dbbe26da6a7b326 Mon Sep 17 00:00:00 2001 From: andreolf Date: Fri, 21 Aug 2026 14:03:17 +0200 Subject: [PATCH 2/2] 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 --- examples/pydantic-ai-bot/README.md | 7 ++++--- examples/pydantic-ai-bot/pyproject.toml | 2 +- examples/pydantic-ai-bot/src/app.py | 18 +++++++----------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/examples/pydantic-ai-bot/README.md b/examples/pydantic-ai-bot/README.md index 98146f2..06dd811 100644 --- a/examples/pydantic-ai-bot/README.md +++ b/examples/pydantic-ai-bot/README.md @@ -56,8 +56,9 @@ agents: ## Notes -- The AG-UI helpers live at `pydantic_ai.ui.ag_ui` in current releases and `pydantic_ai.ag_ui` in - earlier ones; `src/app.py` imports whichever is present. If your `pydantic-ai` predates AG-UI - support, upgrade it. +- Serving is done with `AGUIAdapter.dispatch_request` from `pydantic_ai.ui.ag_ui`, which reads the + `RunAgentInput`, exposes its `tools` to the model as external (frontend) tools, and returns a + streaming AG-UI response. Verified against `pydantic-ai` 2.33.0; if yours predates the + `pydantic_ai.ui.ag_ui` module, upgrade it. - Only tool-calling models can drive the computer. A model without tool calling will chat but never open a page. diff --git a/examples/pydantic-ai-bot/pyproject.toml b/examples/pydantic-ai-bot/pyproject.toml index cc5af93..6d1327d 100644 --- a/examples/pydantic-ai-bot/pyproject.toml +++ b/examples/pydantic-ai-bot/pyproject.toml @@ -4,7 +4,7 @@ version = "0.0.0" description = "An example OpenBot Bot written in Pydantic AI, served over AG-UI." requires-python = ">=3.10" dependencies = [ - "pydantic-ai[ag-ui]>=0.4", + "pydantic-ai[ag-ui]>=2.33", "starlette>=0.37", "uvicorn>=0.30", ] diff --git a/examples/pydantic-ai-bot/src/app.py b/examples/pydantic-ai-bot/src/app.py index ddcbb35..41a8c82 100644 --- a/examples/pydantic-ai-bot/src/app.py +++ b/examples/pydantic-ai-bot/src/app.py @@ -14,16 +14,12 @@ import os from pydantic_ai import Agent +from pydantic_ai.ui.ag_ui import AGUIAdapter from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route -try: # pydantic-ai moved the AG-UI helpers under `ui` in later releases. - from pydantic_ai.ui.ag_ui import handle_ag_ui_request -except ImportError: # earlier layout - from pydantic_ai.ag_ui import handle_ag_ui_request - MODEL = os.environ.get("BOT_MODEL", "gpt-4.1") # A real Pydantic AI agent with its own model client. It defines no tools of its own: the tools it @@ -46,13 +42,13 @@ async def ag_ui(request: Request) -> Response: """One POST carrying a ``RunAgentInput``, a stream of AG-UI events back. - Pydantic AI reads the run input, exposes ``input.tools`` to the model as external tools, runs the - agent, and streams AG-UI events as Server-Sent Events. The tool loop stays on the client, exactly - as it does for the Bot in the box: a tool call is emitted, this run ends, and OpenBot executes it - through the policy gateway before starting the next run with the result. That is why this file can - drive a browser it has no access to. + ``AGUIAdapter.dispatch_request`` reads the run input, exposes ``input.tools`` to the model as + external (frontend) tools, runs the agent, and returns a streaming AG-UI Server-Sent-Events + response. The tool loop stays on the client, exactly as it does for the Bot in the box: a tool + call is emitted, this run ends, and OpenBot executes it through the policy gateway before starting + the next run with the result. That is why this file can drive a browser it has no access to. """ - return await handle_ag_ui_request(agent, request) + return await AGUIAdapter.dispatch_request(request, agent=agent) async def health(_: Request) -> Response: