Skip to content

Commit 8147e2e

Browse files
howlowckalliscode
authored andcommitted
Python: (AG-UI) Support service-managed thread on AG-UI (microsoft#3136)
* added service thread support * set service_thread_id to only supplied_thread_id * uses raw_representation to extract the conversation_id * removed accidental edit * updated test to use raw_representation * resolves copilot review feedback * revert back StubAgent, since not used * removed relative module import * removed hasattr check per PR feedback
1 parent 1623f4a commit 8147e2e

6 files changed

Lines changed: 316 additions & 26 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,20 @@ def __init__(
2424
self,
2525
state_schema: Any | None = None,
2626
predict_state_config: dict[str, dict[str, str]] | None = None,
27+
use_service_thread: bool = False,
2728
require_confirmation: bool = True,
2829
):
2930
"""Initialize agent configuration.
3031
3132
Args:
3233
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
3334
predict_state_config: Configuration for predictive state updates
35+
use_service_thread: Whether the agent thread is service-managed
3436
require_confirmation: Whether predictive updates require confirmation
3537
"""
3638
self.state_schema = self._normalize_state_schema(state_schema)
3739
self.predict_state_config = predict_state_config or {}
40+
self.use_service_thread = use_service_thread
3841
self.require_confirmation = require_confirmation
3942

4043
@staticmethod
@@ -86,6 +89,7 @@ def __init__(
8689
predict_state_config: dict[str, dict[str, str]] | None = None,
8790
require_confirmation: bool = True,
8891
orchestrators: list[Orchestrator] | None = None,
92+
use_service_thread: bool = False,
8993
confirmation_strategy: ConfirmationStrategy | None = None,
9094
):
9195
"""Initialize the AG-UI compatible agent wrapper.
@@ -101,6 +105,7 @@ def __init__(
101105
Set to False for agentic generative UI that updates automatically.
102106
orchestrators: Custom orchestrators (auto-configured if None).
103107
Orchestrators are checked in order; first match handles the request.
108+
use_service_thread: Whether the agent thread is service-managed.
104109
confirmation_strategy: Strategy for generating confirmation messages.
105110
Defaults to DefaultConfirmationStrategy if None.
106111
"""
@@ -111,6 +116,7 @@ def __init__(
111116
self.config = AgentConfig(
112117
state_schema=state_schema,
113118
predict_state_config=predict_state_config,
119+
use_service_thread=use_service_thread,
114120
require_confirmation=require_confirmation,
115121
)
116122

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

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
import uuid
88
from abc import ABC, abstractmethod
9-
from collections.abc import AsyncGenerator
9+
from collections.abc import AsyncGenerator, Sequence
1010
from typing import TYPE_CHECKING, Any
1111

1212
from ag_ui.core import (
@@ -53,11 +53,18 @@
5353
merge_tools,
5454
register_additional_client_tools,
5555
)
56-
from ._utils import convert_agui_tools_to_agent_framework, generate_event_id, get_role_value
56+
from ._utils import (
57+
convert_agui_tools_to_agent_framework,
58+
generate_event_id,
59+
get_conversation_id_from_update,
60+
get_role_value,
61+
)
5762

5863
if TYPE_CHECKING:
5964
from ._agent import AgentConfig
6065
from ._confirmation_strategies import ConfirmationStrategy
66+
from ._events import AgentFrameworkEventBridge
67+
from ._orchestration._state_manager import StateManager
6168

6269

6370
logger = logging.getLogger(__name__)
@@ -92,6 +99,8 @@ def __init__(
9299
self._last_message = None
93100
self._run_id: str | None = None
94101
self._thread_id: str | None = None
102+
self._supplied_run_id: str | None = None
103+
self._supplied_thread_id: str | None = None
95104

96105
@property
97106
def messages(self):
@@ -125,26 +134,66 @@ def last_message(self):
125134
self._last_message = self.messages[-1]
126135
return self._last_message
127136

137+
@property
138+
def supplied_run_id(self) -> str | None:
139+
"""Get the supplied run ID, if any."""
140+
if self._supplied_run_id is None:
141+
self._supplied_run_id = self.input_data.get("run_id") or self.input_data.get("runId")
142+
return self._supplied_run_id
143+
128144
@property
129145
def run_id(self) -> str:
130-
"""Get or generate run ID."""
146+
"""Get supplied run ID or generate a new run ID."""
147+
if self._run_id:
148+
return self._run_id
149+
150+
if self.supplied_run_id:
151+
self._run_id = self.supplied_run_id
152+
131153
if self._run_id is None:
132-
self._run_id = self.input_data.get("run_id") or self.input_data.get("runId") or str(uuid.uuid4())
133-
# This should never be None after the if block above, but satisfy type checkers
134-
if self._run_id is None: # pragma: no cover
135-
raise RuntimeError("Failed to initialize run_id")
154+
self._run_id = str(uuid.uuid4())
155+
136156
return self._run_id
137157

158+
@property
159+
def supplied_thread_id(self) -> str | None:
160+
"""Get the supplied thread ID, if any."""
161+
if self._supplied_thread_id is None:
162+
self._supplied_thread_id = self.input_data.get("thread_id") or self.input_data.get("threadId")
163+
return self._supplied_thread_id
164+
138165
@property
139166
def thread_id(self) -> str:
140-
"""Get or generate thread ID."""
167+
"""Get supplied thread ID or generate a new thread ID."""
168+
if self._thread_id:
169+
return self._thread_id
170+
171+
if self.supplied_thread_id:
172+
self._thread_id = self.supplied_thread_id
173+
141174
if self._thread_id is None:
142-
self._thread_id = self.input_data.get("thread_id") or self.input_data.get("threadId") or str(uuid.uuid4())
143-
# This should never be None after the if block above, but satisfy type checkers
144-
if self._thread_id is None: # pragma: no cover
145-
raise RuntimeError("Failed to initialize thread_id")
175+
self._thread_id = str(uuid.uuid4())
176+
146177
return self._thread_id
147178

179+
def update_run_id(self, new_run_id: str) -> None:
180+
"""Update the run ID in the context.
181+
182+
Args:
183+
new_run_id: The new run ID to set
184+
"""
185+
self._supplied_run_id = new_run_id
186+
self._run_id = new_run_id
187+
188+
def update_thread_id(self, new_thread_id: str) -> None:
189+
"""Update the thread ID in the context.
190+
191+
Args:
192+
new_thread_id: The new thread ID to set
193+
"""
194+
self._supplied_thread_id = new_thread_id
195+
self._thread_id = new_thread_id
196+
148197

149198
class Orchestrator(ABC):
150199
"""Base orchestrator for agent execution flows."""
@@ -297,6 +346,28 @@ def can_handle(self, context: ExecutionContext) -> bool:
297346
"""
298347
return True
299348

349+
def _create_initial_events(
350+
self, event_bridge: "AgentFrameworkEventBridge", state_manager: "StateManager"
351+
) -> Sequence[BaseEvent]:
352+
"""Generate initial events for the run.
353+
354+
Args:
355+
event_bridge: Event bridge for creating events
356+
Returns:
357+
Initial AG-UI events
358+
"""
359+
events: list[BaseEvent] = [event_bridge.create_run_started_event()]
360+
361+
predict_event = state_manager.predict_state_event()
362+
if predict_event:
363+
events.append(predict_event)
364+
365+
snapshot_event = state_manager.initial_snapshot_event(event_bridge)
366+
if snapshot_event:
367+
events.append(snapshot_event)
368+
369+
return events
370+
300371
async def run(
301372
self,
302373
context: ExecutionContext,
@@ -342,17 +413,11 @@ async def run(
342413
approval_tool_name=approval_tool_name,
343414
)
344415

345-
yield event_bridge.create_run_started_event()
346-
347-
predict_event = state_manager.predict_state_event()
348-
if predict_event:
349-
yield predict_event
350-
351-
snapshot_event = state_manager.initial_snapshot_event(event_bridge)
352-
if snapshot_event:
353-
yield snapshot_event
416+
if context.config.use_service_thread:
417+
thread = AgentThread(service_thread_id=context.supplied_thread_id)
418+
else:
419+
thread = AgentThread()
354420

355-
thread = AgentThread()
356421
thread.metadata = { # type: ignore[attr-defined]
357422
"ag_ui_thread_id": context.thread_id,
358423
"ag_ui_run_id": context.run_id,
@@ -363,6 +428,8 @@ async def run(
363428
provider_messages = context.messages or []
364429
snapshot_messages = context.snapshot_messages
365430
if not provider_messages:
431+
for event in self._create_initial_events(event_bridge, state_manager):
432+
yield event
366433
logger.warning("No messages provided in AG-UI input")
367434
yield event_bridge.create_run_finished_event()
368435
return
@@ -554,13 +621,41 @@ def _build_messages_snapshot(tool_message_id: str | None = None) -> MessagesSnap
554621
confirmation_message = strategy.on_state_rejected()
555622

556623
message_id = generate_event_id()
624+
for event in self._create_initial_events(event_bridge, state_manager):
625+
yield event
557626
yield TextMessageStartEvent(message_id=message_id, role="assistant")
558627
yield TextMessageContentEvent(message_id=message_id, delta=confirmation_message)
559628
yield TextMessageEndEvent(message_id=message_id)
560629
yield event_bridge.create_run_finished_event()
561630
return
562631

632+
should_recreate_event_bridge = False
563633
async for update in context.agent.run_stream(messages_to_run, **run_kwargs):
634+
conv_id = get_conversation_id_from_update(update)
635+
if conv_id and conv_id != context.thread_id:
636+
context.update_thread_id(conv_id)
637+
should_recreate_event_bridge = True
638+
639+
if update.response_id and update.response_id != context.run_id:
640+
context.update_run_id(update.response_id)
641+
should_recreate_event_bridge = True
642+
643+
if should_recreate_event_bridge:
644+
event_bridge = AgentFrameworkEventBridge(
645+
run_id=context.run_id,
646+
thread_id=context.thread_id,
647+
predict_state_config=context.config.predict_state_config,
648+
current_state=current_state,
649+
skip_text_content=skip_text_content,
650+
require_confirmation=context.config.require_confirmation,
651+
approval_tool_name=approval_tool_name,
652+
)
653+
should_recreate_event_bridge = False
654+
655+
if update_count == 0:
656+
for event in self._create_initial_events(event_bridge, state_manager):
657+
yield event
658+
564659
update_count += 1
565660
logger.info(f"[STREAM] Received update #{update_count} from agent")
566661
if all_updates is not None:
@@ -672,6 +767,11 @@ def _build_messages_snapshot(tool_message_id: str | None = None) -> MessagesSnap
672767
yield TextMessageEndEvent(message_id=message_id)
673768
logger.info(f"Emitted conversational message with length={len(response_dict['message'])}")
674769

770+
if all_updates is not None and len(all_updates) == 0:
771+
logger.info("No updates received from agent - emitting initial events")
772+
for event in self._create_initial_events(event_bridge, state_manager):
773+
yield event
774+
675775
logger.info(f"[FINALIZE] Checking for unclosed message. current_message_id={event_bridge.current_message_id}")
676776
if event_bridge.current_message_id:
677777
logger.info(f"[FINALIZE] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}")

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from datetime import date, datetime
1111
from typing import Any
1212

13-
from agent_framework import AIFunction, Role, ToolProtocol
13+
from agent_framework import AgentResponseUpdate, AIFunction, ChatResponseUpdate, Role, ToolProtocol
1414

1515
# Role mapping constants
1616
AGUI_TO_FRAMEWORK_ROLE: dict[str, Role] = {
@@ -259,3 +259,17 @@ def convert_tools_to_agui_format(
259259
continue
260260

261261
return results if results else None
262+
263+
264+
def get_conversation_id_from_update(update: AgentResponseUpdate) -> str | None:
265+
"""Extract conversation ID from AgentResponseUpdate metadata.
266+
267+
Args:
268+
update: AgentRunResponseUpdate instance
269+
Returns:
270+
Conversation ID if present, else None
271+
272+
"""
273+
if isinstance(update.raw_representation, ChatResponseUpdate):
274+
return update.raw_representation.conversation_id
275+
return None

python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,60 @@ async def stream_fn(
637637
assert "written" in full_text.lower() or "document" in full_text.lower()
638638

639639

640+
async def test_agent_with_use_service_thread_is_false():
641+
"""Test that when use_service_thread is False, the AgentThread used to run the agent is NOT set to the service thread ID."""
642+
from agent_framework.ag_ui import AgentFrameworkAgent
643+
644+
request_service_thread_id: str | None = None
645+
646+
async def stream_fn(
647+
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
648+
) -> AsyncIterator[ChatResponseUpdate]:
649+
nonlocal request_service_thread_id
650+
thread = kwargs.get("thread")
651+
request_service_thread_id = thread.service_thread_id if thread else None
652+
yield ChatResponseUpdate(
653+
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
654+
)
655+
656+
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
657+
wrapper = AgentFrameworkAgent(agent=agent, use_service_thread=False)
658+
659+
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
660+
661+
events: list[Any] = []
662+
async for event in wrapper.run_agent(input_data):
663+
events.append(event)
664+
assert request_service_thread_id is None # type: ignore[attr-defined] (service_thread_id should be set)
665+
666+
667+
async def test_agent_with_use_service_thread_is_true():
668+
"""Test that when use_service_thread is True, the AgentThread used to run the agent is set to the service thread ID."""
669+
from agent_framework.ag_ui import AgentFrameworkAgent
670+
671+
request_service_thread_id: str | None = None
672+
673+
async def stream_fn(
674+
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
675+
) -> AsyncIterator[ChatResponseUpdate]:
676+
nonlocal request_service_thread_id
677+
thread = kwargs.get("thread")
678+
request_service_thread_id = thread.service_thread_id if thread else None
679+
yield ChatResponseUpdate(
680+
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
681+
)
682+
683+
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
684+
wrapper = AgentFrameworkAgent(agent=agent, use_service_thread=True)
685+
686+
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
687+
688+
events: list[Any] = []
689+
async for event in wrapper.run_agent(input_data):
690+
events.append(event)
691+
assert request_service_thread_id == "conv_123456" # type: ignore[attr-defined] (service_thread_id should be set)
692+
693+
640694
async def test_function_approval_mode_executes_tool():
641695
"""Test that function approval with approval_mode='always_require' sends the correct messages."""
642696
from agent_framework import FunctionResultContent, ai_function

0 commit comments

Comments
 (0)