@@ -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+
18511969async def test_builtin_tool_call () -> None :
18521970 """Test back-to-back builtin tool calls share the same parent_message_id.
18531971
0 commit comments