Files
Mark 8a6d14b29a fix(showcase): port pydantic-ai integration to v2 and restore live system prompts (#6379)
Ports `showcase/integrations/pydantic-ai` — the last pydantic-ai surface
still on v1 — to Pydantic AI v2. Refs #6364.

Three commits plus a bot formatting fix, best reviewed separately.

## 1. `chore(showcase): port pydantic-ai integration to Pydantic AI v2`

- **`requirements.txt`** → `pydantic-ai-slim[ag-ui,openai]==2.22.0`,
`ag-ui-protocol==0.1.19`. Drops the `opentelemetry-api<1.44` ceiling
from #6374; v2 resolves cleanly against otel 1.44.0, so the workaround
is no longer needed. `starlette<1.0.0` is unchanged and satisfies v2's
`>=0.46.2`.
- **9 `StateDeps` imports** move from `pydantic_ai.ag_ui` (removed in
v2) to `pydantic_ai.ui`.
- **`agent_server.py`** — `Agent.to_ag_ui()` was removed in 2.0.0, so a
`mount_agent()` helper builds the equivalent Starlette sub-app and
mounts it. The shape is deliberately identical to what v1's `AGUIApp`
produced — a Starlette app whose only route is `POST /`, named
`run_agent` — so **all 19 mount paths behave exactly as before, trailing
slashes included, and no TypeScript route file changes**.

`deps` is constructed **per request**. v1's `run_ag_ui` did `deps =
replace(deps, state=state)`, handing each run its own object; v2's
adapter does `deps.state = state`, mutating what it is given. A single
shared instance under v2 therefore lets concurrent runs overwrite each
other's state mid-run.

## 2. `fix(showcase): apply the multimodal provider gate to v2 native
content`

v2's `AGUIAdapter.load_messages` converts AG-UI attachments to native
content types *before* the model boundary; v1 delivered the raw AG-UI
part dicts. `_NATIVE_CONTENT` listed `BinaryContent` as a flatten
fixpoint, so under v2 inline attachments were waved straight through and
the entire provider gate was skipped:

- inline PDFs were no longer text-extracted, so raw bytes went to OpenAI
- unsupported image subtypes (HEIC/SVG/TIFF) were no longer degraded and
reached the provider as images, which fails the turn
- missing-mime magic-byte sniffing never ran
- `AudioUrl`/`VideoUrl` were neither fixpoints nor classifiable, so they
hit the fail-loud raise

`BinaryContent` is no longer a fixpoint. `_classify_native_content` maps
native content onto the same `(kind, scheme, mime, value)` tuple the
AG-UI classifier already produces, so **every existing gate applies
unchanged** — no gate logic was rewritten. `audio/*` and `video/*` are
named explicitly because `_kind_for` routes them to `"other"`, and a
missing mime defaults to `"image"` so the sniffer runs.

Net behaviour matches v1: a supported inline image still flattens to an
`ImageUrl` data URI, which is why most of the suite went green without
touching assertions.

Five assertions did change. They checked that state-backing content was
still AG-UI `InputContent`, which encoded v1's bridging. They now assert
the flatten's output (`ImageUrl`) never appears in state — the leak they
were written to guard. The adjacent identity and snapshot checks that
prove non-mutation are untouched.

## 3. `fix(showcase): gate url-source content and correct the v1-parity
claim`

Adversarial review of the first two commits found the gate was only half
fixed. `_NATIVE_CONTENT` still short-circuited `ImageUrl` and
`DocumentUrl`, which v2 builds from unvetted client input, so url-source
attachments bypassed the gate where v1 routed them through it:

- an `image/heic` or `image/svg+xml` url reached the provider as
`input_image`, which the Responses API rejects — failing the turn
- an `audio/mpeg` document url reached it as `input_file`
- a blank-mime inline PDF went to the image sniffer instead of text
extraction, because `load_messages` collapses `ImageInputContent` and
`DocumentInputContent` to the same bare `BinaryContent` and erases the
modality v1 defaulted on

Native content is now gated **before** the fixpoint check rather than
instead of it. `_classify_native_content` returns a tuple only when the
gate must act; `None` means provider-safe and falls through to the
fixpoint, preserving object identity. `ImageUrl` is gated rather than
rerouted so a provider-safe one keeps its identity and any explicit
`_media_type`.

It also corrected a false claim. The `mount_agent` docstring said
routing *and* behaviour were unchanged. Routing is; model input is not.
v2 defaults `manage_system_prompt='server'`, so each agent's
`system_prompt=` now reaches the model. On v1 it never did —
`_agent_graph` emitted system parts only `if not messages` and the AG-UI
bridge always supplied history — so **18 of 19 agents had silently dead
system prompts on main**. A/B on both versions with the same agent and
request: v1 sends 0 system-prompt parts, v2 sends 1. The new behaviour
is correct and kept; the docstring now says so.

## Verification

Against pydantic-ai 2.22.0, in a venv built from this branch's
`requirements.txt`:

- **52/52 Python tests pass**, up from 42/52.
`test_multimodal_content_mapping.py`'s `importorskip` pointed at the
removed `pydantic_ai.ag_ui`, which would have skipped all 43 of its
tests **green** under v2; it now targets `pydantic_ai.ui.ag_ui` and uses
the public `AGUIAdapter.load_messages` in place of the v1 private
helper.
- **16/19 mounts** return `200 text/event-stream` with `RUN_STARTED …
RUN_FINISHED` and no `RUN_ERROR`, driven through the real app with
`TestClient` using trailing-slash URLs as the TS routes do. The other
three (`/a2ui_dynamic`, `/beautiful_chat`, `/`) reach tool execution and
then fail on a raw `OpenAI()` client constructed inside a tool, which
the harness cannot intercept and aimock handles in CI.
- **Per-request deps isolation** confirmed on
`/shared_state_read_write`: state sent by one request does not appear in
the next.

`build-check (pydantic-ai)` is green on this branch, and because
`requirements.txt` changed, the cached pip layer was invalidated — so
that was a **genuine fresh resolve of pydantic-ai 2.22.0 inside the real
Dockerfile**, not a cached pass. It also confirms dropping the
`opentelemetry-api<1.44` ceiling is safe.

### D6 harness probes — run, with a baseline

The behavioural gate is the shared harness D6 probes. No CI job runs
them for showcase paths, so they were run locally on both this branch
and `main`:

| | main (v1) | this branch (v2) |
|---|---|---|
| passed | **33** / 36 | **34** / 36 |
| `reasoning-display` | ✗ `no reasoning-role message rendered within
5000ms` | ✅ **passes** |
| `gen-ui-agent` | ✗ `waitForTurnComplete … runStartCount=2,
done-signal-missing` | ✗ identical error |
| `shared-state-read` | ✗ `Strict mode: 1 candidate fixture(s) skipped
by sequence/turn state` | ✗ identical error |

```bash
cd showcase
AIMOCK_URL_LOCAL=http://localhost:4010 bin/showcase test pydantic-ai --d6 --direct --rebuild --cycle --verbose
```

**The port takes D6 from 33/36 to 34/36.** The two remaining failures
are pre-existing on `main` with byte-identical error strings — this
branch neither causes nor fixes them, and both are tracked in #6381
rather than blocking here.

`gen-ui-agent` is root-caused and is not fixture drift: that demo was
never ported to pydantic-ai. `src/agents/gen_ui_agent.py` exists in
llamaindex with a real `set_steps` tool but has no counterpart here, the
route points at `/gen_ui_tool_based/` (the chart-viz agent), and
`set_steps` is declared nowhere in the package. The fixture fabricates
`set_steps` calls the backend cannot honour, so pydantic-ai rejects the
unknown tool and exhausts its single retry. Confirmed live against real
OpenAI: the cell returns plain text, which is correct for the code as
written.

`reasoning-display` going green is the notable behavioural gain, and it
retires a documented v1 limitation. `PARITY_NOTES.md:91-97` justifies
omitting the reasoning-message branch of `use-rendered-messages.tsx` on
the grounds that "PydanticAI's AG-UI adapter does not emit reasoning
content today" — true on v1, false on v2. (That block is stale on two
further counts: it cites `@ag-ui/core@0.0.43` where `package.json` pins
0.0.57, and claims `ReasoningMessage` is not exported where it is
imported at `reasoning-block.tsx:4`.) Correcting it is tracked on #6364.

To be precise about what that proves: **v2 forwards reasoning content
where v1 dropped it.** The probe supplies the reasoning channel via its
fixture, so what is verified is the forwarding path — adapter → AG-UI
stream → frontend renderer — end to end. Whether a given model actually
emits a reasoning summary live is a separate matter and outside this
port's control: it requires a native reasoning model
(`reasoning_agent.py` defaults to `gpt-5`, overridable via
`REASONING_MODEL`) and, for summary text, a verified OpenAI
organisation. A live run here returned prose with no reasoning block,
consistent with the org-verification gate rather than anything in the
port.

Also verified: the image builds from scratch on v2. Because
`requirements.txt` changed, the cached pip layer was invalidated, so
`build-check (pydantic-ai)` in CI was a genuine fresh resolve of
pydantic-ai 2.22.0 inside the real Dockerfile — which also confirms
dropping the `opentelemetry-api<1.44` ceiling is safe.

### CI gate coverage, for the record

No CI job exercises this package's runtime behaviour on a PR, on this
branch or on `main`:

- `test / e2e / dojo` runs from the upstream `ag-ui` checkout (`ref:
main`) against upstream example agents, and filters on `packages/**` /
`sdk-python/**`
- `test_showcase-frontend-matrix.yml` is dispatch-only and builds the
integration from `base/` — a frozen-backend React baseline
- `showcase_validate.yml` asserts `tests/e2e/` exists with a minimum
spec count; it does not run it
- the package's own `tests/e2e/` (37 files) is invoked by nothing — per
`AGENTS.md` rule 1 the measuring test is the shared harness probe, so
that layer is legacy

## Remaining for #6364

Two acceptance criteria are outstanding, which is why this says Refs
rather than Closes:

- the harness D6 value-test (`bin/showcase test pydantic-ai --d6
--rebuild`), which no CI gate runs for showcase paths
- `PARITY_NOTES.md` has 6 version-dependent blocks, 4 of which were
already inaccurate against the tree before this PR; left alone
deliberately to keep this diff scoped

## Possible follow-up

`multimodal_agent.py` still reaches into three private APIs
(`pydantic_ai._run_context`, `pydantic_ai.models.wrapper`,
`pydantic_ai.models.{ModelRequestParameters,StreamedResponse}`) and
subclasses `WrapperModel`, overriding
`request`/`count_tokens`/`request_stream`. v2 adds a supported
alternative: `AbstractCapability.before_model_request`, which receives a
`ModelRequestContext` carrying `messages` and `streaming`. Migrating
would delete those private imports and ~85 lines. Deliberately not in
this PR — it fixes nothing and would obscure the review.
2026-08-13 09:59:40 -07:00
..