mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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
This commit is contained in:
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user