Skip to content

Commit f183f88

Browse files
moonbox3CopilotCopilot
authored
Python: AG-UI deterministic state updates from tool results (#5201)
* AG-UI deterministic state updates from tool results * fix(ag-ui): address PR #5201 review comments 1. Add missing AGUIEventConverter, AGUIHttpService, __version__ to _IMPORTS in core ag_ui lazy-export list to match the .pyi stub. 2. Coalesce predictive and deterministic state snapshots into a single StateSnapshotEvent when both mechanisms are active on the same tool result, reducing redundant snapshot traffic. 3. Update state_update() docstring to clarify that a predictive snapshot may be emitted before the deterministic one when predict_state_config is active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3c31ac2 commit f183f88

11 files changed

Lines changed: 878 additions & 4 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ._endpoint import add_agent_framework_fastapi_endpoint
1010
from ._event_converters import AGUIEventConverter
1111
from ._http_service import AGUIHttpService
12+
from ._state import state_update
1213
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
1314
from ._workflow import AgentFrameworkWorkflow, WorkflowFactory
1415

@@ -34,5 +35,6 @@
3435
"PredictStateConfig",
3536
"RunMetadata",
3637
"DEFAULT_TAGS",
38+
"state_update",
3739
"__version__",
3840
]

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

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import json
88
import logging
9+
from collections.abc import Mapping
910
from dataclasses import dataclass, field
1011
from typing import Any, cast
1112

@@ -31,6 +32,7 @@
3132
from agent_framework import Content
3233

3334
from ._orchestration._predictive_state import PredictiveStateHandler
35+
from ._state import TOOL_RESULT_STATE_KEY
3436
from ._utils import generate_event_id, make_json_safe
3537

