Python: Fix streamed workflow agent continuation context by finalizing AgentExecutor streams (#3882)

* Fix streamed workflow agent continuation context by finalizing AgentExecutor streams

* Fix stream handling

* Fixes

* Fix DevUI and tests
This commit is contained in:
Evan Mattson
2026-02-13 07:45:46 +09:00
committed by GitHub
Unverified
parent 2203fa0f8b
commit a276c1295a
17 changed files with 359 additions and 267 deletions
@@ -17,6 +17,7 @@ from agent_framework import (
BaseContextProvider,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
FunctionTool,
Message,
@@ -154,6 +155,111 @@ async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChat
assert session.service_session_id == "123"
async def test_chat_client_agent_updates_existing_session_id_non_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.run_responses = [
ChatResponse(
messages=[Message(role="assistant", contents=[Content.from_text("test response")])],
conversation_id="resp_new_123",
)
]
agent = Agent(client=chat_client_base)
session = agent.get_session(service_session_id="resp_old_123")
await agent.run("Hello", session=session)
assert session.service_session_id == "resp_new_123"
async def test_chat_client_agent_update_session_id_streaming_uses_conversation_id(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[Content.from_text("stream part 1")],
role="assistant",
response_id="resp_stream_123",
conversation_id="conv_stream_456",
),
ChatResponseUpdate(
contents=[Content.from_text(" stream part 2")],
role="assistant",
response_id="resp_stream_123",
conversation_id="conv_stream_456",
finish_reason="stop",
),
]
]
agent = Agent(client=chat_client_base)
session = agent.create_session()
stream = agent.run("Hello", session=session, stream=True)
async for _ in stream:
pass
result = await stream.get_final_response()
assert result.text == "stream part 1 stream part 2"
assert session.service_session_id == "conv_stream_456"
async def test_chat_client_agent_updates_existing_session_id_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[Content.from_text("stream part 1")],
role="assistant",
response_id="resp_stream_123",
conversation_id="resp_new_456",
),
ChatResponseUpdate(
contents=[Content.from_text(" stream part 2")],
role="assistant",
response_id="resp_stream_123",
conversation_id="resp_new_456",
finish_reason="stop",
),
]
]
agent = Agent(client=chat_client_base)
session = agent.get_session(service_session_id="resp_old_456")
stream = agent.run("Hello", session=session, stream=True)
async for _ in stream:
pass
await stream.get_final_response()
assert session.service_session_id == "resp_new_456"
async def test_chat_client_agent_update_session_id_streaming_does_not_use_response_id(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[Content.from_text("stream response without conversation id")],
role="assistant",
response_id="resp_only_123",
finish_reason="stop",
),
]
]
agent = Agent(client=chat_client_base)
session = agent.create_session()
stream = agent.run("Hello", session=session, stream=True)
async for _ in stream:
pass
result = await stream.get_final_response()
assert result.text == "stream response without conversation id"
assert session.service_session_id is None
async def test_chat_client_agent_update_session_messages(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
session = agent.create_session()
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -50,6 +50,57 @@ class _CountingAgent(BaseAgent):
return _run()
class _StreamingHookAgent(BaseAgent):
"""Agent that exposes whether its streaming result hook was executed."""
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.result_hook_called = False
def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[Content.from_text(text="hook test")],
role="assistant",
)
async def _mark_result_hook_called(response: AgentResponse) -> AgentResponse:
self.result_hook_called = True
return response
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates).with_result_hook(
_mark_result_hook_called
)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", ["hook test"])])
return _run()
async def test_agent_executor_streaming_finalizes_stream_and_runs_result_hooks() -> None:
"""AgentExecutor should call get_final_response() so stream result hooks execute."""
agent = _StreamingHookAgent(id="hook_agent", name="HookAgent")
executor = AgentExecutor(agent, id="hook_exec")
workflow = SequentialBuilder(participants=[executor]).build()
output_events: list[Any] = []
async for event in workflow.run("run hook test", stream=True):
if event.type == "output":
output_events.append(event)
assert output_events
assert agent.result_hook_called
async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
"""Test that workflow checkpoint stores AgentExecutor's cache and session states and restores them correctly."""
storage = InMemoryCheckpointStorage()
@@ -12,7 +12,7 @@ from agent_framework import (
WorkflowRunState,
)
from agent_framework._workflows._checkpoint_encoding import (
_PICKLE_MARKER,
_PICKLE_MARKER, # type: ignore
encode_checkpoint_value,
)
from agent_framework._workflows._events import WorkflowEvent