Skip to content

Commit 1f0ffc1

Browse files
authored
Python: Fix ag-ui state handling issues (#2289)
* Fix ag-ui state handling * Bump package version and update changelog * Update changelog
1 parent d503717 commit 1f0ffc1

File tree

7 files changed

+169
-17
lines changed

7 files changed

+169
-17
lines changed

python/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.0b251117] - 2025-11-17
11+
12+
### Fixed
13+
14+
- **agent-framework-ag-ui**: Fix ag-ui state handling issues ([#2289](https://github.com/microsoft/agent-framework/pull/2289))
15+
1016
## [1.0.0b251114] - 2025-11-14
1117

1218
### Added

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def __init__(
8686
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
8787
self.tool_results: list[dict[str, Any]] = [] # Track tool results
8888
self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted
89+
self.accumulated_text_content: str = "" # Track accumulated text for final MessagesSnapshotEvent
8990

9091
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
9192
"""
@@ -99,18 +100,29 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba
99100
"""
100101
events: list[BaseEvent] = []
101102

102-
for content in update.contents:
103+
logger.info(f"Processing AgentRunUpdate with {len(update.contents)} content items")
104+
for idx, content in enumerate(update.contents):
105+
logger.info(f" Content {idx}: type={type(content).__name__}")
103106
if isinstance(content, TextContent):
107+
logger.info(
108+
f" TextContent found: text_length={len(content.text)}, text_preview='{content.text[:100]}'"
109+
)
110+
logger.info(
111+
f" Flags: skip_text_content={self.skip_text_content}, should_stop_after_confirm={self.should_stop_after_confirm}"
112+
)
113+
104114
# Skip text content if using structured outputs (it's just the JSON)
105115
if self.skip_text_content:
116+
logger.info(" SKIPPING TextContent: skip_text_content is True")
106117
continue
107118

108119
# Skip text content if we're about to emit confirm_changes
109120
# The summary should only appear after user confirms
110121
if self.should_stop_after_confirm:
111-
logger.debug("Skipping text content - waiting for confirm_changes response")
122+
logger.info(" SKIPPING TextContent: waiting for confirm_changes response")
112123
# Save the summary text to show after confirmation
113124
self.suppressed_summary += content.text
125+
logger.info(f" Suppressed summary now has {len(self.suppressed_summary)} chars")
114126
continue
115127

116128
if not self.current_message_id:
@@ -119,14 +131,16 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba
119131
message_id=self.current_message_id,
120132
role="assistant",
121133
)
122-
logger.debug(f"Emitting TextMessageStartEvent with message_id={self.current_message_id}")
134+
logger.info(f" EMITTING TextMessageStartEvent with message_id={self.current_message_id}")
123135
events.append(start_event)
124136

125137
event = TextMessageContentEvent(
126138
message_id=self.current_message_id,
127139
delta=content.text,
128140
)
129-
logger.debug(f"Emitting TextMessageContentEvent with delta: {content.text}")
141+
# Accumulate text content for final MessagesSnapshotEvent
142+
self.accumulated_text_content += content.text
143+
logger.info(f" EMITTING TextMessageContentEvent with delta: '{content.text}'")
130144
events.append(event)
131145

132146
elif isinstance(content, FunctionCallContent):
@@ -427,7 +441,24 @@ async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[Ba
427441

428442
# Emit MessagesSnapshotEvent with the complete conversation including tool calls and results
429443
# This is required for CopilotKit's useCopilotAction to detect tool result
430-
if self.pending_tool_calls and self.tool_results:
444+
# HOWEVER: Skip this for predictive tools when require_confirmation=False, because
445+
# the agent will generate a follow-up text message and we'll emit a complete snapshot at the end.
446+
# Emitting here would create an incomplete snapshot that gets replaced, causing UI flicker.
447+
should_emit_snapshot = self.pending_tool_calls and self.tool_results
448+
449+
# Check if this is a predictive tool that will have a follow-up message
450+
is_predictive_without_confirmation = False
451+
if should_emit_snapshot and self.current_tool_call_name and self.predict_state_config:
452+
for state_key, config in self.predict_state_config.items():
453+
if config["tool"] == self.current_tool_call_name and not self.require_confirmation:
454+
is_predictive_without_confirmation = True
455+
logger.info(
456+
f"Skipping intermediate MessagesSnapshotEvent for predictive tool '{self.current_tool_call_name}' "
457+
"- will emit complete snapshot after follow-up message"
458+
)
459+
break
460+
461+
if should_emit_snapshot and not is_predictive_without_confirmation:
431462
# Import message adapter
432463
from ._message_adapters import agent_framework_messages_to_agui
433464

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,62 @@ def extract_text_from_contents(contents: list[Any]) -> str:
283283
return "".join(text_parts)
284284

285285

286+
def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
287+
"""Normalize AG-UI messages for MessagesSnapshotEvent.
288+
289+
Converts AG-UI input format (with 'input_text' type) to snapshot format (with 'text' type).
290+
291+
Args:
292+
messages: List of AG-UI messages in input format
293+
294+
Returns:
295+
List of normalized messages suitable for MessagesSnapshotEvent
296+
"""
297+
from ._utils import generate_event_id
298+
299+
result: list[dict[str, Any]] = []
300+
for msg in messages:
301+
normalized_msg = msg.copy()
302+
303+
# Ensure ID exists
304+
if "id" not in normalized_msg:
305+
normalized_msg["id"] = generate_event_id()
306+
307+
# Normalize content field
308+
content = normalized_msg.get("content")
309+
if isinstance(content, list):
310+
# Convert content array format to simple string
311+
text_parts = []
312+
for item in content:
313+
if isinstance(item, dict):
314+
# Convert 'input_text' to 'text' type
315+
if item.get("type") == "input_text":
316+
text_parts.append(item.get("text", ""))
317+
elif item.get("type") == "text":
318+
text_parts.append(item.get("text", ""))
319+
else:
320+
# Other types - just extract text field if present
321+
text_parts.append(item.get("text", ""))
322+
normalized_msg["content"] = "".join(text_parts)
323+
elif content is None:
324+
normalized_msg["content"] = ""
325+
326+
# Normalize tool_call_id to toolCallId for tool messages
327+
if normalized_msg.get("role") == "tool":
328+
if "tool_call_id" in normalized_msg:
329+
normalized_msg["toolCallId"] = normalized_msg["tool_call_id"]
330+
del normalized_msg["tool_call_id"]
331+
elif "toolCallId" not in normalized_msg:
332+
normalized_msg["toolCallId"] = ""
333+
334+
result.append(normalized_msg)
335+
336+
return result
337+
338+
286339
__all__ = [
287340
"agui_messages_to_agent_framework",
288341
"agent_framework_messages_to_agui",
342+
"agui_messages_to_snapshot_format",
289343
"extract_text_from_contents",
290344
]

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

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from ag_ui.core import (
1313
BaseEvent,
14+
MessagesSnapshotEvent,
1415
RunErrorEvent,
1516
TextMessageContentEvent,
1617
TextMessageEndEvent,
@@ -588,32 +589,37 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
588589
# We should NOT add to thread.on_new_messages() as that would cause duplication.
589590
# Instead, we pass messages directly to the agent via messages_to_run.
590591

591-
# Inject current state as system message context if we have state
592+
# Inject current state as system message context if we have state and this is a new user turn
592593
messages_to_run: list[Any] = []
593594

595+
# Check if the last message is from the user (new turn) vs assistant/tool (mid-execution)
596+
is_new_user_turn = False
597+
if provider_messages:
598+
last_msg = provider_messages[-1]
599+
is_new_user_turn = last_msg.role.value == "user"
600+
601+
# Check if conversation has tool calls (indicates mid-execution)
594602
conversation_has_tool_calls = False
595-
logger.debug(f"Checking {len(provider_messages)} provider messages for tool calls")
596-
for i, msg in enumerate(provider_messages):
597-
logger.debug(
598-
f" Message {i}: role={msg.role.value}, contents={len(msg.contents) if hasattr(msg, 'contents') and msg.contents else 0}"
599-
)
600603
for msg in provider_messages:
601604
if msg.role.value == "assistant" and hasattr(msg, "contents") and msg.contents:
602605
if any(isinstance(content, FunctionCallContent) for content in msg.contents):
603606
conversation_has_tool_calls = True
604607
break
605-
if current_state and context.config.state_schema and not conversation_has_tool_calls:
608+
609+
# Only inject state context on new user turns AND when conversation doesn't have tool calls
610+
# (tool calls indicate we're mid-execution, so state context was already injected)
611+
if current_state and context.config.state_schema and is_new_user_turn and not conversation_has_tool_calls:
606612
state_json = json.dumps(current_state, indent=2)
607613
state_context_msg = ChatMessage(
608614
role="system",
609615
contents=[
610616
TextContent(
611617
text=f"""Current state of the application:
612-
{state_json}
618+
{state_json}
613619
614-
When modifying state, you MUST include ALL existing data plus your changes.
615-
For example, if adding a new ingredient, include all existing ingredients PLUS the new one.
616-
Never replace existing data - always append or merge."""
620+
When modifying state, you MUST include ALL existing data plus your changes.
621+
For example, if adding one new item to a list, include ALL existing items PLUS the one new item.
622+
Never replace existing data - always preserve and append or merge."""
617623
)
618624
],
619625
)
@@ -714,12 +720,19 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
714720

715721
# Collect all updates to get the final structured output
716722
all_updates: list[Any] = []
723+
update_count = 0
717724
async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=tools_param):
725+
update_count += 1
726+
logger.info(f"[STREAM] Received update #{update_count} from agent")
718727
all_updates.append(update)
719728
events = await event_bridge.from_agent_run_update(update)
729+
logger.info(f"[STREAM] Update #{update_count} produced {len(events)} events")
720730
for event in events:
731+
logger.info(f"[STREAM] Yielding event: {type(event).__name__}")
721732
yield event
722733

734+
logger.info(f"[STREAM] Agent stream completed. Total updates: {update_count}")
735+
723736
# After agent completes, check if we should stop (waiting for user to confirm changes)
724737
if event_bridge.should_stop_after_confirm:
725738
logger.info("Stopping run after confirm_changes - waiting for user response")
@@ -793,9 +806,56 @@ def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
793806
yield TextMessageEndEvent(message_id=message_id)
794807
logger.info(f"Emitted conversational message: {response_dict['message'][:100]}...")
795808

809+
logger.info(f"[FINALIZE] Checking for unclosed message. current_message_id={event_bridge.current_message_id}")
796810
if event_bridge.current_message_id:
811+
logger.info(f"[FINALIZE] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}")
797812
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
798813

814+
# Emit MessagesSnapshotEvent to persist the final assistant text message
815+
from ._message_adapters import agui_messages_to_snapshot_format
816+
817+
# Build the final assistant message with accumulated text content
818+
assistant_text_message = {
819+
"id": event_bridge.current_message_id,
820+
"role": "assistant",
821+
"content": event_bridge.accumulated_text_content,
822+
}
823+
824+
# Convert input messages to snapshot format (normalize content structure)
825+
# event_bridge.input_messages are already in AG-UI format, just need normalization
826+
converted_input_messages = agui_messages_to_snapshot_format(event_bridge.input_messages)
827+
828+
# Build complete messages array
829+
# Include: input messages + any pending tool calls/results + final text message
830+
all_messages = converted_input_messages.copy()
831+
832+
# Add assistant message with tool calls if any
833+
if event_bridge.pending_tool_calls:
834+
tool_call_message = {
835+
"id": generate_event_id(),
836+
"role": "assistant",
837+
"tool_calls": event_bridge.pending_tool_calls.copy(),
838+
}
839+
all_messages.append(tool_call_message)
840+
841+
# Add tool results if any
842+
all_messages.extend(event_bridge.tool_results.copy())
843+
844+
# Add final text message
845+
all_messages.append(assistant_text_message)
846+
847+
messages_snapshot = MessagesSnapshotEvent(
848+
messages=all_messages, # type: ignore[arg-type]
849+
)
850+
logger.info(
851+
f"[FINALIZE] Emitting MessagesSnapshotEvent with {len(all_messages)} messages "
852+
f"(text content length: {len(event_bridge.accumulated_text_content)})"
853+
)
854+
yield messages_snapshot
855+
else:
856+
logger.info("[FINALIZE] No current_message_id - skipping TextMessageEndEvent")
857+
858+
logger.info("[FINALIZE] Emitting RUN_FINISHED event")
799859
yield event_bridge.create_run_finished_event()
800860
logger.info(f"Completed agent run for thread_id={context.thread_id}, run_id={context.run_id}")
801861

python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,5 @@ def recipe_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
130130
"recipe": {"tool": "update_recipe", "tool_argument": "recipe"},
131131
},
132132
confirmation_strategy=RecipeConfirmationStrategy(),
133+
require_confirmation=False,
133134
)

python/packages/ag-ui/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agent-framework-ag-ui"
3-
version = "1.0.0b251114"
3+
version = "1.0.0b251117"
44
description = "AG-UI protocol integration for Agent Framework"
55
readme = "README.md"
66
license-files = ["LICENSE"]

0 commit comments

Comments
 (0)