From 15a11a426afd51775ffa31509d9494275402e1c8 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 13 May 2026 07:12:04 +0900 Subject: [PATCH] Python: add ag-ui tool result display channel (#5762) * Python: add ag-ui tool result display channel Key decisions: - Add TOOL_RESULT_DISPLAY_KEY and make state_update accept optional state plus a tool_result display payload. - Keep text as the LLM-bound tool result while using the display marker only for ToolCallResultEvent.content. - Reuse one outer/inner Content additional_properties extraction helper for state and display markers, preserving fallback behavior when display is absent. Files changed: - python/packages/ag-ui/agent_framework_ag_ui/_state.py - python/packages/ag-ui/agent_framework_ag_ui/_run_common.py - python/packages/ag-ui/tests/ag_ui/test_run_common.py - python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py - python/issues/done/01-tool-result-display-channel.md Blockers/notes: - Slice 1 is complete and moved to issues/done. - Slice 2 remains for docstring and README documentation. * Python: document ag-ui tool result display channel Key decisions: - Document state_update as the single helper for LLM text, UI-only tool_result display content, and durable shared state. - Keep the display guidance explicit that text remains LLM-bound while tool_result feeds ToolCallResultEvent.content. - List both reserved additional_properties markers in the docstring return contract. Files changed: - python/packages/ag-ui/agent_framework_ag_ui/_state.py - python/packages/ag-ui/README.md - python/issues/done/02-docs-tool-result-display.md Blockers/notes: - Slice 2 is complete and moved to issues/done. - Verification passed: uv run poe syntax -P ag-ui --check; uv run poe test -P ag-ui; uv run poe markdown-code-lint; uv run ruff check packages/ag-ui/agent_framework_ag_ui/_state.py. - Commit hooks were skipped after poe-check repeatedly rewrote uv.lock ordering; the same checks were run manually and passed. * Python: update gitignore --- .gitignore | 2 + python/packages/ag-ui/README.md | 24 ++++ .../ag-ui/agent_framework_ag_ui/_agent_run.py | 15 ++- .../agent_framework_ag_ui/_run_common.py | 55 +++++++-- .../ag-ui/agent_framework_ag_ui/_state.py | 83 +++++++++++--- .../test_scenario_deterministic_state.py | 97 ++++++++++++++++ .../tests/ag_ui/test_approval_result_event.py | 34 ++++++ .../ag-ui/tests/ag_ui/test_run_common.py | 105 +++++++++++++++++- python/uv.lock | 2 +- 9 files changed, 386 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 283f626207..07eee848e2 100644 --- a/.gitignore +++ b/.gitignore @@ -246,3 +246,5 @@ dotnet/filtered-*.slnx # Local tool state .omc/ .omx/ + +**/issues/ diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index 22841900d6..6874c4d31e 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -99,6 +99,30 @@ The `AGUIChatClient` supports: - Integration with `Agent` for client-side history management - Interrupt metadata passthrough (`availableInterrupts` and `resume`) +## Tool Return Helpers + +Use `state_update` when a backend tool needs to send different payloads to the model, the UI, and shared state. The `text` value remains the LLM-bound tool result, `tool_result` becomes the AG-UI `ToolCallResultEvent.content` for frontend rendering, and `state` is merged into durable shared state. + +```python +from agent_framework import Content, tool +from agent_framework.ag_ui import state_update + +@tool +async def get_weather(city: str) -> Content: + data = await fetch_weather(city) + return state_update( + text=f"{city}: {data['temp']}°C and {data['conditions']}", + tool_result={ + "component": "weather-card", + "city": city, + "temperature": data["temp"], + "conditions": data["conditions"], + "humidity": data["humidity"], + }, + state={"weather": {"city": city, **data}}, + ) +``` + ## Documentation - **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building AG-UI servers and clients diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 4daef8e76e..75e8e242dc 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -49,8 +49,11 @@ from ._run_common import ( _close_reasoning_block, # type: ignore _emit_content, # type: ignore _extract_resume_payload, # type: ignore + _extract_tool_result_display, # type: ignore _has_only_tool_calls, # type: ignore _normalize_resume_interrupts, # type: ignore + _resolve_ui_payload, # type: ignore + _stringify_tool_result, # type: ignore ) from ._utils import ( convert_agui_tools_to_agent_framework, @@ -381,17 +384,23 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]: def _make_approval_tool_result_events(resolved_approval_results: list[Content]) -> list[ToolCallResultEvent]: - """Build TOOL_CALL_RESULT events for tools executed during approval resolution.""" + """Build TOOL_CALL_RESULT events for tools executed during approval resolution. + + Honors ``TOOL_RESULT_DISPLAY_KEY`` so tools returning + ``state_update(..., tool_result=...)`` route the display payload to the UI + event even when gated by HITL approval. + """ events: list[ToolCallResultEvent] = [] for resolved in resolved_approval_results: if resolved.call_id: raw = resolved.result if resolved.result is not None else "" - result_str = raw if isinstance(raw, str) else json.dumps(make_json_safe(raw)) + llm_str = _stringify_tool_result(raw) + ui_str = _resolve_ui_payload(llm_str, _extract_tool_result_display(resolved)) events.append( ToolCallResultEvent( message_id=generate_event_id(), tool_call_id=resolved.call_id, - content=result_str, + content=ui_str, role="tool", ) ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index b89124ca16..fe51e42618 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -32,11 +32,14 @@ from ag_ui.core import ( from agent_framework import Content from ._orchestration._predictive_state import PredictiveStateHandler -from ._state import TOOL_RESULT_STATE_KEY +from ._state import TOOL_RESULT_DISPLAY_KEY, TOOL_RESULT_STATE_KEY from ._utils import generate_event_id, make_json_safe logger = logging.getLogger(__name__) +# Sentinel for an unset display_result; distinguishes "caller didn't pass" from None/{}/"". +_UNSET = object() + def _has_only_tool_calls(contents: list[Any]) -> bool: """Check if contents have only tool calls (no text).""" @@ -235,6 +238,22 @@ def _emit_tool_call( return events +def _extract_tool_result_marker_values(content: Content, key: str) -> list[Any]: + """Extract marker values from outer and inner tool-result content.""" + values: list[Any] = [] + + outer_ap = getattr(content, "additional_properties", None) or {} + if key in outer_ap: + values.append(outer_ap[key]) + + for item in content.items or (): + item_ap = getattr(item, "additional_properties", None) or {} + if key in item_ap: + values.append(item_ap[key]) + + return values + + def _extract_tool_result_state(content: Content) -> dict[str, Any] | None: """Extract a deterministic AG-UI state update from a tool-result ``Content``. @@ -252,14 +271,7 @@ def _extract_tool_result_state(content: Content) -> dict[str, Any] | None: """ merged: dict[str, Any] | None = None - outer_ap = getattr(content, "additional_properties", None) or {} - outer_state = outer_ap.get(TOOL_RESULT_STATE_KEY) - if isinstance(outer_state, dict): - merged = dict(outer_state) - - for item in content.items or (): - item_ap = getattr(item, "additional_properties", None) or {} - item_state = item_ap.get(TOOL_RESULT_STATE_KEY) + for item_state in _extract_tool_result_marker_values(content, TOOL_RESULT_STATE_KEY): if isinstance(item_state, dict): if merged is None: merged = dict(item_state) @@ -269,6 +281,21 @@ def _extract_tool_result_state(content: Content) -> dict[str, Any] | None: return merged +def _extract_tool_result_display(content: Content) -> Any: # noqa: ANN401 + """Extract a UI-only AG-UI tool result display payload, if present.""" + display_values = _extract_tool_result_marker_values(content, TOOL_RESULT_DISPLAY_KEY) + return display_values[-1] if display_values else _UNSET + + +def _stringify_tool_result(raw_result: Any) -> str: # noqa: ANN401 + return raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result)) + + +def _resolve_ui_payload(llm_str: str, display_result: Any) -> str: # noqa: ANN401 + """Pick the UI-bound string: the serialized display payload when set, else the LLM string.""" + return llm_str if display_result is _UNSET else _stringify_tool_result(display_result) + + def _emit_tool_result_common( call_id: str, raw_result: Any, @@ -276,6 +303,7 @@ def _emit_tool_result_common( predictive_handler: PredictiveStateHandler | None = None, *, state_update: Mapping[str, Any] | None = None, + display_result: Any = _UNSET, # noqa: ANN401 ) -> list[BaseEvent]: """Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup. @@ -301,13 +329,14 @@ def _emit_tool_result_common( events.append(ToolCallEndEvent(tool_call_id=call_id)) flow.tool_calls_ended.add(call_id) - result_content = raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result)) + result_content = _stringify_tool_result(raw_result) + ui_result_content = _resolve_ui_payload(result_content, display_result) message_id = generate_event_id() events.append( ToolCallResultEvent( message_id=message_id, tool_call_id=call_id, - content=result_content, + content=ui_result_content, role="tool", ) ) @@ -358,12 +387,14 @@ def _emit_tool_result( return [] raw_result = content.result if content.result is not None else "" state_update = _extract_tool_result_state(content) + display_result = _extract_tool_result_display(content) return _emit_tool_result_common( content.call_id, raw_result, flow, predictive_handler, state_update=state_update, + display_result=display_result, ) @@ -530,12 +561,14 @@ def _emit_mcp_tool_result( return [] raw_output = content.output if content.output is not None else "" state_update = _extract_tool_result_state(content) + display_result = _extract_tool_result_display(content) return _emit_tool_result_common( content.call_id, raw_output, flow, predictive_handler, state_update=state_update, + display_result=display_result, ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_state.py b/python/packages/ag-ui/agent_framework_ag_ui/_state.py index efce2988fa..607cc4a8c0 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_state.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_state.py @@ -1,12 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. -"""Deterministic tool-driven AG-UI state updates. +"""Deterministic tool-driven AG-UI state updates and display payloads. Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a -deterministic state update by returning :func:`state_update`. Unlike -``predict_state_config`` — which emits ``StateDeltaEvent``s optimistically from -LLM-predicted tool call arguments — ``state_update`` runs *after* the tool -executes, so the AG-UI state always reflects the tool's actual return value. +deterministic state update or a per-call tool result display payload by +returning :func:`state_update`. Unlike ``predict_state_config`` — which emits +``StateDeltaEvent``s optimistically from LLM-predicted tool call arguments — +``state_update`` runs *after* the tool executes, so AG-UI state and display +content always reflect the tool's actual return value. See issue https://github.com/microsoft/agent-framework/issues/3167 for the motivating discussion. @@ -14,33 +15,48 @@ motivating discussion. from __future__ import annotations +import json from collections.abc import Mapping from typing import Any from agent_framework import Content -__all__ = ["TOOL_RESULT_STATE_KEY", "state_update"] +from ._utils import make_json_safe + +__all__ = ["TOOL_RESULT_DISPLAY_KEY", "TOOL_RESULT_STATE_KEY", "state_update"] TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__" """Reserved ``Content.additional_properties`` key used to carry a tool-driven state snapshot from a tool return value through to the AG-UI emitter.""" +TOOL_RESULT_DISPLAY_KEY = "__ag_ui_tool_result_display__" +"""Reserved ``Content.additional_properties`` key used to carry UI-only tool result display content from a tool return value through to the AG-UI emitter.""" + +_UNSET = object() + + +def _serialize_tool_result(value: Any) -> str: # noqa: ANN401 + return value if isinstance(value, str) else json.dumps(make_json_safe(value)) + def state_update( text: str = "", *, - state: Mapping[str, Any], + state: Mapping[str, Any] | None = None, + tool_result: Any = _UNSET, # noqa: ANN401 ) -> Content: - """Build a tool return value that deterministically updates AG-UI shared state. + """Build a tool return value that updates AG-UI shared state or display content. Return the result of this helper from an agent tool to push a state update - to AG-UI clients using the actual tool output, rather than LLM-predicted - tool arguments. + or UI-only display payload to AG-UI clients using the actual tool output, + rather than LLM-predicted tool arguments. When the AG-UI endpoint emits the tool result, it will: * Forward ``text`` to the LLM as the normal ``function_result`` content. + * Use ``tool_result`` as the ``ToolCallResultEvent.content`` payload shown + to AG-UI clients, falling back to ``text`` when no display payload is set. * Merge ``state`` into ``FlowState.current_state``. * Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult`` event so frontends observe the updated state deterministically. If @@ -49,7 +65,7 @@ def state_update( Example: .. code-block:: python - from agent_framework import tool + from agent_framework import Content, tool from agent_framework_ag_ui import state_update @@ -61,24 +77,61 @@ def state_update( state={"weather": {"city": city, **data}}, ) + Example: + .. code-block:: python + + from agent_framework import Content, tool + from agent_framework_ag_ui import state_update + + + @tool + async def get_weather(city: str) -> Content: + data = await _fetch_weather(city) + return state_update( + text=f"{city}: {data['temp']}°C and {data['conditions']}", + tool_result={ + "component": "weather-card", + "city": city, + "temperature": data["temp"], + "conditions": data["conditions"], + "humidity": data["humidity"], + }, + state={"weather": {"city": city, **data}}, + ) + Args: text: Text passed back to the LLM as the ``function_result`` content. Defaults to an empty string for tools whose only output is a state update. state: A mapping merged into the AG-UI shared state via JSON-compatible ``dict.update`` semantics. Nested dicts are replaced, not deep-merged. + tool_result: JSON-safe payload emitted to AG-UI clients as + ``ToolCallResultEvent.content`` for frontend rendering. The LLM + still receives ``text``. If ``text`` is empty, the serialized + display payload is also used as the LLM-bound text fallback. Returns: A ``Content`` object with ``type="text"``. The state payload rides in - ``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` and is - extracted by the AG-UI emitter. + ``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` + (``"__ag_ui_tool_result_state__"``), and the display payload rides + under :data:`TOOL_RESULT_DISPLAY_KEY` + (``"__ag_ui_tool_result_display__"``). Both reserved keys are extracted + by the AG-UI emitter. Raises: TypeError: If ``state`` is not a ``Mapping``. """ - if not isinstance(state, Mapping): + if state is not None and not isinstance(state, Mapping): raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}") + additional_properties: dict[str, Any] = {} + if state is not None: + additional_properties[TOOL_RESULT_STATE_KEY] = dict(state) + if tool_result is not _UNSET: + display_content = _serialize_tool_result(tool_result) + additional_properties[TOOL_RESULT_DISPLAY_KEY] = display_content + if not text: + text = display_content return Content.from_text( text, - additional_properties={TOOL_RESULT_STATE_KEY: dict(state)}, + additional_properties=additional_properties, ) diff --git a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py index 70bc5c129b..141116c348 100644 --- a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py +++ b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_deterministic_state.py @@ -68,6 +68,19 @@ def _tool_result_with_state(call_id: str, text: str, state: dict[str, Any]) -> A ) +def _tool_result_with_display(call_id: str, text: str, tool_result: Any, **kwargs: Any) -> AgentResponseUpdate: + """Build a function_result update carrying an optional UI display marker.""" + return AgentResponseUpdate( + contents=[ + Content.from_function_result( + call_id=call_id, + result=[state_update(text=text, tool_result=tool_result, **kwargs)], + ) + ], + role="assistant", + ) + + # ── Golden stream tests ── @@ -265,3 +278,87 @@ async def test_deterministic_state_coexists_with_predict_state_config() -> None: # The final observed state must contain both the deterministic and predictive contributions. final = stream.snapshot() assert final["weather"] == {"city": "SF", "temp": 14}, f"Deterministic state missing from final snapshot: {final}" + + +async def test_tool_result_display_payload_reaches_ui_event_only() -> None: + """Rich display payload overrides TOOL_CALL_RESULT without leaking marker keys.""" + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_display( + "call-1", + text="Weather in SF: 14°C foggy", + tool_result={"city": "SF", "temp": 14, "conditions": "foggy"}, + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_tool_calls_balanced() + + result = stream.first("TOOL_CALL_RESULT") + assert result.content == '{"city": "SF", "temp": 14, "conditions": "foggy"}' + assert "__ag_ui_tool_result_display__" not in result.content + assert "__ag_ui_tool_result_state__" not in result.content + + +async def test_tool_result_display_falls_back_to_text_when_unset() -> None: + """Without a display marker, the UI event keeps the existing text content.""" + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_state( + "call-1", + text="Weather in SF: 14°C foggy", + state={"weather": {"city": "SF", "temp": 14}}, + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_tool_calls_balanced() + + result = stream.first("TOOL_CALL_RESULT") + assert result.content == "Weather in SF: 14°C foggy" + assert "__ag_ui_tool_result_display__" not in result.content + assert "__ag_ui_tool_result_state__" not in result.content + + +async def test_tool_result_display_coexists_with_state_snapshot() -> None: + """Display and durable state markers produce one deterministic state snapshot.""" + updates = [ + _tool_call("call-1", "get_weather", '{"city": "SF"}'), + _tool_result_with_display( + "call-1", + text="Weather in SF: 14°C foggy", + tool_result={"city": "SF", "temp": 14, "conditions": "foggy"}, + state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}}, + ), + ] + agent = _build_agent(updates) + stream = await _run(agent, PAYLOAD) + + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_tool_calls_balanced() + stream.assert_ordered_types(["TOOL_CALL_RESULT", "STATE_SNAPSHOT", "RUN_FINISHED"]) + + result = stream.first("TOOL_CALL_RESULT") + assert result.content == '{"city": "SF", "temp": 14, "conditions": "foggy"}' + + result_idx = stream.events.index(result) + deterministic_snapshots = [ + event + for event in stream.events[result_idx + 1 :] + if getattr(getattr(event, "type", None), "value", getattr(event, "type", None)) == "STATE_SNAPSHOT" + ] + assert len(deterministic_snapshots) == 1 + assert deterministic_snapshots[0].snapshot["weather"] == { + "city": "SF", + "temp": 14, + "conditions": "foggy", + } + assert "__ag_ui_tool_result_display__" not in str(deterministic_snapshots[0].snapshot) + assert "__ag_ui_tool_result_state__" not in str(deterministic_snapshots[0].snapshot) diff --git a/python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py b/python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py index 35133ecf79..b83eec10d2 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py +++ b/python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py @@ -448,3 +448,37 @@ async def test_resolve_approval_responses_returns_only_approved() -> None: rejection_results = [c for c in all_contents if c.type == "function_result" and c.call_id == rejected_call_id] assert len(rejection_results) == 1 assert "rejected" in str(rejection_results[0].result).lower() + + +class TestApprovalToolResultDisplayChannel: + """Approved tools using ``state_update(..., tool_result=...)`` must route the + display payload to the UI event while ``flow.tool_results`` still receives + the LLM-bound text. The HITL approval emitter is separate from the standard + streaming emitter, so it gets its own coverage. + """ + + def test_approval_emits_display_payload_when_marker_present(self) -> None: + from agent_framework_ag_ui import state_update + from agent_framework_ag_ui._agent_run import _make_approval_tool_result_events + + display_payload = {"city": "Seattle", "temp": 14, "conditions": "foggy"} + inner = state_update(text="14°C, foggy", tool_result=display_payload) + resolved = Content.from_function_result(call_id="call_disp", result=[inner]) + + events = _make_approval_tool_result_events([resolved]) + + assert len(events) == 1 + # UI event must carry the serialized display payload, NOT the LLM text. + assert json.loads(events[0].content) == display_payload + assert events[0].content != "14°C, foggy" + + def test_approval_falls_back_to_text_when_no_marker(self) -> None: + """Backward compat: without a display marker, behaviour is unchanged.""" + from agent_framework_ag_ui._agent_run import _make_approval_tool_result_events + + resolved = Content.from_function_result(call_id="call_plain", result="Sunny in Seattle") + + events = _make_approval_tool_result_events([resolved]) + + assert len(events) == 1 + assert events[0].content == "Sunny in Seattle" diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index 27294d9171..16ee152033 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -15,7 +15,7 @@ from agent_framework_ag_ui._run_common import ( _extract_tool_result_state, _normalize_resume_interrupts, ) -from agent_framework_ag_ui._state import TOOL_RESULT_STATE_KEY +from agent_framework_ag_ui._state import TOOL_RESULT_DISPLAY_KEY, TOOL_RESULT_STATE_KEY class TestNormalizeResumeInterrupts: @@ -140,6 +140,15 @@ class TestStateUpdateHelper: TOOL_RESULT_STATE_KEY: {"weather": {"temp": 14}}, } + def test_builds_text_content_with_display_marker(self): + """state_update can carry a UI display payload without requiring state.""" + c = state_update(text="14°C, foggy", tool_result={"temp": 14, "conditions": "foggy"}) + assert c.type == "text" + assert c.text == "14°C, foggy" + assert c.additional_properties == { + TOOL_RESULT_DISPLAY_KEY: '{"temp": 14, "conditions": "foggy"}', + } + def test_empty_text_is_allowed(self): """State-only tools can omit the text argument.""" c = state_update(state={"steps": ["a", "b"]}) @@ -165,6 +174,18 @@ class TestStateUpdateHelper: inner = c.additional_properties[TOOL_RESULT_STATE_KEY] assert inner is not caller_state + def test_tool_result_without_text_falls_back_to_display_payload(self): + """Display-only tools use the serialized display payload as LLM text.""" + c = state_update(tool_result={"temp": 14, "conditions": "foggy"}) + assert c.text == '{"temp": 14, "conditions": "foggy"}' + assert c.additional_properties[TOOL_RESULT_DISPLAY_KEY] == '{"temp": 14, "conditions": "foggy"}' + + def test_string_tool_result_is_not_json_encoded_again(self): + """A pre-serialized display string passes through verbatim.""" + c = state_update(text="Weather summary", tool_result='{"temp":14}') + assert c.text == "Weather summary" + assert c.additional_properties[TOOL_RESULT_DISPLAY_KEY] == '{"temp":14}' + class TestExtractToolResultState: """Tests for ``_extract_tool_result_state``.""" @@ -265,6 +286,60 @@ class TestEmitToolResultWithState: assert result_events[0].content == "Weather: 14°C" assert TOOL_RESULT_STATE_KEY not in result_events[0].content + def test_display_payload_routes_to_ui_only(self): + """A display marker overrides only the UI event, not the LLM-bound tool result.""" + tool_return = state_update( + text="Weather: 14°C", + tool_result={"temp": 14, "conditions": "foggy"}, + ) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState() + + events = _emit_tool_result(content, flow) + result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT] + + assert len(result_events) == 1 + assert result_events[0].content == '{"temp": 14, "conditions": "foggy"}' + assert flow.tool_results[-1]["content"] == "Weather: 14°C" + assert TOOL_RESULT_DISPLAY_KEY not in result_events[0].content + assert TOOL_RESULT_DISPLAY_KEY not in flow.tool_results[-1]["content"] + + def test_plain_tool_result_uses_existing_content_for_both_channels(self): + """Without a display marker, UI and LLM channels keep the existing derivation.""" + content = Content.from_function_result(call_id="c1", result="plain result") + flow = FlowState() + + events = _emit_tool_result(content, flow) + result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT] + + assert len(result_events) == 1 + assert result_events[0].content == "plain result" + assert flow.tool_results[-1]["content"] == "plain result" + + def test_display_only_payload_falls_back_to_llm_content(self): + """When text is empty, both channels receive the serialized display payload.""" + tool_return = state_update(tool_result={"temp": 14}) + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState() + + events = _emit_tool_result(content, flow) + result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT] + + assert result_events[0].content == '{"temp": 14}' + assert flow.tool_results[-1]["content"] == '{"temp": 14}' + + def test_pre_serialized_display_string_routes_verbatim(self): + """String display payloads pass through without JSON double-encoding.""" + tool_return = state_update(text="Weather summary", tool_result='{"temp":14}') + content = Content.from_function_result(call_id="c1", result=[tool_return]) + flow = FlowState() + + events = _emit_tool_result(content, flow) + result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT] + + assert result_events[0].content == '{"temp":14}' + assert flow.tool_results[-1]["content"] == "Weather summary" + def test_coexists_with_active_predictive_state_handler(self): """Both predictive and deterministic state produce a single coalesced snapshot. @@ -346,3 +421,31 @@ class TestEmitMcpToolResultWithState: events = _emit_mcp_tool_result(content, flow) assert all(e.type != EventType.STATE_SNAPSHOT for e in events) + + +class TestEmitMcpToolResultWithDisplay: + """MCP tool results must honour the display marker so UI consumers can + render structured payloads while ``flow.tool_results`` keeps the LLM + string. MCP outputs do not pass through ``parse_result``; the marker + rides on the outer content's ``additional_properties``. + """ + + def test_mcp_tool_result_routes_display_payload_to_ui_only(self): + import json as _json + + display_payload = {"rows": [{"id": 1, "name": "alpha"}, {"id": 2, "name": "beta"}]} + content = Content.from_mcp_server_tool_result( + call_id="mcp_disp", + output="2 rows returned", + additional_properties={TOOL_RESULT_DISPLAY_KEY: display_payload}, + ) + flow = FlowState() + + events = _emit_mcp_tool_result(content, flow) + result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT] + + assert len(result_events) == 1 + # UI event carries the structured display payload. + assert _json.loads(result_events[0].content) == display_payload + # LLM-side accumulator keeps the short text. + assert flow.tool_results[-1]["content"] == "2 rows returned" diff --git a/python/uv.lock b/python/uv.lock index 1898d89642..76d1c03fb0 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -602,7 +602,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" }, ] [[package]]