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..06dd811 --- /dev/null +++ b/examples/pydantic-ai-bot/README.md @@ -0,0 +1,64 @@ +# 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 + +- 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 new file mode 100644 index 0000000..6d1327d --- /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]>=2.33", + "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..41a8c82 --- /dev/null +++ b/examples/pydantic-ai-bot/src/app.py @@ -0,0 +1,71 @@ +""" +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 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 + +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. + + ``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 AGUIAdapter.dispatch_request(request, agent=agent) + + +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)