Skip to content

Commit dd3d085

Browse files
moonbox3CopilotCopilot
authored
Python: Include reasoning messages in MESSAGES_SNAPSHOT events (microsoft#4844)
* Include reasoning messages in MESSAGES_SNAPSHOT (microsoft#4843) FlowState now tracks reasoning messages emitted during a run. _emit_text_reasoning() persists reasoning (including encrypted_value) into flow.reasoning_messages, and _build_messages_snapshot() appends them to the final MESSAGES_SNAPSHOT event. Changes: - Add reasoning_messages field to FlowState - Update _emit_text_reasoning() to accept optional flow parameter - Include reasoning_messages in _build_messages_snapshot() - Add 'reasoning' to ALLOWED_AGUI_ROLES so normalize_agui_role() preserves the role through snapshot round-trips - Skip reasoning messages in agui_messages_to_agent_framework() since they are UI-only state and should not be forwarded to LLM providers - Add regression tests for snapshot emission, encrypted value preservation, and multi-turn round-trip with reasoning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Include reasoning messages in MESSAGES_SNAPSHOT events Fixes microsoft#4843 * Fix PR review feedback for reasoning persistence (microsoft#4843) - Accumulate reasoning text per message_id (append deltas) instead of storing only the current chunk, matching flow.accumulated_text pattern - Use camelCase encryptedValue in snapshot JSON to match AG-UI protocol conventions (toolCallId, encryptedValue) - Normalize snake_case encrypted_value to encryptedValue in agui_messages_to_snapshot_format for input compatibility - Update normalize_agui_role docstring to include reasoning role - Add tests for incremental reasoning accumulation and key normalization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for microsoft#4843: Python: agent-framework-ag-ui: include reasoning messages in MESSAGES_SNAPSHOT --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dc27740 commit dd3d085

7 files changed

Lines changed: 303 additions & 5 deletions

File tree

python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,10 @@ def _build_messages_snapshot(
684684
}
685685
)
686686

687+
# Add reasoning messages so frontends that reconcile state from
688+
# MESSAGES_SNAPSHOT retain reasoning content after streaming ends.
689+
all_messages.extend(flow.reasoning_messages)
690+
687691
return MessagesSnapshotEvent(messages=all_messages) # type: ignore[arg-type]
688692

689693

@@ -1061,7 +1065,9 @@ async def run_agent_stream(
10611065

10621066
# Emit MessagesSnapshotEvent if we have tool calls or results
10631067
# Feature #5: Suppress intermediate snapshots for predictive tools without confirmation
1064-
should_emit_snapshot = flow.pending_tool_calls or flow.tool_results or flow.accumulated_text
1068+
should_emit_snapshot = (
1069+
flow.pending_tool_calls or flow.tool_results or flow.accumulated_text or flow.reasoning_messages
1070+
)
10651071
if should_emit_snapshot:
10661072
# Check if we should suppress for predictive tool
10671073
last_tool_name = None

python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,10 @@ def _filter_modified_args(
604604
# Handle standard tool result messages early (role="tool") to preserve provider invariants
605605
# This path maps AG‑UI tool messages to function_result content with the correct tool_call_id
606606
role_str = normalize_agui_role(msg.get("role", "user"))
607+
if role_str == "reasoning":
608+
# Reasoning messages are UI-only state carried in MESSAGES_SNAPSHOT.
609+
# They should not be forwarded to the LLM provider.
610+
continue
607611
if role_str == "tool":
608612
# Prefer explicit tool_call_id fields; fall back to backend fields only if necessary
609613
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
@@ -1020,6 +1024,11 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
10201024
elif "toolCallId" not in normalized_msg:
10211025
normalized_msg["toolCallId"] = ""
10221026

1027+
# Normalize encrypted_value to encryptedValue for reasoning messages
1028+
if normalized_msg.get("role") == "reasoning" and "encrypted_value" in normalized_msg:
1029+
normalized_msg["encryptedValue"] = normalized_msg["encrypted_value"]
1030+
del normalized_msg["encrypted_value"]
1031+
10231032
result.append(normalized_msg)
10241033

10251034
return result

python/packages/ag-ui/agent_framework_ag_ui/_run_common.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ class FlowState:
126126
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
127127
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
128128
interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
129+
reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
130+
accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
129131

130132
def get_tool_name(self, call_id: str | None) -> str | None:
131133
"""Get tool name by call ID."""
@@ -460,7 +462,7 @@ def _emit_mcp_tool_result(
460462
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
461463

462464

463-
def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
465+
def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]:
464466
"""Emit AG-UI reasoning events for text_reasoning content.
465467
466468
Uses the protocol-defined reasoning event types so that AG-UI consumers
@@ -470,6 +472,10 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
470472
``content.protected_data`` is present it is emitted as a
471473
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
472474
reasoning for state continuity without conflating it with display text.
475+
476+
When *flow* is provided the reasoning message is persisted into
477+
``flow.reasoning_messages`` so that ``_build_messages_snapshot`` can
478+
include it in the final ``MESSAGES_SNAPSHOT``.
473479
"""
474480
text = content.text or ""
475481
if not text and content.protected_data is None:
@@ -498,6 +504,36 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
498504

499505
events.append(ReasoningEndEvent(message_id=message_id))
500506

507+
# Persist reasoning into flow state for MESSAGES_SNAPSHOT.
508+
# Accumulate reasoning text per message_id, similar to flow.accumulated_text,
509+
# so that incremental deltas build the full reasoning string.
510+
if flow is not None:
511+
if text:
512+
previous_text = flow.accumulated_reasoning.get(message_id, "")
513+
flow.accumulated_reasoning[message_id] = previous_text + text
514+
full_text = flow.accumulated_reasoning.get(message_id, text or "")
515+
516+
# Update existing reasoning entry for this message_id if present; otherwise append a new one.
517+
existing_entry: dict[str, Any] | None = None
518+
for entry in flow.reasoning_messages:
519+
if isinstance(entry, dict) and entry.get("id") == message_id:
520+
existing_entry = entry
521+
break
522+
523+
if existing_entry is None:
524+
reasoning_entry: dict[str, Any] = {
525+
"id": message_id,
526+
"role": "reasoning",
527+
"content": full_text,
528+
}
529+
if content.protected_data is not None:
530+
reasoning_entry["encryptedValue"] = content.protected_data
531+
flow.reasoning_messages.append(reasoning_entry)
532+
else:
533+
existing_entry["content"] = full_text
534+
if content.protected_data is not None:
535+
existing_entry["encryptedValue"] = content.protected_data
536+
501537
return events
502538

503539

@@ -527,6 +563,6 @@ def _emit_content(
527563
if content_type == "mcp_server_tool_result":
528564
return _emit_mcp_tool_result(content, flow, predictive_handler)
529565
if content_type == "text_reasoning":
530-
return _emit_text_reasoning(content)
566+
return _emit_text_reasoning(content, flow)
531567
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
532568
return []

python/packages/ag-ui/agent_framework_ag_ui/_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"system": "system",
2828
}
2929

30-
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"}
30+
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"}
3131

3232

3333
def generate_event_id() -> str:
@@ -82,7 +82,7 @@ def normalize_agui_role(raw_role: Any) -> str:
8282
raw_role: Raw role value from AG-UI message
8383
8484
Returns:
85-
Normalized role string (user, assistant, system, or tool)
85+
Normalized role string (user, assistant, system, tool, or reasoning)
8686
"""
8787
if not isinstance(raw_role, str):
8888
return "user"

python/packages/ag-ui/tests/ag_ui/test_message_adapters.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,3 +1669,94 @@ def test_agui_fresh_approval_is_still_processed():
16691669
assert len(approval_contents) == 1, "Fresh approval should produce function_approval_response"
16701670
assert approval_contents[0].approved is True
16711671
assert approval_contents[0].function_call.name == "get_datetime"
1672+
1673+
1674+
class TestReasoningRoundTrip:
1675+
"""Tests for reasoning message handling in inbound/outbound adapters."""
1676+
1677+
def test_reasoning_skipped_on_inbound(self):
1678+
"""Reasoning messages from prior snapshot are not forwarded to the LLM."""
1679+
messages_input = [
1680+
{"id": "u1", "role": "user", "content": "Hello"},
1681+
{"id": "r1", "role": "reasoning", "content": "Thinking..."},
1682+
{"id": "a1", "role": "assistant", "content": "Hi there"},
1683+
]
1684+
1685+
result = agui_messages_to_agent_framework(messages_input)
1686+
1687+
roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result]
1688+
assert "reasoning" not in roles
1689+
assert len(result) == 2
1690+
1691+
def test_reasoning_preserved_in_snapshot_format(self):
1692+
"""Reasoning messages retain their role through snapshot normalization."""
1693+
messages_input = [
1694+
{"id": "u1", "role": "user", "content": "Hello"},
1695+
{"id": "r1", "role": "reasoning", "content": "Thinking about this..."},
1696+
{"id": "a1", "role": "assistant", "content": "Answer"},
1697+
]
1698+
1699+
result = agui_messages_to_snapshot_format(messages_input)
1700+
1701+
reasoning_msgs = [m for m in result if m.get("role") == "reasoning"]
1702+
assert len(reasoning_msgs) == 1
1703+
assert reasoning_msgs[0]["content"] == "Thinking about this..."
1704+
1705+
def test_reasoning_with_encrypted_value_in_snapshot_format(self):
1706+
"""Reasoning with encryptedValue passes through snapshot normalization."""
1707+
messages_input = [
1708+
{
1709+
"id": "r1",
1710+
"role": "reasoning",
1711+
"content": "visible",
1712+
"encryptedValue": "secret-data",
1713+
},
1714+
]
1715+
1716+
result = agui_messages_to_snapshot_format(messages_input)
1717+
1718+
assert len(result) == 1
1719+
assert result[0]["role"] == "reasoning"
1720+
assert result[0]["encryptedValue"] == "secret-data"
1721+
1722+
def test_reasoning_encrypted_value_snake_case_normalized(self):
1723+
"""Snake-case encrypted_value is normalized to encryptedValue in snapshot format."""
1724+
messages_input = [
1725+
{
1726+
"id": "r1",
1727+
"role": "reasoning",
1728+
"content": "visible",
1729+
"encrypted_value": "snake-case-data",
1730+
},
1731+
]
1732+
1733+
result = agui_messages_to_snapshot_format(messages_input)
1734+
1735+
assert len(result) == 1
1736+
assert result[0]["encryptedValue"] == "snake-case-data"
1737+
assert "encrypted_value" not in result[0]
1738+
1739+
def test_multi_turn_with_reasoning_in_prior_snapshot(self):
1740+
"""Second turn with reasoning from prior snapshot does not corrupt messages."""
1741+
messages_input = [
1742+
{"id": "u1", "role": "user", "content": "First question"},
1743+
{"id": "r1", "role": "reasoning", "content": "Prior reasoning"},
1744+
{"id": "a1", "role": "assistant", "content": "First answer"},
1745+
{"id": "u2", "role": "user", "content": "Follow-up question"},
1746+
]
1747+
1748+
result = agui_messages_to_agent_framework(messages_input)
1749+
1750+
roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result]
1751+
# Reasoning is filtered out, other messages preserved in order
1752+
assert roles == ["user", "assistant", "user"]
1753+
# Content not corrupted
1754+
texts = []
1755+
for m in result:
1756+
for c in m.contents or []:
1757+
if hasattr(c, "text") and c.text:
1758+
texts.append(c.text)
1759+
assert "First question" in texts
1760+
assert "First answer" in texts
1761+
assert "Follow-up question" in texts
1762+
assert "Prior reasoning" not in texts

python/packages/ag-ui/tests/ag_ui/test_run.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,3 +1346,158 @@ def test_routes_text_reasoning(self):
13461346

13471347
assert len(events) == 5
13481348
assert isinstance(events[0], ReasoningStartEvent)
1349+
1350+
1351+
class TestReasoningInSnapshot:
1352+
"""Tests for reasoning message inclusion in MESSAGES_SNAPSHOT."""
1353+
1354+
def test_reasoning_persisted_to_flow_state(self):
1355+
"""_emit_text_reasoning with flow persists reasoning into flow.reasoning_messages."""
1356+
flow = FlowState()
1357+
content = Content.from_text_reasoning(
1358+
id="reason_persist",
1359+
text="Let me think step by step.",
1360+
)
1361+
1362+
_emit_text_reasoning(content, flow)
1363+
1364+
assert len(flow.reasoning_messages) == 1
1365+
assert flow.reasoning_messages[0]["id"] == "reason_persist"
1366+
assert flow.reasoning_messages[0]["role"] == "reasoning"
1367+
assert flow.reasoning_messages[0]["content"] == "Let me think step by step."
1368+
assert "encryptedValue" not in flow.reasoning_messages[0]
1369+
1370+
def test_reasoning_with_encrypted_value_persisted(self):
1371+
"""Reasoning with protected_data preserves encryptedValue in flow state."""
1372+
flow = FlowState()
1373+
content = Content.from_text_reasoning(
1374+
id="reason_enc",
1375+
text="visible reasoning",
1376+
protected_data="encrypted-data-123",
1377+
)
1378+
1379+
_emit_text_reasoning(content, flow)
1380+
1381+
assert len(flow.reasoning_messages) == 1
1382+
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-data-123"
1383+
1384+
def test_snapshot_includes_reasoning(self):
1385+
"""_build_messages_snapshot includes reasoning messages from flow state."""
1386+
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
1387+
1388+
flow = FlowState()
1389+
flow.accumulated_text = "Here is my answer."
1390+
flow.reasoning_messages = [
1391+
{"id": "r1", "role": "reasoning", "content": "Thinking..."},
1392+
]
1393+
1394+
snapshot = _build_messages_snapshot(flow, [])
1395+
1396+
roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages]
1397+
assert "reasoning" in roles
1398+
1399+
def test_snapshot_preserves_reasoning_encrypted_value(self):
1400+
"""Snapshot reasoning with encryptedValue is preserved end-to-end."""
1401+
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
1402+
1403+
flow = FlowState()
1404+
content = Content.from_text_reasoning(
1405+
id="reason_e2e",
1406+
text="visible",
1407+
protected_data="secret-data",
1408+
)
1409+
_emit_text_reasoning(content, flow)
1410+
1411+
text_content = Content.from_text("Final answer.")
1412+
_emit_text(text_content, flow)
1413+
1414+
snapshot = _build_messages_snapshot(flow, [])
1415+
1416+
reasoning_msgs = [
1417+
m
1418+
for m in snapshot.messages
1419+
if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "reasoning"
1420+
]
1421+
assert len(reasoning_msgs) == 1
1422+
msg = reasoning_msgs[0]
1423+
if isinstance(msg, dict):
1424+
assert msg["content"] == "visible"
1425+
assert msg["encryptedValue"] == "secret-data"
1426+
1427+
def test_emit_content_routes_reasoning_with_flow(self):
1428+
"""_emit_content passes flow to _emit_text_reasoning for persistence."""
1429+
flow = FlowState()
1430+
content = Content.from_text_reasoning(text="routed reasoning")
1431+
1432+
_emit_content(content, flow)
1433+
1434+
assert len(flow.reasoning_messages) == 1
1435+
assert flow.reasoning_messages[0]["content"] == "routed reasoning"
1436+
1437+
def test_reasoning_without_flow_does_not_error(self):
1438+
"""Calling _emit_text_reasoning without flow still works (backward compat)."""
1439+
content = Content.from_text_reasoning(text="no flow")
1440+
1441+
events = _emit_text_reasoning(content)
1442+
1443+
assert len(events) == 5
1444+
assert isinstance(events[0], ReasoningStartEvent)
1445+
1446+
def test_snapshot_reasoning_ordering(self):
1447+
"""Reasoning messages appear after assistant text in snapshot."""
1448+
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
1449+
1450+
flow = FlowState()
1451+
reasoning_content = Content.from_text_reasoning(id="r1", text="Thinking...")
1452+
_emit_text_reasoning(reasoning_content, flow)
1453+
1454+
text_content = Content.from_text("Answer")
1455+
_emit_text(text_content, flow)
1456+
1457+
snapshot = _build_messages_snapshot(flow, [{"id": "u1", "role": "user", "content": "Hi"}])
1458+
1459+
# user -> assistant text -> reasoning
1460+
assert len(snapshot.messages) == 3
1461+
roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages]
1462+
assert roles == ["user", "assistant", "reasoning"]
1463+
1464+
def test_reasoning_accumulates_incremental_deltas(self):
1465+
"""Multiple reasoning deltas with the same id accumulate into one entry."""
1466+
flow = FlowState()
1467+
content1 = Content.from_text_reasoning(id="reason_inc", text="First ")
1468+
content2 = Content.from_text_reasoning(id="reason_inc", text="second ")
1469+
content3 = Content.from_text_reasoning(id="reason_inc", text="third.")
1470+
1471+
_emit_text_reasoning(content1, flow)
1472+
_emit_text_reasoning(content2, flow)
1473+
_emit_text_reasoning(content3, flow)
1474+
1475+
assert len(flow.reasoning_messages) == 1
1476+
assert flow.reasoning_messages[0]["id"] == "reason_inc"
1477+
assert flow.reasoning_messages[0]["content"] == "First second third."
1478+
1479+
def test_reasoning_accumulates_distinct_message_ids(self):
1480+
"""Reasoning entries with different ids are stored separately."""
1481+
flow = FlowState()
1482+
content_a = Content.from_text_reasoning(id="a", text="alpha")
1483+
content_b = Content.from_text_reasoning(id="b", text="beta")
1484+
1485+
_emit_text_reasoning(content_a, flow)
1486+
_emit_text_reasoning(content_b, flow)
1487+
1488+
assert len(flow.reasoning_messages) == 2
1489+
assert flow.reasoning_messages[0]["content"] == "alpha"
1490+
assert flow.reasoning_messages[1]["content"] == "beta"
1491+
1492+
def test_reasoning_encrypted_value_updated_on_later_delta(self):
1493+
"""encryptedValue is set even when it arrives with a later delta."""
1494+
flow = FlowState()
1495+
content1 = Content.from_text_reasoning(id="enc_late", text="part1 ")
1496+
content2 = Content.from_text_reasoning(id="enc_late", text="part2", protected_data="encrypted-payload")
1497+
1498+
_emit_text_reasoning(content1, flow)
1499+
_emit_text_reasoning(content2, flow)
1500+
1501+
assert len(flow.reasoning_messages) == 1
1502+
assert flow.reasoning_messages[0]["content"] == "part1 part2"
1503+
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload"

python/packages/ag-ui/tests/ag_ui/test_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ def test_normalize_agui_role_valid():
450450
assert normalize_agui_role("assistant") == "assistant"
451451
assert normalize_agui_role("system") == "system"
452452
assert normalize_agui_role("tool") == "tool"
453+
assert normalize_agui_role("reasoning") == "reasoning"
453454

454455

455456
def test_normalize_agui_role_invalid():

0 commit comments

Comments
 (0)