Skip to content

Commit 6df3288

Browse files
authored
fix: AG-UI follow-up messages failing after built-in tool use (#4624)
1 parent 69793df commit 6df3288

File tree

2 files changed

+128
-3
lines changed

2 files changed

+128
-3
lines changed

pydantic_ai_slim/pydantic_ai/ui/ag_ui/_adapter.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
from base64 import b64decode
67
from collections.abc import Mapping, Sequence
78
from functools import cached_property
@@ -165,7 +166,7 @@ def load_messages(cls, messages: Sequence[Message]) -> list[ModelMessage]: # no
165166
case _: # pragma: no cover
166167
raise ValueError(f'Unsupported user message part type: {type(part)}')
167168

168-
if user_prompt_content: # pragma: no branch
169+
if user_prompt_content:
169170
content_to_add = (
170171
user_prompt_content[0]
171172
if len(user_prompt_content) == 1 and isinstance(user_prompt_content[0], str)
@@ -211,10 +212,16 @@ def load_messages(cls, messages: Sequence[Message]) -> list[ModelMessage]: # no
211212

212213
if tool_call_id.startswith(BUILTIN_TOOL_CALL_ID_PREFIX):
213214
_, provider_name, original_id = tool_call_id.split('|', 2)
215+
content: Any = tool_msg.content
216+
if isinstance(content, str):
217+
try:
218+
content = json.loads(content)
219+
except (json.JSONDecodeError, ValueError):
220+
pass
214221
builder.add(
215222
BuiltinToolReturnPart(
216223
tool_name=tool_name,
217-
content=tool_msg.content,
224+
content=content,
218225
tool_call_id=original_id,
219226
provider_name=provider_name,
220227
)

tests/test_ag_ui.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1809,7 +1809,11 @@ async def test_messages(image_content: BinaryContent, document_content: BinaryCo
18091809
),
18101810
BuiltinToolReturnPart(
18111811
tool_name='web_search',
1812-
content='{"results": [{"title": "Hello, world!", "url": "https://en.wikipedia.org/wiki/Hello,_world!"}]}',
1812+
content={
1813+
'results': [
1814+
{'title': 'Hello, world!', 'url': 'https://en.wikipedia.org/wiki/Hello,_world!'}
1815+
]
1816+
},
18131817
tool_call_id='search_1',
18141818
timestamp=IsDatetime(),
18151819
provider_name='function',
@@ -1848,6 +1852,120 @@ async def test_messages(image_content: BinaryContent, document_content: BinaryCo
18481852
)
18491853

18501854

1855+
async def test_builtin_tool_return_json_string_content_parsed() -> None:
1856+
"""Regression test for https://github.com/pydantic/pydantic-ai/issues/4623.
1857+
1858+
AG-UI ToolMessage.content is always a string. For built-in tools the original
1859+
dict content gets JSON-serialized on the way out. The adapter must parse it
1860+
back so downstream model code (which checks isinstance(content, dict)) doesn't
1861+
silently drop the tool result.
1862+
"""
1863+
messages: list[Message] = [
1864+
AssistantMessage(
1865+
id='msg_1',
1866+
tool_calls=[
1867+
ToolCall(
1868+
id='pyd_ai_builtin|anthropic|srvtoolu_abc123',
1869+
function=FunctionCall(
1870+
name='web_fetch',
1871+
arguments='{"url": "https://example.com"}',
1872+
),
1873+
),
1874+
],
1875+
),
1876+
ToolMessage(
1877+
id='msg_2',
1878+
content='{"type": "web_fetch_result", "url": "https://example.com", "page_content": "hello"}',
1879+
tool_call_id='pyd_ai_builtin|anthropic|srvtoolu_abc123',
1880+
),
1881+
]
1882+
1883+
result = AGUIAdapter.load_messages(messages)
1884+
response = result[0]
1885+
assert isinstance(response, ModelResponse)
1886+
1887+
return_part = response.parts[1]
1888+
assert isinstance(return_part, BuiltinToolReturnPart)
1889+
assert return_part.tool_name == 'web_fetch'
1890+
assert return_part.tool_call_id == 'srvtoolu_abc123'
1891+
assert return_part.provider_name == 'anthropic'
1892+
content = return_part.content
1893+
assert content == {'type': 'web_fetch_result', 'url': 'https://example.com', 'page_content': 'hello'}
1894+
1895+
1896+
async def test_builtin_tool_return_plain_string_content_preserved() -> None:
1897+
"""Plain string content that isn't valid JSON stays as-is."""
1898+
messages: list[Message] = [
1899+
AssistantMessage(
1900+
id='msg_1',
1901+
tool_calls=[
1902+
ToolCall(
1903+
id='pyd_ai_builtin|anthropic|srvtoolu_abc456',
1904+
function=FunctionCall(
1905+
name='web_fetch',
1906+
arguments='{"url": "https://example.com"}',
1907+
),
1908+
),
1909+
],
1910+
),
1911+
ToolMessage(
1912+
id='msg_2',
1913+
content='just a plain string, not JSON',
1914+
tool_call_id='pyd_ai_builtin|anthropic|srvtoolu_abc456',
1915+
),
1916+
]
1917+
1918+
result = AGUIAdapter.load_messages(messages)
1919+
response = result[0]
1920+
assert isinstance(response, ModelResponse)
1921+
1922+
return_part = response.parts[1]
1923+
assert isinstance(return_part, BuiltinToolReturnPart)
1924+
assert return_part.content == 'just a plain string, not JSON'
1925+
1926+
1927+
async def test_builtin_tool_return_non_string_content_passthrough() -> None:
1928+
"""When ToolMessage.content is already a non-string (e.g. dict), it passes through without JSON parsing."""
1929+
tool_msg = ToolMessage.model_construct(
1930+
id='msg_2',
1931+
content={'type': 'web_fetch_result', 'url': 'https://example.com'},
1932+
tool_call_id='pyd_ai_builtin|anthropic|srvtoolu_abc789',
1933+
)
1934+
messages: list[Message] = [
1935+
AssistantMessage(
1936+
id='msg_1',
1937+
tool_calls=[
1938+
ToolCall(
1939+
id='pyd_ai_builtin|anthropic|srvtoolu_abc789',
1940+
function=FunctionCall(
1941+
name='web_fetch',
1942+
arguments='{"url": "https://example.com"}',
1943+
),
1944+
),
1945+
],
1946+
),
1947+
tool_msg,
1948+
]
1949+
1950+
result = AGUIAdapter.load_messages(messages)
1951+
response = result[0]
1952+
assert isinstance(response, ModelResponse)
1953+
1954+
return_part = response.parts[1]
1955+
assert isinstance(return_part, BuiltinToolReturnPart)
1956+
assert return_part.content == {'type': 'web_fetch_result', 'url': 'https://example.com'}
1957+
1958+
1959+
async def test_user_message_empty_content_list_skipped() -> None:
1960+
"""A UserMessage with an empty content list produces no UserPromptPart."""
1961+
messages: list[Message] = [
1962+
UserMessage(id='msg_1', content=[]),
1963+
]
1964+
1965+
result = AGUIAdapter.load_messages(messages)
1966+
assert result == []
1967+
1968+
18511969
async def test_builtin_tool_call() -> None:
18521970
"""Test back-to-back builtin tool calls share the same parent_message_id.
18531971

0 commit comments

Comments
 (0)