Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
120 changes: 85 additions & 35 deletions python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,81 @@ def _filter_modified_args(
return result


def _split_mixed_message_to_agui(msg: Message, role: str) -> list[dict[str, Any]]:
"""Convert a Message that carries function_result content into ordered AG-UI messages.

A single Agent Framework message can interleave assistant content (text,
function_call) with one or more function_result (tool) contents -- for example a
parallel tool-call batch or a finalized turn. AG-UI needs each function_result as
its own ``tool`` message, but the assistant call that produced a result must still
precede it; a result emitted ahead of its matching call is an orphan that providers
reject or drop. This walks ``msg.contents`` in order and flushes any accumulated
assistant segment (text/tool_calls) before each result, preserving the original
call -> result ordering.

The source message id is kept on the first emitted message; every additional
message gets 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 the split messages.
"""
from ._utils import generate_event_id

messages: list[dict[str, Any]] = []
seg_text = ""
seg_tool_calls: list[dict[str, Any]] = []
source_id_available = bool(msg.message_id)

def next_id() -> str:
nonlocal source_id_available
if source_id_available and msg.message_id:
source_id_available = False
return msg.message_id
source_id_available = False
return generate_event_id()

def flush_segment() -> None:
nonlocal seg_text, seg_tool_calls
if not seg_text and not seg_tool_calls:
return
assistant_msg: dict[str, Any] = {"id": next_id(), "role": role, "content": seg_text}
if seg_tool_calls:
assistant_msg["tool_calls"] = seg_tool_calls
messages.append(assistant_msg)
seg_text = ""
seg_tool_calls = []

for content in msg.contents:
if content.type == "text":
seg_text += content.text or ""
elif content.type == "function_call":
seg_tool_calls.append(
{
"id": content.call_id,
"type": "function",
"function": {
"name": content.name,
"arguments": content.arguments,
},
}
Comment thread
manjunathshiva marked this conversation as resolved.
Outdated
)
elif content.type == "function_result":
# Flush any assistant call/text accumulated before this result so the
# matching call precedes it, then emit the result as its own tool message.
flush_segment()
Comment thread
manjunathshiva marked this conversation as resolved.
Outdated
messages.append(
{
"id": next_id(),
"role": "tool",
"content": content.result if content.result is not None else "",
"toolCallId": content.call_id,
}
)

# Emit any trailing assistant segment (e.g. a summary text after the results).
flush_segment()
return messages


def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.

Expand Down Expand Up @@ -993,13 +1068,21 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An
role_value: str = msg.role if hasattr(msg.role, "value") else msg.role
role = FRAMEWORK_TO_AGUI_ROLE.get(role_value, "user")

# A message carrying function_result content may interleave assistant
# (text/function_call) and tool (function_result) segments -- e.g. parallel
# tool calls or a finalized turn. Split it into ordered AG-UI messages so no
# result is dropped and each result stays after its matching call. Messages
# with no result use the simple single-message form below.
if any(content.type == "function_result" for content in msg.contents):
result.extend(_split_mixed_message_to_agui(msg, role))
continue

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

for content in msg.contents:
if content.type == "text":
content_text += content.text # type: ignore[operator]
content_text += content.text or ""
elif content.type == "function_call":
tool_calls.append(
{
Expand All @@ -1011,39 +1094,6 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An
},
}
)
elif content.type == "function_result":
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):
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 Down
50 changes: 50 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 @@ -963,6 +963,56 @@ def test_agent_framework_to_agui_function_result_with_text_preserves_both():
assert text_msg["id"] != tool_msg["id"]


def test_agent_framework_to_agui_function_call_precedes_its_result():
"""A [function_call, function_result] message keeps the call before the result (no orphan)."""
msg = Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_a", name="get_weather", arguments={"city": "Seattle"}),
Content.from_function_result(call_id="call_a", result="Sunny"),
],
message_id="mixed-order-1",
)

messages = agent_framework_messages_to_agui([msg])

assert len(messages) == 2
assistant_msg, tool_msg = messages
# The assistant call must be emitted before its result; a result ahead of its
# matching call would be an orphan that providers reject.
assert assistant_msg["role"] == "assistant"
assert [tc["id"] for tc in assistant_msg["tool_calls"]] == ["call_a"]
assert tool_msg["role"] == "tool"
assert tool_msg["toolCallId"] == "call_a"
assert tool_msg["content"] == "Sunny"
# First emitted message keeps the source id; the split-off message gets its own.
assert assistant_msg["id"] == "mixed-order-1"
assert tool_msg["id"] != assistant_msg["id"]


def test_agent_framework_to_agui_call_result_text_order_preserved():
"""[function_call, function_result, text] round-trips in order: call, result, then summary text."""
msg = Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_a", name="get_weather", arguments="{}"),
Content.from_function_result(call_id="call_a", result="Sunny"),
Content.from_text("It is sunny."),
],
message_id="mixed-order-2",
)

messages = agent_framework_messages_to_agui([msg])

assert [m["role"] for m in messages] == ["assistant", "tool", "assistant"]
assert messages[0]["tool_calls"][0]["id"] == "call_a"
assert messages[1]["toolCallId"] == "call_a"
assert messages[2]["content"] == "It is sunny."
# Only the first emitted message reuses the source id, and all ids are distinct.
assert messages[0]["id"] == "mixed-order-2"
assert len({m["id"] for m in messages}) == 3


# Additional tests for better coverage


Expand Down
Loading