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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,5 @@ dotnet/filtered-*.slnx
# Local tool state
.omc/
.omx/

**/issues/
24 changes: 24 additions & 0 deletions python/packages/ag-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@
_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,
Expand Down Expand Up @@ -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",
)
)
Expand Down
55 changes: 44 additions & 11 deletions python/packages/ag-ui/agent_framework_ag_ui/_run_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@
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)."""
Expand Down Expand Up @@ -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``.

Expand All @@ -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)
Expand All @@ -269,13 +281,29 @@ 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,
flow: FlowState,
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.

Expand All @@ -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",
)
)
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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,
)


Expand Down
83 changes: 68 additions & 15 deletions python/packages/ag-ui/agent_framework_ag_ui/_state.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,62 @@
# 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.
"""

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."""
Comment thread
moonbox3 marked this conversation as resolved.

_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
Expand All @@ -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


Expand All @@ -61,24 +77,61 @@ async def get_weather(city: str) -> Content:
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,
)
Loading
Loading