3638
logger = logging.getLogger(__name__)
@@ -233,16 +235,66 @@ def _emit_tool_call(
233235
return events
234236

235237

238+
def _extract_tool_result_state(content: Content) -> dict[str, Any] | None:
239+
"""Extract a deterministic AG-UI state update from a tool-result ``Content``.
240+
241+
Tools using :func:`agent_framework_ag_ui.state_update` carry the state
242+
payload in ``additional_properties[TOOL_RESULT_STATE_KEY]`` on the inner
243+
text item produced by ``parse_result``. We also check the outer
244+
function_result content's ``additional_properties`` for robustness.
245+
246+
If multiple items carry state, they are merged in order so later items
247+
override earlier ones (plain ``dict.update`` semantics).
248+
249+
Returns:
250+
The merged state dict to apply, or ``None`` if no state update is
251+
present.
252+
"""
253+
merged: dict[str, Any] | None = None
254+
255+
outer_ap = getattr(content, "additional_properties", None) or {}
256+
outer_state = outer_ap.get(TOOL_RESULT_STATE_KEY)
257+
if isinstance(outer_state, dict):
258+
merged = dict(outer_state)
259+
260+
for item in content.items or ():
261+
item_ap = getattr(item, "additional_properties", None) or {}
262+
item_state = item_ap.get(TOOL_RESULT_STATE_KEY)
263+
if isinstance(item_state, dict):
264+
if merged is None:
265+
merged = dict(item_state)
266+
else:
267+
merged.update(item_state)
268+
269+
return merged
270+
271+
236272
def _emit_tool_result_common(
237273
call_id: str,
238274
raw_result: Any,
239275
flow: FlowState,
240276
predictive_handler: PredictiveStateHandler | None = None,
277+
*,
278+
state_update: Mapping[str, Any] | None = None,
241279
) -> list[BaseEvent]:
242280
"""Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup.
243281
244282
Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result``
245283
(MCP server tool results) delegate to this function.
284+
285+
Args:
286+
call_id: Tool call identifier.
287+
raw_result: The stringified tool result content sent back to the LLM.
288+
flow: Current ``FlowState``.
289+
predictive_handler: Optional predictive state handler driven by
290+
``predict_state_config``.
291+
state_update: Optional deterministic state snapshot produced by a tool
292+
returning :func:`agent_framework_ag_ui.state_update`. When present,
293+
it is merged into ``flow.current_state`` and a ``StateSnapshotEvent``
294+
is emitted after the ``ToolCallResult`` event. When both
295+
``predictive_handler`` and ``state_update`` are active, predictive
296+
updates are applied first, then the deterministic merge, and a
297+
single coalesced ``StateSnapshotEvent`` is emitted.
246298
"""
247299
events: list[BaseEvent] = []
248300

@@ -271,8 +323,18 @@ def _emit_tool_result_common(
271323

272324
if predictive_handler:
273325
predictive_handler.apply_pending_updates()
274-
if flow.current_state:
275-
events.append(StateSnapshotEvent(snapshot=flow.current_state))
326+
327+
if state_update:
328+
flow.current_state.update(state_update)
329+
logger.debug(
330+
"Emitted deterministic tool-result StateSnapshotEvent for call_id=%s (keys=%s)",
331+
call_id,
332+
list(state_update.keys()),
333+
)
334+
335+
# Emit a single coalesced snapshot when either mechanism updated state.
336+
if (predictive_handler or state_update) and flow.current_state:
337+
events.append(StateSnapshotEvent(snapshot=flow.current_state))
276338

277339
flow.tool_call_id = None
278340
flow.tool_call_name = None
@@ -295,7 +357,14 @@ def _emit_tool_result(
295357
if not content.call_id:
296358
return []
297359
raw_result = content.result if content.result is not None else ""
298-
return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler)
360+
state_update = _extract_tool_result_state(content)
361+
return _emit_tool_result_common(
362+
content.call_id,
363+
raw_result,
364+
flow,
365+
predictive_handler,
366+
state_update=state_update,
367+
)
299368

300369

301370
def _emit_approval_request(
@@ -460,7 +529,14 @@ def _emit_mcp_tool_result(
460529
logger.warning("MCP tool result content missing call_id, skipping")
461530
return []
462531
raw_output = content.output if content.output is not None else ""
463-
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
532+
state_update = _extract_tool_result_state(content)
533+
return _emit_tool_result_common(
534+
content.call_id,
535+
raw_output,
536+
flow,
537+
predictive_handler,
538+
state_update=state_update,
539+
)
464540

465541

466542
def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]:
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""Deterministic tool-driven AG-UI state updates.
4+
5+
Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a
6+
deterministic state update by returning :func:`state_update`. Unlike
7+
``predict_state_config`` — which emits ``StateDeltaEvent``s optimistically from
8+
LLM-predicted tool call arguments — ``state_update`` runs *after* the tool
9+
executes, so the AG-UI state always reflects the tool's actual return value.
10+
11+
See issue https://github.com/microsoft/agent-framework/issues/3167 for the
12+
motivating discussion.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from collections.abc import Mapping
18+
from typing import Any
19+
20+
from agent_framework import Content
21+
22+
__all__ = ["TOOL_RESULT_STATE_KEY", "state_update"]
23+
24+
25+
TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__"
26+
"""Reserved ``Content.additional_properties`` key used to carry a tool-driven
27+
state snapshot from a tool return value through to the AG-UI emitter."""
28+
29+
30+
def state_update(
31+
text: str = "",
32+
*,
33+
state: Mapping[str, Any],
34+
) -> Content:
35+
"""Build a tool return value that deterministically updates AG-UI shared state.
36+
37+
Return the result of this helper from an agent tool to push a state update
38+
to AG-UI clients using the actual tool output, rather than LLM-predicted
39+
tool arguments.
40+
41+
When the AG-UI endpoint emits the tool result, it will:
42+
43+
* Forward ``text`` to the LLM as the normal ``function_result`` content.
44+
* Merge ``state`` into ``FlowState.current_state``.
45+
* Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult``
46+
event so frontends observe the updated state deterministically. If
47+
predictive state is enabled, a predictive snapshot may be emitted first.
48+
49+
Example:
50+
.. code-block:: python
51+
52+
from agent_framework import tool
53+
from agent_framework_ag_ui import state_update
54+
55+
56+
@tool
57+
async def get_weather(city: str) -> Content:
58+
data = await _fetch_weather(city)
59+
return state_update(
60+
text=f"Weather in {city}: {data['temp']}°C {data['conditions']}",
61+
state={"weather": {"city": city, **data}},
62+
)
63+
64+
Args:
65+
text: Text passed back to the LLM as the ``function_result`` content.
66+
Defaults to an empty string for tools whose only output is a state
67+
update.
68+
state: A mapping merged into the AG-UI shared state via JSON-compatible
69+
``dict.update`` semantics. Nested dicts are replaced, not deep-merged.
70+
71+
Returns:
72+
A ``Content`` object with ``type="text"``. The state payload rides in
73+
``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` and is
74+
extracted by the AG-UI emitter.
75+
76+
Raises:
77+
TypeError: If ``state`` is not a ``Mapping``.
78+
"""
79+
if not isinstance(state, Mapping):
80+
raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}")
81+
return Content.from_text(
82+
text,
83+
additional_properties={TOOL_RESULT_STATE_KEY: dict(state)},
84+
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""Deterministic tool-driven AG-UI state example.
4+
5+
This sample demonstrates how a tool can push a *deterministic* state update
6+
to the AG-UI frontend based on its actual return value — in contrast to
7+
``predict_state_config`` which fires optimistically from LLM-predicted tool
8+
call arguments. See issue https://github.com/microsoft/agent-framework/issues/3167.
9+
10+
The :func:`agent_framework_ag_ui.state_update` helper wraps a text result
11+
together with a state snapshot. When a tool returns one of these, the AG-UI
12+
endpoint merges the snapshot into the shared state and emits a
13+
``StateSnapshotEvent`` after the tool result.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Any
19+
20+
from agent_framework import Agent, Content, SupportsChatGetResponse, tool
21+
from agent_framework.ag_ui import AgentFrameworkAgent
22+
23+
from agent_framework_ag_ui import state_update
24+
25+
# Simulated weather database — in the issue's motivating example the tool
26+
# would instead call a real weather API.
27+
_WEATHER_DB: dict[str, dict[str, Any]] = {
28+
"seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75},
29+
"san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85},
30+
"new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60},
31+
"miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90},
32+
"chicago": {"temperature": 9, "conditions": "windy", "humidity": 65},
33+
}
34+
35+
36+
@tool
37+
async def get_weather(location: str) -> Content:
38+
"""Fetch current weather for a location and push it into AG-UI shared state.
39+
40+
Unlike ``predict_state_config`` — which derives state optimistically from
41+
LLM-predicted tool call arguments — this tool uses ``state_update`` to
42+
forward the *actual* fetched weather to the frontend. The ``text`` goes
43+
back to the LLM as the normal tool result, and the ``state`` dict is merged
44+
into the AG-UI shared state.
45+
46+
Args:
47+
location: City name to look up.
48+
49+
Returns:
50+
A :class:`Content` carrying both the LLM-visible text result and a
51+
deterministic state snapshot.
52+
"""
53+
key = location.lower()
54+
data = _WEATHER_DB.get(
55+
key,
56+
{"temperature": 21, "conditions": "partly cloudy", "humidity": 50},
57+
)
58+
weather_record = {"location": location, **data}
59+
return state_update(
60+
text=(
61+
f"The weather in {location} is {data['conditions']} at "
62+
f"{data['temperature']}°C with {data['humidity']}% humidity."
63+
),
64+
state={"weather": weather_record},
65+
)
66+
67+
68+
def weather_state_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
69+
"""Create an AG-UI agent with a deterministic tool-driven state tool."""
70+
agent = Agent[Any](
71+
name="weather_state_agent",
72+
instructions=(
73+
"You are a weather assistant. When a user asks about the weather "
74+
"in a city, call the get_weather tool and use its output to give a "
75+
"friendly, concise reply. The tool also updates the shared UI state "
76+
"so the frontend can render a weather card from the `weather` key."
77+
),
78+
client=client,
79+
tools=[get_weather],
80+
)
81+
82+
return AgentFrameworkAgent(
83+
agent=agent,
84+
name="WeatherStateAgent",
85+
description="Weather agent that deterministically updates shared state from tool results.",
86+
state_schema={
87+
"weather": {
88+
"type": "object",
89+
"description": "Last fetched weather record",
90+
},
91+
},
92+
)

python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ..agents.task_steps_agent import task_steps_agent_wrapped
2525
from ..agents.ui_generator_agent import ui_generator_agent
2626
from ..agents.weather_agent import weather_agent
27+
from ..agents.weather_state_agent import weather_state_agent
2728

2829
AnthropicClient: type[Any] | None
2930
try:
@@ -141,6 +142,14 @@
141142
path="/subgraphs",
142143
)
143144

145+
# Deterministic Tool-Driven State - tool returns state_update() to push snapshot
146+
# from actual tool output (see issue #3167).
147+
add_agent_framework_fastapi_endpoint(
148+
app=app,
149+
agent=weather_state_agent(client),
150+
path="/deterministic_state",
151+
)
152+
144153

145154
def main():
146155
"""Run the server."""

0 commit comments

Comments
 (0)