Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,7 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An

content_text = ""
tool_calls: list[dict[str, Any]] = []
tool_result_call_id: str | None = None
function_results: list[Any] = []

for content in msg.contents:
if content.type == "text":
Expand All @@ -1012,9 +1012,38 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An
}
)
elif content.type == "function_result":
# Tool result content - extract call_id and result
tool_result_call_id = content.call_id
content_text = content.result if content.result is not None else ""
function_results.append(content)

# A single Agent Framework message can carry several function_result
# contents (parallel tool calls). Emit one AG-UI tool message per result so
# none are dropped and each keeps its own toolCallId.
if function_results:
# Preserve the source id for the first result; give every additional
# message an independent generated id. Deriving suffixes from the source
# id (e.g. f"{base_id}-1") risks colliding with a legitimate id elsewhere
# in the history, which would let id-keyed clients re-collapse results.
for idx, fr in enumerate(function_results):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a follow up here...

Could we preserve the original call/result order when splitting mixed messages? This loop emits every function_result before the separate message containing tool_calls, so a framework-produced [function_call, function_result] message round-trips as [function_result, function_call]. Providers require a tool result to follow its matching assistant call, so AGUIChatClient can send an orphan result that is rejected or dropped while the later call remains pending. Walking msg.contents and flushing assistant call/text segments before each result would preserve parallel results without reversing the transcript.

@manjunathshiva Manjunath Janardhan (manjunathshiva) Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, thanks for the careful follow-up. Emitting the results before the tool_calls segment reverses a [function_call, function_result] message into [function_result, function_call], which orphans the result against its call. Walking msg.contents in order and flushing any accumulated assistant call/text segment before each function_result preserves the original call→result ordering, keeps parallel results intact, and keeps trailing assistant text after the results. I'll open a follow-up PR with that change plus a regression test for the [function_call, function_result] round-trip, and link it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened the follow-up: #8005. It walks msg.contents in order and flushes the assistant call/text segment before each function_result, so [function_call, function_result] now emits as [assistant(call), tool(result)] instead of reversing. Added regression tests for the ordering plus the parallel-result and trailing-text cases from #7980.

result.append(
{
"id": msg.message_id if (idx == 0 and msg.message_id) else generate_event_id(),
"role": "tool",
"content": fr.result if fr.result is not None else "",
"toolCallId": fr.call_id,
}
)
# A mixed message may also carry text / function_call contents alongside
# the tool results (e.g. a finalized assistant turn). Emit those as a
# separate, distinctly-identified message so they are not lost.
if content_text or tool_calls:
extra_msg: dict[str, Any] = {
"id": generate_event_id(),
"role": role,
"content": content_text,
}
if tool_calls:
extra_msg["tool_calls"] = tool_calls
result.append(extra_msg)
continue

agui_msg: dict[str, Any] = {
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id
Expand All @@ -1025,12 +1054,6 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An
if tool_calls:
agui_msg["tool_calls"] = tool_calls

# If this is a tool result message, add toolCallId (using camelCase for Pydantic)
if tool_result_call_id:
agui_msg["toolCallId"] = tool_result_call_id
# Tool result messages should have role="tool"
agui_msg["role"] = "tool"

result.append(agui_msg)

return result
Expand Down
67 changes: 67 additions & 0 deletions python/packages/ag-ui/tests/ag_ui/test_message_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,73 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
assert agui_msg["content"] == '["First result", "Second result"]'


def test_agent_framework_to_agui_parallel_function_results_not_collapsed():
"""A tool Message with several function_result contents emits one AG-UI tool message each."""
msg = Message(
role="tool",
contents=[
Content.from_function_result(call_id="call_a", result="result-a"),
Content.from_function_result(call_id="call_b", result="result-b"),
Content.from_function_result(call_id="call_c", result="result-c"),
],
message_id="tool-batch-1",
)

messages = agent_framework_messages_to_agui([msg])

tool_msgs = [m for m in messages if m.get("role") == "tool"]
assert len(tool_msgs) == 3
assert [m["toolCallId"] for m in tool_msgs] == ["call_a", "call_b", "call_c"]
assert [m["content"] for m in tool_msgs] == ["result-a", "result-b", "result-c"]
# Split messages must carry distinct ids so downstream clients keyed by id do not re-collapse them.
assert len({m["id"] for m in tool_msgs}) == 3
# The first result keeps the source message id for continuity.
assert tool_msgs[0]["id"] == "tool-batch-1"


def test_agent_framework_to_agui_parallel_function_results_none_result():
"""A None result among parallel results maps to empty string without dropping the entry."""
msg = Message(
role="tool",
contents=[
Content.from_function_result(call_id="call_a", result="result-a"),
Content.from_function_result(call_id="call_b", result=None),
],
message_id="tool-batch-2",
)

messages = agent_framework_messages_to_agui([msg])

tool_msgs = [m for m in messages if m.get("role") == "tool"]
assert len(tool_msgs) == 2
assert tool_msgs[1]["toolCallId"] == "call_b"
assert tool_msgs[1]["content"] == ""


def test_agent_framework_to_agui_function_result_with_text_preserves_both():
"""A mixed message (function_result + text) emits a tool message and keeps the text."""
msg = Message(
role="assistant",
contents=[
Content.from_function_result(call_id="weather-call", result="Sunny in Seattle"),
Content.from_text("The weather is sunny."),
],
message_id="mixed-1",
)

messages = agent_framework_messages_to_agui([msg])

assert len(messages) == 2
tool_msg, text_msg = messages
assert tool_msg["role"] == "tool"
assert tool_msg["toolCallId"] == "weather-call"
assert tool_msg["content"] == "Sunny in Seattle"
# The assistant text is not swallowed by the tool result and keeps its own role/id.
assert text_msg["role"] == "assistant"
assert text_msg["content"] == "The weather is sunny."
assert text_msg["id"] != tool_msg["id"]


# Additional tests for better coverage


Expand Down
Loading