Repository navigation
Python: fix(tools): preserve UTF-8 characters without unicode escaping in tool results - #9400
Nithin (nithin42) wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Two new tests need non-None text assertions to avoid type-check errors.
1 open finding
What changed in this PR
Preserves non-ASCII characters in Python tool-result JSON, addressing #9366 and matching existing serialization behavior elsewhere in the framework.
Changes:
- Adds
ensure_ascii=Falseto MCP and function-result serialization. - Adds regression tests for multilingual tool outputs.
| File | Description |
|---|---|
python/packages/core/tests/core/test_types.py |
Tests Unicode preservation in function results. |
python/packages/core/tests/core/test_mcp.py |
Tests Unicode preservation in MCP structured results. |
python/packages/core/agent_framework/_types.py |
Preserves Unicode in Content.from_function_result. |
python/packages/core/agent_framework/_tools.py |
Preserves Unicode in FunctionTool.parse_result. |
python/packages/core/agent_framework/_mcp.py |
Preserves Unicode when serializing structuredContent. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
|
One more spot in Ran this on the PR branch (d21f61d), with a stub session whose prompt returns two text messages, "Año" and "Género": Adding |
|
Somay kousis (@Somay-kousis) Good catch, thanks for the review! I've added |
e71b5a7 to
762d1d4
Compare
Lucas Kim (kimnamu)
left a comment
There was a problem hiding this comment.
Thanks for turning #9366 around the same day it was filed, with a test for each call site, and for adding the multi-message prompt case within half an hour of it being pointed out.
I checked what this changes for Bedrock. BedrockChatClient re-parses tool-result text with json.loads before it builds the Converse toolResult (_chat_client.py:681), so single results were already decoded. Nothing regresses there, and the joined-text cases stop sending escapes to the model:
| Tool result | toolResult.content on main 4982e4c6 |
with this PR 762d1d4f |
|---|---|---|
function tool returns {"col": "Año"} |
[{"json": {"col": "Año"}}] |
same ✅ |
MCP structured_first (default) |
[{"json": {"col": "Año"}}] |
same ✅ |
function tool returns [Content.from_text("cols:"), {"col": "Año"}] |
[{"text": "cols:\n{\"col\": \"A\\u00f1o\"}"}] |
[{"text": "cols:\n{\"col\": \"Año\"}"}] |
MCP both |
[{"text": "Año, Género\n{\"col\": \"A\\u00f1o\"}"}] |
[{"text": "Año, Género\n{\"col\": \"Año\"}"}] |
The last two rows differ because several text items are joined with "\n" first (_chat_client.py:657), the result no longer parses, and it goes out as a text block.
One more spot in a function you already touched, if you'd like it here: _parse_prompt_result_from_mcp still dumps ResourceLink parts with the default escaping (_mcp.py:1300). For a prompt with a text message plus a link named Categoría.csv, the second Bedrock block is {"text": "{\"name\": \"Categor\\u00eda.csv\", \"uri\": \"file:///data/c.csv\", \"type\": \"resource_link\"}"}. A single-message prompt is fine for the same json.loads reason. Extending your new test catches it:
diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py
index 091342c5..9ea61493 100644
--- a/python/packages/core/agent_framework/_mcp.py
+++ b/python/packages/core/agent_framework/_mcp.py
@@ -1297,7 +1297,9 @@ class MCPTool:
)
)
elif isinstance(content, types.ResourceLink):
- parts.append(json.dumps(content.model_dump(by_alias=True, exclude_none=True), default=str))
+ parts.append(
+ json.dumps(content.model_dump(by_alias=True, exclude_none=True), default=str, ensure_ascii=False)
+ )
else:
parts.append(str(content))
if not parts:
diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py
index 2f173abd..61a1eb00 100644
--- a/python/packages/core/tests/core/test_mcp.py
+++ b/python/packages/core/tests/core/test_mcp.py
@@ -1118,6 +1118,10 @@ def test_parse_prompt_result_from_mcp_unicode_unescaped():
role="assistant",
content=types.TextContent(type="text", text="Género"),
),
+ types.PromptMessage(
+ role="user",
+ content=types.ResourceLink(type="resource_link", name="Categoría.csv", uri="file:///data/c.csv"),
+ ),
],
)
@@ -1127,6 +1131,7 @@ def test_parse_prompt_result_from_mcp_unicode_unescaped():
assert "Género" in result
assert "\\u00f1" not in result
assert "\\u00e9" not in result
+ assert "Categoría.csv" in result
def test_parse_tool_result_from_mcp():# extended test, source at 762d1d4f
E assert 'Categoría.csv' in '["Año", "Género", "{\\"name\\": \\"Categor\\\\u00eda.csv\\", \\"uri\\": \\"file:///data/c.csv\\", \\"type\\": \\"resource_link\\"}"]'
1 failed, 466 deselected
# plus the _mcp.py change
1 passed, 466 deselected
Red/green on the PR's own tests and local runs
- The 5 new tests with only
_mcp.py,_tools.pyand_types.pyput back to the merge basec3ce2506:5 failed(for exampleassert 'Año' in '{"columns": ["A\\u00f1o", "G\\u00e9nero", "Categor\\u00eda"]}'). With the PR sources restored:5 passed. test_mcp.py+test_types.pyat762d1d4f:950 passed, 2 skipped. Same count with the diff above applied (git apply), andruff format/ruff checkare clean on_mcp.py.packages/bedrock/tests -m "not integration"at762d1d4f:92 passed.- The function-tool rows come from an
Agentrun against a stubbedconverse(tool call on the first turn, text on the second), reading thetoolResultfrom the second request. The MCP rows passMCPTool._parse_tool_result_from_mcpoutput through_convert_content_to_bedrock_block.
Claude (via Claude Code) assisted with the reproduction and this write-up.
|
Lucas Kim (@kimnamu) Good catch on the ResourceLink part too. I have that change tested and ready to go locally, and I am happy to push it up if the maintainers would like it included in this PR. I will wait for their review and feedback first so we do not churn the branch. |

Motivation & Context
Fixes #9366
When MCP tool results (
structuredContent) and standardFunctionTool/Content.from_function_resultserialize structured output to JSON text,json.dumps()defaultensure_ascii=Trueescapes non-ASCII characters (e.g. Spanish, German, CJK, Cyrillic) into\uXXXXsequences.This causes:
Precedent PRs across the repository (#6627, #6628, #7124, #3894) have standardized on
ensure_ascii=Falsewhen serializing structured tool and agent payloads.Description & Review Guide
agent_framework/_mcp.py: Addedensure_ascii=Falsetojson.dumps(mcp_type.structuredContent, ...).agent_framework/_tools.py: Addedensure_ascii=Falsetojson.dumps(...)inFunctionTool.parse_resultfor item lists and dict results.agent_framework/_types.py: Addedensure_ascii=Falsetojson.dumps(...)inContent.from_function_result.test_mcp.pyandtest_types.pyverifying characters such asñ,é,í, andüare preserved without unicode escape sequences.Contribution Checklist