From a033721ac202471f161e316da69eae7e81bb7a98 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 03:28:14 +0900 Subject: [PATCH 01/10] Python: Fix response_format resolution in streaming finalizer (#4291) * Python: Fix AgentResponse.value being None when streaming workflow (#3970) The streaming path in BaseAgent.run() used the raw 'options' parameter (passed by the caller) to bind response_format into the outer stream's finalizer. When response_format was set in default_options rather than runtime options, it was missing from the finalizer and value was None. Fix: Use the merged chat_options from the run context (via ctx_holder), matching the non-streaming path which already uses ctx['chat_options']. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #3970: safer ctx access, add test coverage --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_agents.py | 9 ++-- .../packages/core/tests/core/test_agents.py | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index a3f4570b6e..580b6e2c6d 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -935,6 +935,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] session.service_session_id = conv_id return update + def _finalizer(updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: + ctx = ctx_holder["ctx"] + rf = ctx.get("chat_options", {}).get("response_format") if ctx else (options.get("response_format") if options else None) + return self._finalize_response_updates(updates, response_format=rf) + return ( ResponseStream .from_awaitable(_get_stream()) @@ -943,9 +948,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] map_chat_to_agent_update, agent_name=self.name, ), - finalizer=partial( - self._finalize_response_updates, response_format=options.get("response_format") if options else None - ), + finalizer=_finalizer, ) .with_transform_hook(_propagate_conversation_id) .with_result_hook(_post_hook) diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 627987a1f2..b6f84dc970 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -97,6 +97,58 @@ async def test_chat_client_agent_run_streaming(client: SupportsChatGetResponse) assert result.text == "test streaming response another update" +async def test_chat_client_agent_streaming_response_format_from_default_options( + client: SupportsChatGetResponse, +) -> None: + """AgentResponse.value must be parsed when response_format is set in default_options and streaming.""" + from pydantic import BaseModel + + class Greeting(BaseModel): + greeting: str + + json_text = '{"greeting": "Hello"}' + client.streaming_responses.append( # type: ignore[attr-defined] + [ChatResponseUpdate(contents=[Content.from_text(json_text)], role="assistant", finish_reason="stop")] + ) + + agent = Agent(client=client, default_options={"response_format": Greeting}) + stream = agent.run("Hello", stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + + assert result.text == json_text + assert result.value is not None + assert isinstance(result.value, Greeting) + assert result.value.greeting == "Hello" + + +async def test_chat_client_agent_streaming_response_format_from_run_options( + client: SupportsChatGetResponse, +) -> None: + """AgentResponse.value must be parsed when response_format is passed via run() options kwarg.""" + from pydantic import BaseModel + + class Greeting(BaseModel): + greeting: str + + json_text = '{"greeting": "Hi"}' + client.streaming_responses.append( # type: ignore[attr-defined] + [ChatResponseUpdate(contents=[Content.from_text(json_text)], role="assistant", finish_reason="stop")] + ) + + agent = Agent(client=client) + stream = agent.run("Hello", stream=True, options={"response_format": Greeting}) + async for _ in stream: + pass + result = await stream.get_final_response() + + assert result.text == json_text + assert result.value is not None + assert isinstance(result.value, Greeting) + assert result.value.greeting == "Hi" + + async def test_chat_client_agent_create_session(client: SupportsChatGetResponse) -> None: agent = Agent(client=client) session = agent.create_session() From 97b24990d902a358e64d19c219961eaaebfa543b Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 05:52:06 +0900 Subject: [PATCH 02/10] Python: Tighten HandoffBuilder to require Agent instead of SupportsAgentRun (#4301) (#4302) HandoffBuilder.participants() accepted SupportsAgentRun by API contract, but build() failed at runtime because _prepare_agent_with_handoffs() requires Agent instances for cloning, tool injection, and middleware. Fix: Update all public type hints, docstrings, and validation in HandoffBuilder and HandoffAgentExecutor to require Agent explicitly. The isinstance check is now performed early in participants() with a clear error message explaining why Agent is required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_handoff.py | 65 ++++++++++--------- .../orchestrations/tests/test_handoff.py | 26 ++++++++ 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index d2ff5af959..5d6e84ef05 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -197,7 +197,7 @@ class HandoffAgentExecutor(AgentExecutor): def __init__( self, - agent: SupportsAgentRun, + agent: Agent, handoffs: Sequence[HandoffConfiguration], *, agent_session: AgentSession | None = None, @@ -210,7 +210,7 @@ class HandoffAgentExecutor(AgentExecutor): """Initialize the HandoffAgentExecutor. Args: - agent: The agent to execute + agent: The ``Agent`` instance to execute handoffs: Sequence of handoff configurations defining target agents agent_session: Optional AgentSession that manages the agent's execution context is_start_agent: Whether this agent is the starting agent in the handoff workflow. @@ -240,20 +240,18 @@ class HandoffAgentExecutor(AgentExecutor): def _prepare_agent_with_handoffs( self, - agent: SupportsAgentRun, + agent: Agent, handoffs: Sequence[HandoffConfiguration], - ) -> SupportsAgentRun: + ) -> Agent: """Prepare an agent by adding handoff tools for the specified target agents. Args: - agent: The agent to prepare + agent: The ``Agent`` instance to prepare handoffs: Sequence of handoff configurations defining target agents Returns: - A new AgentExecutor instance with handoff tools added + A cloned ``Agent`` instance with handoff tools added """ - if not isinstance(agent, Agent): - raise TypeError("Handoff can only be applied to Agent. Please ensure the agent is a Agent instance.") # Clone the agent to avoid mutating the original cloned_agent = self._clone_chat_agent(agent) # type: ignore @@ -701,13 +699,15 @@ class HandoffBuilder: approach to multi-agent collaboration. Handoffs can be configured using `.add_handoff`. If none are specified, all agents can hand off to all others by default (making a mesh topology). - Participants must be agents. Support for custom executors is not available in handoff workflows. + Participants must be ``Agent`` instances. ``SupportsAgentRun`` protocol implementors that + are not ``Agent`` subclasses are not supported because handoff workflows require cloning, + tool injection, and middleware — capabilities only available on ``Agent``. Outputs: The final conversation history as a list of Message once the group chat completes. Note: - 1. Agents in handoff workflows must be Agent instances and support local tool calls. + 1. Agents in handoff workflows must be ``Agent`` instances and support local tool calls. 2. Handoff doesn't support intermediate outputs from agents. All outputs are returned as they become available. This is because agents in handoff workflows are not considered sub-agents of a central orchestrator, thus all outputs are directly emitted. @@ -717,7 +717,7 @@ class HandoffBuilder: self, *, name: str | None = None, - participants: Sequence[SupportsAgentRun] | None = None, + participants: Sequence[Agent] | None = None, description: str | None = None, checkpoint_storage: CheckpointStorage | None = None, termination_condition: TerminationCondition | None = None, @@ -734,7 +734,7 @@ class HandoffBuilder: Args: name: Optional workflow identifier used in logging and debugging. If not provided, a default name will be generated. - participants: Optional list of agents that will participate in the handoff workflow. + participants: Optional list of ``Agent`` instances that will participate in the handoff workflow. You can also call `.participants([...])` later. Each participant must have a unique identifier (`.name` is preferred if set, otherwise `.id` is used). description: Optional human-readable description explaining the workflow's @@ -747,7 +747,7 @@ class HandoffBuilder: self._description = description # Participant related members - self._participants: dict[str, SupportsAgentRun] = {} + self._participants: dict[str, Agent] = {} self._start_id: str | None = None if participants: @@ -768,11 +768,11 @@ class HandoffBuilder: # Termination related members self._termination_condition: Callable[[list[Message]], bool | Awaitable[bool]] | None = termination_condition - def participants(self, participants: Sequence[SupportsAgentRun]) -> "HandoffBuilder": + def participants(self, participants: Sequence[Agent]) -> "HandoffBuilder": """Register the agents that will participate in the handoff workflow. Args: - participants: Sequence of SupportsAgentRun instances. Each must have a unique identifier. + participants: Sequence of ``Agent`` instances. Each must have a unique identifier. (`.name` is preferred if set, otherwise `.id` is used). Returns: @@ -781,7 +781,7 @@ class HandoffBuilder: Raises: ValueError: If participants is empty, contains duplicates, or `.participants()` has already been called. - TypeError: If participants are not SupportsAgentRun instances. + TypeError: If participants are not ``Agent`` instances. Example: @@ -804,14 +804,15 @@ class HandoffBuilder: if not participants: raise ValueError("participants cannot be empty") - named: dict[str, SupportsAgentRun] = {} + named: dict[str, Agent] = {} for participant in participants: - if isinstance(participant, SupportsAgentRun): - resolved_id = self._resolve_to_id(participant) - else: + if not isinstance(participant, Agent): raise TypeError( - f"Participants must be SupportsAgentRun or Executor instances. Got {type(participant).__name__}." + f"Participants must be Agent instances. Got {type(participant).__name__}. " + "Handoff workflows require Agent because they rely on cloning, tool injection, " + "and middleware capabilities." ) + resolved_id = self._resolve_to_id(participant) if resolved_id in named: raise ValueError(f"Duplicate participant name '{resolved_id}' detected") @@ -823,8 +824,8 @@ class HandoffBuilder: def add_handoff( self, - source: SupportsAgentRun, - targets: Sequence[SupportsAgentRun], + source: Agent, + targets: Sequence[Agent], *, description: str | None = None, ) -> "HandoffBuilder": @@ -905,7 +906,7 @@ class HandoffBuilder: return self - def with_start_agent(self, agent: SupportsAgentRun) -> "HandoffBuilder": + def with_start_agent(self, agent: Agent) -> "HandoffBuilder": """Set the agent that will initiate the handoff workflow. If not specified, the first registered participant will be used as the starting agent. @@ -929,7 +930,7 @@ class HandoffBuilder: def with_autonomous_mode( self, *, - agents: Sequence[SupportsAgentRun] | Sequence[str] | None = None, + agents: Sequence[Agent] | Sequence[str] | None = None, prompts: dict[str, str] | None = None, turn_limits: dict[str, int] | None = None, ) -> "HandoffBuilder": @@ -943,7 +944,7 @@ class HandoffBuilder: Args: agents: Optional list of agents to enable autonomous mode for. Can be: - Factory names (str): If using participant factories - - SupportsAgentRun instances: The actual agent objects + - SupportsAgentRun / Agent instances: The actual agent objects - If not provided, all agents will operate in autonomous mode. prompts: Optional mapping of agent identifiers/factory names to custom prompts to use when continuing in autonomous mode. If not provided, a default prompt will be used. @@ -1092,22 +1093,22 @@ class HandoffBuilder: # region Internal Helper Methods - def _resolve_agents(self) -> dict[str, SupportsAgentRun]: + def _resolve_agents(self) -> dict[str, Agent]: """Resolve participant instances into agent instances. Returns: - Map of executor IDs to `SupportsAgentRun` instances + Map of executor IDs to ``Agent`` instances """ if not self._participants: raise ValueError("No participants provided. Call .participants() first.") return self._participants - def _resolve_handoffs(self, agents: dict[str, SupportsAgentRun]) -> dict[str, list[HandoffConfiguration]]: + def _resolve_handoffs(self, agents: dict[str, Agent]) -> dict[str, list[HandoffConfiguration]]: """Resolve handoff configurations to executor IDs. Args: - agents: Map of agent IDs to `SupportsAgentRun` instances + agents: Map of agent IDs to ``Agent`` instances Returns: Map of executor IDs to list of HandoffConfiguration instances @@ -1154,13 +1155,13 @@ class HandoffBuilder: def _resolve_executors( self, - agents: dict[str, SupportsAgentRun], + agents: dict[str, Agent], handoffs: dict[str, list[HandoffConfiguration]], ) -> dict[str, HandoffAgentExecutor]: """Resolve agents into HandoffAgentExecutors. Args: - agents: Map of agent IDs to `SupportsAgentRun` instances + agents: Map of agent IDs to ``Agent`` instances handoffs: Map of executor IDs to list of HandoffConfiguration instances Returns: diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index e0d94355b6..43c2f9153a 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -1091,3 +1091,29 @@ async def test_auto_handoff_middleware_calls_next_for_non_handoff_tool() -> None call_next.assert_awaited_once() assert context.result is None + + +def test_handoff_builder_rejects_non_agent_supports_agent_run(): + """Verify that participants() rejects SupportsAgentRun implementations that are not Agent instances.""" + from agent_framework import AgentResponse, AgentSession, SupportsAgentRun + + class FakeAgentRun: + def __init__(self, id, name): + self.id = id + self.name = name + self.description = "d" + + async def run(self, messages=None, *, stream=False, session=None, **kwargs): + return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]) + + def create_session(self, **kwargs): + return AgentSession() + + def get_session(self, *, service_session_id, **kwargs): + return AgentSession(service_session_id=service_session_id) + + fake = FakeAgentRun("a", "A") + assert isinstance(fake, SupportsAgentRun) + + with pytest.raises(TypeError, match="Participants must be Agent instances"): + HandoffBuilder().participants([fake]) From 823e714ccf5c994fe802a7ede7c0e30cf7e5e2d8 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 05:53:20 +0900 Subject: [PATCH 03/10] Python: Strip reserved kwargs in AgentExecutor to prevent duplicate-argument TypeError (#4298) * Python: Strip reserved kwargs in AgentExecutor to prevent collision (#4295) workflow.run(session=...) passed 'session' through to agent.run() via **run_kwargs while AgentExecutor also passes session=self._session explicitly, causing TypeError: got multiple values for keyword argument. _prepare_agent_run_args now strips reserved params (session, stream, messages) from run_kwargs and logs a warning when they are present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #4295 - Use _RESERVED_RUN_PARAMS constant in stripping loop instead of hardcoded tuple to maintain single source of truth - Trim frozenset to only stripped keys (session, stream, messages); options and additional_function_arguments have separate merge logic - Fix caplog type annotation to use TYPE_CHECKING pattern - Assert options return value in reserved-kwarg stripping test - Add test for multiple reserved kwargs supplied simultaneously - Add integration test for messages= kwarg via workflow.run() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_workflows/_agent_executor.py | 19 ++++ .../tests/workflow/test_agent_executor.py | 90 ++++++++++++++++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 257833bb6a..acec8e48e2 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -415,6 +415,10 @@ class AgentExecutor(Executor): return response + # Parameters that are explicitly passed to agent.run() by AgentExecutor + # and must not appear in **run_kwargs to avoid TypeError from duplicate values. + _RESERVED_RUN_PARAMS: frozenset[str] = frozenset({"session", "stream", "messages"}) + @staticmethod def _prepare_agent_run_args(raw_run_kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: """Prepare kwargs and options for agent.run(), avoiding duplicate option passing. @@ -423,8 +427,23 @@ class AgentExecutor(Executor): `options.additional_function_arguments`. If workflow kwargs include an `options` key, merge it into the final options object and remove it from kwargs before spreading `**run_kwargs`. + + Reserved parameters (session, stream, messages) that are explicitly + managed by AgentExecutor are stripped from run_kwargs to prevent + ``TypeError: got multiple values for keyword argument`` collisions. """ run_kwargs = dict(raw_run_kwargs) + + # Strip reserved params that AgentExecutor passes explicitly to agent.run(). + for key in AgentExecutor._RESERVED_RUN_PARAMS: + if key in run_kwargs: + logger.warning( + "Workflow kwarg '%s' is reserved by AgentExecutor and will be ignored. " + "Remove it from workflow.run() kwargs to silence this warning.", + key, + ) + run_kwargs.pop(key) + options_from_workflow = run_kwargs.pop("options", None) workflow_additional_args = run_kwargs.pop("additional_function_arguments", None) diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 7c2e6fc356..db53868ee1 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -1,7 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. +import logging from collections.abc import AsyncIterable, Awaitable -from typing import Any +from typing import TYPE_CHECKING, Any + +import pytest from agent_framework import ( AgentExecutor, @@ -18,6 +21,9 @@ from agent_framework._workflows._agent_executor import AgentExecutorResponse from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage from agent_framework.orchestrations import SequentialBuilder +if TYPE_CHECKING: + from _pytest.logging import LogCaptureFixture + class _CountingAgent(BaseAgent): """Agent that echoes messages with a counter to verify session state persistence.""" @@ -251,3 +257,85 @@ async def test_agent_executor_save_and_restore_state_directly() -> None: # Verify session was restored with correct session_id restored_session = new_executor._session # type: ignore[reportPrivateUsage] assert restored_session.session_id == session.session_id + + +async def test_agent_executor_run_with_session_kwarg_does_not_raise() -> None: + """Passing session= via workflow.run() should not cause a duplicate-keyword TypeError (#4295).""" + agent = _CountingAgent(id="session_kwarg_agent", name="SessionKwargAgent") + executor = AgentExecutor(agent, id="session_kwarg_exec") + workflow = SequentialBuilder(participants=[executor]).build() + + # This previously raised: TypeError: run() got multiple values for keyword argument 'session' + result = await workflow.run("hello", session="user-supplied-value") + assert result is not None + assert agent.call_count == 1 + + +async def test_agent_executor_run_streaming_with_stream_kwarg_does_not_raise() -> None: + """Passing stream= via workflow.run() kwargs should not cause a duplicate-keyword TypeError.""" + agent = _CountingAgent(id="stream_kwarg_agent", name="StreamKwargAgent") + executor = AgentExecutor(agent, id="stream_kwarg_exec") + workflow = SequentialBuilder(participants=[executor]).build() + + # stream=True at workflow level triggers streaming mode (returns async iterable) + events = [] + async for event in workflow.run("hello", stream=True): + events.append(event) + assert len(events) > 0 + assert agent.call_count == 1 + + +@pytest.mark.parametrize("reserved_kwarg", ["session", "stream", "messages"]) +async def test_prepare_agent_run_args_strips_reserved_kwargs( + reserved_kwarg: str, caplog: "LogCaptureFixture" +) -> None: + """_prepare_agent_run_args must remove reserved kwargs and log a warning.""" + raw = {reserved_kwarg: "should-be-stripped", "custom_key": "keep-me"} + + with caplog.at_level(logging.WARNING): + run_kwargs, options = AgentExecutor._prepare_agent_run_args(raw) + + assert reserved_kwarg not in run_kwargs + assert "custom_key" in run_kwargs + assert options is not None + assert options["additional_function_arguments"]["custom_key"] == "keep-me" + assert any(reserved_kwarg in record.message for record in caplog.records) + + +async def test_prepare_agent_run_args_preserves_non_reserved_kwargs() -> None: + """Non-reserved workflow kwargs should pass through unchanged.""" + raw = {"custom_param": "value", "another": 42} + run_kwargs, options = AgentExecutor._prepare_agent_run_args(raw) + assert run_kwargs["custom_param"] == "value" + assert run_kwargs["another"] == 42 + + +async def test_prepare_agent_run_args_strips_all_reserved_kwargs_at_once( + caplog: "LogCaptureFixture", +) -> None: + """All reserved kwargs should be stripped when supplied together, each emitting a warning.""" + raw = {"session": "x", "stream": True, "messages": [], "custom": 1} + + with caplog.at_level(logging.WARNING): + run_kwargs, options = AgentExecutor._prepare_agent_run_args(raw) + + assert "session" not in run_kwargs + assert "stream" not in run_kwargs + assert "messages" not in run_kwargs + assert run_kwargs["custom"] == 1 + assert options is not None + assert options["additional_function_arguments"]["custom"] == 1 + + warned_keys = {r.message.split("'")[1] for r in caplog.records if "reserved" in r.message.lower()} + assert warned_keys == {"session", "stream", "messages"} + + +async def test_agent_executor_run_with_messages_kwarg_does_not_raise() -> None: + """Passing messages= via workflow.run() kwargs should not cause a duplicate-keyword TypeError.""" + agent = _CountingAgent(id="messages_kwarg_agent", name="MessagesKwargAgent") + executor = AgentExecutor(agent, id="messages_kwarg_exec") + workflow = SequentialBuilder(participants=[executor]).build() + + result = await workflow.run("hello", messages=["stale"]) + assert result is not None + assert agent.call_count == 1 From b46fe1c82e33f43e6b76cf1c841e536904131100 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 05:57:04 +0900 Subject: [PATCH 04/10] Python: Preserve workflow run kwargs when continuing with `run(responses=...)` (#4296) * fix(python): preserve workflow run kwargs on response continuation (#4293) When continuing a paused workflow with run(responses=...), the existing run kwargs stored in state were unconditionally overwritten with an empty dict. This caused subsequent agent invocations to lose the original run context (e.g., custom_data, user tokens). Now kwargs are only overwritten when: - New kwargs are explicitly provided (override), or - State was just cleared for a fresh run (initialize to {}) On continuation without new kwargs, existing kwargs are preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #4293 - Use consistent get_state(key, {}) default pattern in _agent_executor.py and _workflow_executor.py instead of get_state(key) or {} to safely handle missing WORKFLOW_RUN_KWARGS_KEY - Add test for empty-value kwargs on continuation (custom_data={}) to verify the is-not-None boundary between overwrite and preserve - Add test for reset_context=True with no kwargs to exercise the elif branch that initializes WORKFLOW_RUN_KWARGS_KEY to {} - Add len assertion to override test for consistency - Document kwargs-collapsing behavior at the public API call site Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_workflows/_agent_executor.py | 2 +- .../agent_framework/_workflows/_workflow.py | 15 +- .../_workflows/_workflow_executor.py | 2 +- .../tests/workflow/test_workflow_kwargs.py | 209 ++++++++++++++++++ 4 files changed, 223 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index acec8e48e2..3d8024a35e 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -360,7 +360,7 @@ class AgentExecutor(Executor): Returns: The complete AgentResponse, or None if waiting for user input. """ - run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {}) + run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})) updates: list[AgentResponseUpdate] = [] streamed_user_input_requests: list[Content] = [] diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index cd7dbb4a68..f545fbe5d8 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -345,9 +345,14 @@ class Workflow(DictConvertible): self._runner.context.reset_for_new_run() self._state.clear() - # Store run kwargs in State so executors can access them - # Always store (even empty dict) so retrieval is deterministic - self._state.set(WORKFLOW_RUN_KWARGS_KEY, run_kwargs or {}) + # Store run kwargs in State so executors can access them. + # Only overwrite when new kwargs are explicitly provided or state was + # just cleared (fresh run). On continuation (reset_context=False) with + # no new kwargs, preserve the kwargs from the original run. + if run_kwargs is not None: + self._state.set(WORKFLOW_RUN_KWARGS_KEY, run_kwargs) + elif reset_context: + self._state.set(WORKFLOW_RUN_KWARGS_KEY, {}) self._state.commit() # Commit immediately so kwargs are available # Set streaming mode after reset @@ -564,6 +569,10 @@ class Workflow(DictConvertible): initial_executor_fn=initial_executor_fn, reset_context=reset_context, streaming=streaming, + # Empty **kwargs (no caller-provided kwargs) is collapsed to None so that + # continuation calls without explicit kwargs preserve the original run's kwargs. + # A non-empty kwargs dict (even one with empty values like {"key": {}}) + # is passed through and will overwrite stored kwargs. run_kwargs=kwargs if kwargs else None, ): if event.type == "output" and not self._should_yield_output_event(event): diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 0d2c86070c..e9e4196bfd 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -385,7 +385,7 @@ class WorkflowExecutor(Executor): try: # Get kwargs from parent workflow's State to propagate to subworkflow - parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {} + parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) # Run the sub-workflow and collect all events, passing parent kwargs result = await self.workflow.run(input_data, **parent_kwargs) diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index bf1fd00974..379435e124 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -446,6 +446,215 @@ async def test_kwargs_with_complex_nested_data() -> None: assert received.get("complex_data") == complex_data +async def test_kwargs_preserved_on_response_continuation() -> None: + """Test that run kwargs are preserved when continuing a paused workflow with run(responses=...). + + Regression test for #4293: kwargs were overwritten to {} on continuation calls. + """ + + class _ApprovalCapturingAgent(BaseAgent): + """Agent that pauses for approval on first call and captures kwargs on every call.""" + + captured_kwargs: list[dict[str, Any]] + _asked: bool + + def __init__(self) -> None: + super().__init__(name="approval_agent", description="Test agent") + self.captured_kwargs = [] + self._asked = False + + def run( + self, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + self.captured_kwargs.append(dict(kwargs)) + if not self._asked: + self._asked = True + + async def _pause() -> AgentResponse: + call = Content.from_function_call(call_id="c1", name="do_thing", arguments="{}") + req = Content.from_function_approval_request(id="r1", function_call=call) + return AgentResponse(messages=[Message("assistant", [req])]) + + return _pause() + + async def _done() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", ["done"])]) + + return _done() + + from agent_framework import WorkflowBuilder + + agent = _ApprovalCapturingAgent() + workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build() + + # Initial run with kwargs — workflow should pause for approval + result = await workflow.run("go", custom_data={"token": "abc"}) + request_events = result.get_request_info_events() + assert len(request_events) == 1 + + # Continue with responses only — no new kwargs + approval = request_events[0] + await workflow.run( + responses={approval.request_id: approval.data.to_function_approval_response(True)} + ) + + # Both calls should have received the original kwargs + assert len(agent.captured_kwargs) == 2 + assert agent.captured_kwargs[0].get("custom_data") == {"token": "abc"} + assert agent.captured_kwargs[1].get("custom_data") == {"token": "abc"}, ( + f"kwargs should be preserved on continuation, got: {agent.captured_kwargs[1]}" + ) + + +async def test_kwargs_overridden_on_response_continuation() -> None: + """Test that explicitly provided kwargs override prior kwargs on continuation.""" + + class _ApprovalCapturingAgent(BaseAgent): + captured_kwargs: list[dict[str, Any]] + _asked: bool + + def __init__(self) -> None: + super().__init__(name="approval_agent", description="Test agent") + self.captured_kwargs = [] + self._asked = False + + def run( + self, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + self.captured_kwargs.append(dict(kwargs)) + if not self._asked: + self._asked = True + + async def _pause() -> AgentResponse: + call = Content.from_function_call(call_id="c1", name="do_thing", arguments="{}") + req = Content.from_function_approval_request(id="r1", function_call=call) + return AgentResponse(messages=[Message("assistant", [req])]) + + return _pause() + + async def _done() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", ["done"])]) + + return _done() + + from agent_framework import WorkflowBuilder + + agent = _ApprovalCapturingAgent() + workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build() + + result = await workflow.run("go", custom_data={"token": "abc"}) + request_events = result.get_request_info_events() + approval = request_events[0] + + # Continue with responses AND new kwargs — should override + await workflow.run( + responses={approval.request_id: approval.data.to_function_approval_response(True)}, + custom_data={"token": "xyz"}, + ) + + assert len(agent.captured_kwargs) == 2 + assert agent.captured_kwargs[0].get("custom_data") == {"token": "abc"} + assert agent.captured_kwargs[1].get("custom_data") == {"token": "xyz"} + + +async def test_kwargs_empty_value_passed_on_continuation() -> None: + """Test that explicitly passing a kwarg with an empty value on continuation overrides prior kwargs. + + This exercises the boundary where the caller provides kwargs (e.g., custom_data={}) + that differ from the original run. Because the kwargs dict is non-empty (it has a key), + it passes the `kwargs if kwargs else None` gate and the `is not None` check, so it + overwrites the previously stored kwargs. + """ + + class _ApprovalCapturingAgent(BaseAgent): + captured_kwargs: list[dict[str, Any]] + _asked: bool + + def __init__(self) -> None: + super().__init__(name="approval_agent", description="Test agent") + self.captured_kwargs = [] + self._asked = False + + def run( + self, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + self.captured_kwargs.append(dict(kwargs)) + if not self._asked: + self._asked = True + + async def _pause() -> AgentResponse: + call = Content.from_function_call(call_id="c1", name="do_thing", arguments="{}") + req = Content.from_function_approval_request(id="r1", function_call=call) + return AgentResponse(messages=[Message("assistant", [req])]) + + return _pause() + + async def _done() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", ["done"])]) + + return _done() + + from agent_framework import WorkflowBuilder + + agent = _ApprovalCapturingAgent() + workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build() + + # Initial run with non-empty kwargs + result = await workflow.run("go", custom_data={"token": "abc"}) + request_events = result.get_request_info_events() + assert len(request_events) == 1 + + # Continue with custom_data={} — explicitly clearing the value. + # kwargs={"custom_data": {}} is truthy (has a key), so run_kwargs is set. + approval = request_events[0] + await workflow.run( + responses={approval.request_id: approval.data.to_function_approval_response(True)}, + custom_data={}, + ) + + assert len(agent.captured_kwargs) == 2 + assert agent.captured_kwargs[0].get("custom_data") == {"token": "abc"} + # The continuation explicitly set custom_data={}, overriding the original + assert agent.captured_kwargs[1].get("custom_data") == {} + + +async def test_kwargs_reset_context_stores_empty_dict() -> None: + """Test that reset_context=True with no kwargs stores an empty dict. + + This exercises the `elif reset_context` branch that ensures WORKFLOW_RUN_KWARGS_KEY + is always populated after a fresh run, even when no kwargs are provided. + """ + agent = _KwargsCapturingAgent(name="reset_ctx_test") + + workflow = SequentialBuilder(participants=[agent]).build() + + # Run with no kwargs and reset_context=True (the default for a fresh run) + async for event in workflow.run("test", stream=True): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert len(agent.captured_kwargs) >= 1 + # The only kwarg should be the framework-injected 'options' (no user-provided kwargs) + received = agent.captured_kwargs[0] + assert "custom_data" not in received + assert received.get("options") is None + + async def test_kwargs_preserved_across_workflow_reruns() -> None: """Test that kwargs are correctly isolated between workflow runs.""" agent = _KwargsCapturingAgent(name="rerun_test") From e0461b42c12ed2ca492ad6512583ef1920c649a6 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:07:23 +0900 Subject: [PATCH 05/10] Python: Map file citation annotations from TextDeltaBlock in Assistants API streaming (#4316) (#4320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During Assistants API streaming, TextDeltaBlock.text.annotations was ignored when creating Content objects. This caused raw placeholder strings like 【4:0†source】 to pass through to downstream consumers (including AG-UI) instead of being resolved to citation metadata. Map FileCitationDeltaAnnotation and FilePathDeltaAnnotation from delta_block.text.annotations to Annotation objects on the Content, consistent with the existing patterns in _responses_client.py and _chat_client.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/_assistants_client.py | 50 +++++++- .../openai/test_openai_assistants_client.py | 116 ++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 42a5e32732..dc05411a52 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -16,6 +16,8 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypedDict, cast from openai import AsyncOpenAI from openai.types.beta.threads import ( + FileCitationDeltaAnnotation, + FilePathDeltaAnnotation, ImageURLContentBlockParam, ImageURLParam, MessageContentPartParam, @@ -39,12 +41,14 @@ from .._tools import ( normalize_tools, ) from .._types import ( + Annotation, ChatOptions, ChatResponse, ChatResponseUpdate, Content, Message, ResponseStream, + TextSpanRegion, UsageDetails, ) from ..observability import ChatTelemetryLayer @@ -554,9 +558,53 @@ class OpenAIAssistantsClient( # type: ignore[misc] for delta_block in delta.content or []: if isinstance(delta_block, TextDeltaBlock) and delta_block.text and delta_block.text.value: + text_content = Content.from_text(delta_block.text.value) + if delta_block.text.annotations: + text_content.annotations = [] + for annotation in delta_block.text.annotations: + if isinstance(annotation, FileCitationDeltaAnnotation): + ann: Annotation = Annotation( + type="citation", + additional_properties={ + "text": annotation.text, + "index": annotation.index, + }, + raw_representation=annotation, + ) + if annotation.file_citation and annotation.file_citation.file_id: + ann["file_id"] = annotation.file_citation.file_id + if annotation.start_index is not None and annotation.end_index is not None: + ann["annotated_regions"] = [ + TextSpanRegion( + type="text_span", + start_index=annotation.start_index, + end_index=annotation.end_index, + ) + ] + text_content.annotations.append(ann) + elif isinstance(annotation, FilePathDeltaAnnotation): + ann = Annotation( + type="citation", + additional_properties={ + "text": annotation.text, + "index": annotation.index, + }, + raw_representation=annotation, + ) + if annotation.file_path and annotation.file_path.file_id: + ann["file_id"] = annotation.file_path.file_id + if annotation.start_index is not None and annotation.end_index is not None: + ann["annotated_regions"] = [ + TextSpanRegion( + type="text_span", + start_index=annotation.start_index, + end_index=annotation.end_index, + ) + ] + text_content.annotations.append(ann) yield ChatResponseUpdate( role=role, # type: ignore[arg-type] - contents=[Content.from_text(delta_block.text.value)], + contents=[text_content], conversation_id=thread_id, message_id=response_id, raw_representation=response.data, diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index cf8d74f959..8f39573006 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -7,6 +7,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest from openai.types.beta.threads import MessageDeltaEvent, Run, TextDeltaBlock +from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation +from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation from openai.types.beta.threads.runs import RunStep from pydantic import Field @@ -443,6 +445,120 @@ async def test_process_stream_events_message_delta_text(mock_async_openai: Magic assert update.raw_representation == mock_message_delta +async def test_process_stream_events_message_delta_text_with_file_citation_annotations( + mock_async_openai: MagicMock, +) -> None: + """Test _process_stream_events maps file citation annotations from TextDeltaBlock.""" + client = create_test_openai_assistants_client(mock_async_openai) + + mock_annotation = FileCitationDeltaAnnotation( + index=0, + type="file_citation", + file_citation={"file_id": "file-abc123"}, + start_index=10, + end_index=24, + text="【4:0†source】", + ) + + mock_delta_block = MagicMock(spec=TextDeltaBlock) + mock_delta_block.text = MagicMock() + mock_delta_block.text.value = "Some text 【4:0†source】 more text" + mock_delta_block.text.annotations = [mock_annotation] + + mock_delta = MagicMock() + mock_delta.role = "assistant" + mock_delta.content = [mock_delta_block] + + mock_message_delta = MagicMock(spec=MessageDeltaEvent) + mock_message_delta.delta = mock_delta + + mock_response = MagicMock() + mock_response.event = "thread.message.delta" + mock_response.data = mock_message_delta + + async def async_iterator() -> Any: + yield mock_response + + mock_stream = MagicMock() + mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + thread_id = "thread-789" + updates: list[ChatResponseUpdate] = [] + async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore + updates.append(update) + + assert len(updates) == 1 + update = updates[0] + assert update.text == "Some text 【4:0†source】 more text" + assert update.contents is not None + content = update.contents[0] + assert content.annotations is not None + assert len(content.annotations) == 1 + ann = content.annotations[0] + assert ann["type"] == "citation" + assert ann["file_id"] == "file-abc123" + assert ann["annotated_regions"] is not None + assert ann["annotated_regions"][0]["start_index"] == 10 + assert ann["annotated_regions"][0]["end_index"] == 24 + assert ann["additional_properties"]["text"] == "【4:0†source】" + + +async def test_process_stream_events_message_delta_text_with_file_path_annotations( + mock_async_openai: MagicMock, +) -> None: + """Test _process_stream_events maps file path annotations from TextDeltaBlock.""" + client = create_test_openai_assistants_client(mock_async_openai) + + mock_annotation = FilePathDeltaAnnotation( + index=0, + type="file_path", + file_path={"file_id": "file-xyz789"}, + start_index=5, + end_index=20, + text="sandbox:/path/to/file", + ) + + mock_delta_block = MagicMock(spec=TextDeltaBlock) + mock_delta_block.text = MagicMock() + mock_delta_block.text.value = "Here sandbox:/path/to/file is the file" + mock_delta_block.text.annotations = [mock_annotation] + + mock_delta = MagicMock() + mock_delta.role = "assistant" + mock_delta.content = [mock_delta_block] + + mock_message_delta = MagicMock(spec=MessageDeltaEvent) + mock_message_delta.delta = mock_delta + + mock_response = MagicMock() + mock_response.event = "thread.message.delta" + mock_response.data = mock_message_delta + + async def async_iterator() -> Any: + yield mock_response + + mock_stream = MagicMock() + mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + thread_id = "thread-annotation" + updates: list[ChatResponseUpdate] = [] + async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore + updates.append(update) + + assert len(updates) == 1 + content = updates[0].contents[0] + assert content.annotations is not None + assert len(content.annotations) == 1 + ann = content.annotations[0] + assert ann["type"] == "citation" + assert ann["file_id"] == "file-xyz789" + assert ann["annotated_regions"] is not None + assert ann["annotated_regions"][0]["start_index"] == 5 + assert ann["annotated_regions"][0]["end_index"] == 20 + + async def test_process_stream_events_requires_action(mock_async_openai: MagicMock) -> None: """Test _process_stream_events with thread.run.requires_action event.""" client = create_test_openai_assistants_client(mock_async_openai) From 6f7e55c430ef35ae9f36c806f563d239388419bd Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:07:58 +0900 Subject: [PATCH 06/10] Python: Fix WorkflowAgent not persisting response messages to session history (#1694) (#4319) WorkflowAgent._run_impl() and _run_stream_impl() did not set session_context._response before calling _run_after_providers(). This caused InMemoryHistoryProvider.after_run() to see context.response as None, so response messages were never stored in the session. On subsequent runs, the workflow only received prior user inputs without assistant responses, breaking multi-turn conversations. Fix: Set session_context._response to the workflow result before running after_run providers, matching the behavior of the regular Agent class. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_workflows/_agent.py | 13 +++ .../tests/workflow/test_workflow_agent.py | 86 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 3fb83803c4..bf615814b3 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -270,6 +270,11 @@ class WorkflowAgent(BaseAgent): output_events.append(event) result = self._convert_workflow_events_to_agent_response(response_id, output_events) + + # Set the response on the context so after_run providers (e.g. InMemoryHistoryProvider) + # can persist the response messages alongside input messages. + session_context._response = result # type: ignore[assignment] + await self._run_after_providers(session=provider_session, context=session_context) return result @@ -322,12 +327,20 @@ class WorkflowAgent(BaseAgent): # combine the messages session_messages: list[Message] = session_context.get_messages(include_input=True) + all_updates: list[AgentResponseUpdate] = [] async for event in self._run_core( session_messages, checkpoint_id, checkpoint_storage, streaming=True, **kwargs ): updates = self._convert_workflow_event_to_agent_response_updates(response_id, event) for update in updates: + all_updates.append(update) yield update + + # Build the final response from collected updates so after_run providers + # (e.g. InMemoryHistoryProvider) can persist the response messages. + if all_updates: + session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment] + await self._run_after_providers(session=provider_session, context=session_context) async def _run_core( diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index b2fbded39b..d20d60ba3b 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -578,6 +578,92 @@ class TestWorkflowAgent: assert "first message" in texts assert "second message" in texts + async def test_multi_turn_session_stores_responses(self) -> None: + """Test that WorkflowAgent stores response messages in session history (issue #1694). + + Previously, session_context._response was not set before running after_run + providers, so InMemoryHistoryProvider never persisted response messages. + On subsequent runs the workflow only received prior user inputs, not prior + assistant responses, breaking multi-turn conversations. + """ + capturing_executor = ConversationHistoryCapturingExecutor(id="multi_turn_test", streaming=False) + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = workflow.as_agent(name="Multi Turn Agent") + session = AgentSession() + + # First turn + await agent.run("My name is Bob", session=session) + + # Second turn — the executor should see prior user+assistant messages plus new input + await agent.run("What is my name?", session=session) + + received = capturing_executor.received_messages + roles = [m.role for m in received] + texts = [m.text for m in received] + + # History should include: user("My name is Bob"), assistant(response), user("What is my name?") + assert len(received) == 3, f"Expected 3 messages (user, assistant, user), got {len(received)}: {roles}" + assert roles[0] == "user" + assert "My name is Bob" in (texts[0] or "") + assert roles[1] == "assistant" + assert roles[2] == "user" + assert "What is my name?" in (texts[2] or "") + + async def test_multi_turn_session_stores_responses_streaming(self) -> None: + """Streaming variant: WorkflowAgent stores response messages in session history.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="multi_turn_stream_test", streaming=True) + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = workflow.as_agent(name="Multi Turn Stream Agent") + session = AgentSession() + + # First turn (streaming) + stream = agent.run("Hello", stream=True, session=session) + async for _ in stream: + pass + await stream.get_final_response() + + # Second turn — should include prior history + stream2 = agent.run("Follow up", stream=True, session=session) + async for _ in stream2: + pass + await stream2.get_final_response() + + received = capturing_executor.received_messages + roles = [m.role for m in received] + + assert len(received) == 3, f"Expected 3 messages, got {len(received)}: {roles}" + assert roles[0] == "user" + assert roles[1] == "assistant" + assert roles[2] == "user" + + async def test_multi_turn_session_roundtrip_serialization(self) -> None: + """Test that session can be serialized/deserialized and multi-turn still works.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="roundtrip_test", streaming=False) + workflow = WorkflowBuilder(start_executor=capturing_executor).build() + agent = workflow.as_agent(name="Roundtrip Agent") + session = AgentSession() + + # First turn + await agent.run("My name is Bob", session=session) + + # Serialize and deserialize the session + serialized = session.to_dict() + restored_session = AgentSession.from_dict(serialized) + + # Second turn with restored session + await agent.run("What is my name?", session=restored_session) + + received = capturing_executor.received_messages + roles = [m.role for m in received] + texts = [m.text for m in received] + + assert len(received) == 3, f"Expected 3 messages, got {len(received)}: {roles}" + assert roles[0] == "user" + assert "My name is Bob" in (texts[0] or "") + assert roles[1] == "assistant" + assert roles[2] == "user" + assert "What is my name?" in (texts[2] or "") + async def test_workflow_agent_keeps_explicit_context_providers(self) -> None: """Test that WorkflowAgent does not append defaults when context providers are explicitly provided.""" workflow = WorkflowBuilder( From ff124c44a99129fd720158928d629c7bd8b319cc Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:09:28 +0900 Subject: [PATCH 07/10] Python: Fix single-tool input handling in OpenAIResponsesClient._prepare_tools_for_openai (#4312) * Fix OpenAIResponsesClient mishandling single-tool inputs (#4304) Use normalize_tools() in _prepare_tools_for_openai to wrap single tools (FunctionTool or dict) in a list before iteration, consistent with the chat client implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #4304 - Use precise type annotation matching normalize_tools/OpenAIChatClient signature instead of collapsed Sequence[Any] | Any | None - Move emptiness guard after normalize_tools() call so single falsy tool objects are not silently swallowed - Import ToolTypes for the type annotation - Expand test_prepare_tools_for_openai_single_function_tool assertions to verify parameters, strict, and parameter schema fields - Add test_prepare_tools_for_openai_none to verify None input returns [] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/_responses_client.py | 13 ++++-- .../openai/test_openai_responses_client.py | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index fa140ee0b7..5ba0bbc686 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -43,6 +43,8 @@ from .._tools import ( FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, + ToolTypes, + normalize_tools, ) from .._types import ( Annotation, @@ -425,21 +427,24 @@ class RawOpenAIResponsesClient( # type: ignore[misc] # region Prep methods - def _prepare_tools_for_openai(self, tools: Sequence[Any] | None) -> list[Any]: + def _prepare_tools_for_openai( + self, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None + ) -> list[Any]: """Prepare tools for the OpenAI Responses API. Converts FunctionTool to Responses API format. All other tools pass through unchanged. Args: - tools: Sequence of tools to prepare. + tools: A single tool or sequence of tools to prepare. Returns: List of tool parameters ready for the OpenAI API. """ - if not tools: + tools_list = normalize_tools(tools) + if not tools_list: return [] response_tools: list[Any] = [] - for tool in tools: + for tool in tools_list: if isinstance(tool, FunctionTool): params = tool.parameters() params["additionalProperties"] = False diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 12e5b42d6d..7eaae1e776 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -1193,6 +1193,52 @@ def test_prepare_tools_for_openai_with_mcp() -> None: assert "require_approval" in mcp +def test_prepare_tools_for_openai_single_function_tool() -> None: + """Test that a single FunctionTool (not wrapped in a list) is handled correctly.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + @tool + def hello(name: str) -> str: + """Say hello.""" + return name + + resp_tools = client._prepare_tools_for_openai(hello) + assert isinstance(resp_tools, list) + assert len(resp_tools) == 1 + tool_def = resp_tools[0] + assert tool_def["type"] == "function" + assert tool_def["name"] == "hello" + assert tool_def["strict"] is False + assert "parameters" in tool_def + params = tool_def["parameters"] + assert isinstance(params, dict) + assert params.get("type") == "object" + assert "properties" in params + assert "name" in params["properties"] + assert params["properties"]["name"]["type"] == "string" + + +def test_prepare_tools_for_openai_single_dict_tool() -> None: + """Test that a single dict tool (not wrapped in a list) is handled correctly.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + web_tool = OpenAIResponsesClient.get_web_search_tool(search_context_size="low") + resp_tools = client._prepare_tools_for_openai(web_tool) + assert isinstance(resp_tools, list) + assert len(resp_tools) == 1 + assert "type" in resp_tools[0] + assert resp_tools[0]["search_context_size"] == "low" + + +def test_prepare_tools_for_openai_none() -> None: + """Test that passing None returns an empty list.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + resp_tools = client._prepare_tools_for_openai(None) + assert isinstance(resp_tools, list) + assert len(resp_tools) == 0 + + def test_parse_response_from_openai_with_mcp_approval_request() -> None: """Test that a non-streaming mcp_approval_request is parsed into FunctionApprovalRequestContent.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") From 54c0bea3b6b5c6b396e7ae79724077e4038a252e Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:09:36 +0900 Subject: [PATCH 08/10] Python: Fix agent option merge to support dict-defined tools (#4314) * Fix _merge_options dropping dict-defined tools (#4303) _merge_options used getattr(tool, 'name', None) to de-duplicate tools, which returns None for dict-style tool definitions. This caused all override dict tools to be treated as duplicates of each other and of any base dict tools, silently dropping them. Add _get_tool_name() helper that extracts the name from both object-style tools (via .name attribute) and dict-style tools (via tool['function']['name']). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: fix None dedup bug and add comprehensive tests (#4303) - Exclude None from existing_names set so nameless/malformed tools are not silently deduplicated against each other - Add test for cross-type dedup (dict tool + object tool with same name) - Add test verifying nameless tools are preserved (not falsely deduped) - Add unit tests for _get_tool_name edge cases: missing function key, non-dict function value, missing name, no name attribute, non-dict inputs, and valid dict/object tools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_agents.py | 14 +- .../packages/core/tests/core/test_agents.py | 148 +++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 580b6e2c6d..a519796b17 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -81,6 +81,16 @@ OptionsCoT = TypeVar( ) +def _get_tool_name(tool: Any) -> str | None: + """Extract a tool's name from either an object with a .name attribute or a dict tool definition.""" + if isinstance(tool, dict): + func = tool.get("function") + if isinstance(func, dict): + return func.get("name") + return None + return getattr(tool, "name", None) + + def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: """Merge two options dicts, with override values taking precedence. @@ -97,8 +107,8 @@ def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, continue if key == "tools" and result.get("tools"): # Combine tool lists, avoiding duplicates by name - existing_names = {getattr(t, "name", None) for t in result["tools"]} - unique_new = [t for t in value if getattr(t, "name", None) not in existing_names] + existing_names = {_get_tool_name(t) for t in result["tools"]} - {None} + unique_new = [t for t in value if _get_tool_name(t) not in existing_names] result["tools"] = list(result["tools"]) + unique_new elif key == "logit_bias" and result.get("logit_bias"): # Merge logit_bias dicts diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index b6f84dc970..a857682fe2 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -25,7 +25,7 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) -from agent_framework._agents import _merge_options, _sanitize_agent_name +from agent_framework._agents import _get_tool_name, _merge_options, _sanitize_agent_name from agent_framework._mcp import MCPTool @@ -932,6 +932,152 @@ def test_merge_options_tools_combined(): assert "tool2" in tool_names +def test_merge_options_dict_tools_combined(): + """Test _merge_options combines dict-defined tool lists without duplicates.""" + base = { + "tools": [ + {"type": "function", "function": {"name": "tool_a"}}, + ] + } + override = { + "tools": [ + {"type": "function", "function": {"name": "tool_b"}}, + ] + } + + result = _merge_options(base, override) + + assert len(result["tools"]) == 2 + names = [_get_tool_name(t) for t in result["tools"]] + assert "tool_a" in names + assert "tool_b" in names + + +def test_merge_options_dict_tools_deduplicates(): + """Test _merge_options deduplicates dict-defined tools by function name.""" + base = { + "tools": [ + {"type": "function", "function": {"name": "tool_a"}}, + ] + } + override = { + "tools": [ + {"type": "function", "function": {"name": "tool_a"}}, + {"type": "function", "function": {"name": "tool_b"}}, + ] + } + + result = _merge_options(base, override) + + assert len(result["tools"]) == 2 + names = [_get_tool_name(t) for t in result["tools"]] + assert names.count("tool_a") == 1 + assert "tool_b" in names + + +def test_merge_options_mixed_tools_combined(): + """Test _merge_options combines object and dict-defined tools.""" + + class MockTool: + def __init__(self, name): + self.name = name + + base = {"tools": [MockTool("tool_a")]} + override = { + "tools": [ + {"type": "function", "function": {"name": "tool_b"}}, + ] + } + + result = _merge_options(base, override) + + assert len(result["tools"]) == 2 + names = [_get_tool_name(t) for t in result["tools"]] + assert "tool_a" in names + assert "tool_b" in names + + +def test_merge_options_mixed_tools_deduplicates(): + """Test _merge_options deduplicates when a dict tool and object tool share the same name.""" + + class MockTool: + def __init__(self, name): + self.name = name + + base = {"tools": [MockTool("tool_a")]} + override = { + "tools": [ + {"type": "function", "function": {"name": "tool_a"}}, + ] + } + + result = _merge_options(base, override) + + assert len(result["tools"]) == 1 + assert _get_tool_name(result["tools"][0]) == "tool_a" + + +def test_merge_options_nameless_tools_not_deduplicated(): + """Test that tools with no extractable name (None) are not falsely deduplicated.""" + base = { + "tools": [ + {"type": "function"}, # no 'function.name' -> _get_tool_name returns None + ] + } + override = { + "tools": [ + {"type": "function"}, # also returns None + ] + } + + result = _merge_options(base, override) + + # Both nameless tools should be kept (None is excluded from dedup set) + assert len(result["tools"]) == 2 + + +def test_get_tool_name_dict_no_function_key(): + """_get_tool_name returns None for a dict without a 'function' key.""" + assert _get_tool_name({"type": "function"}) is None + + +def test_get_tool_name_dict_function_not_dict(): + """_get_tool_name returns None when 'function' value is not a dict.""" + assert _get_tool_name({"function": "not_a_dict"}) is None + + +def test_get_tool_name_dict_function_no_name(): + """_get_tool_name returns None when 'function' dict has no 'name' key.""" + assert _get_tool_name({"function": {"description": "does stuff"}}) is None + + +def test_get_tool_name_object_no_name_attr(): + """_get_tool_name returns None for an object without a 'name' attribute.""" + assert _get_tool_name(object()) is None + + +def test_get_tool_name_non_dict_non_object(): + """_get_tool_name returns None for non-dict inputs like int or string.""" + assert _get_tool_name(42) is None + assert _get_tool_name("tool_name") is None + + +def test_get_tool_name_valid_dict(): + """_get_tool_name extracts name from a well-formed dict tool.""" + tool_dict = {"type": "function", "function": {"name": "my_tool"}} + assert _get_tool_name(tool_dict) == "my_tool" + + +def test_get_tool_name_valid_object(): + """_get_tool_name extracts name from an object with a name attribute.""" + + class MockTool: + def __init__(self, name): + self.name = name + + assert _get_tool_name(MockTool("my_tool")) == "my_tool" + + def test_merge_options_logit_bias_merged(): """Test _merge_options merges logit_bias dicts.""" base = {"logit_bias": {"token1": 1.0}} From c45d47d4b24b59e23bceb0625ca2cd8f7259b88b Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 26 Feb 2026 18:45:10 -0800 Subject: [PATCH 09/10] Python: Tuning auto sample validation workflow (#4218) * Tuning validate-01-get-started * Add gh token * Add model * enable debug log * bump up timeout for testing purposes * Test cli is working * Fix end quote * Run gh auth * Run gh auth trail 2 * Run gh auth trail 3 * Test token * Add zcure login * Add zcure login 2 * Add zcure login 3 * Add zcure login 4 * Extract common actions * Extract common actions 2 * Correct env vars * Print outputs to action console * Disable end-to-end samples * Fix ruff errors * Fix ruff errors 2 * Revert workflow changes to fix tests * Revert workflow changes to fix tests 2 * Revert workflow changes to fix tests 3 * Revert workflow changes to fix tests 4 --- .../sample-validation-setup/action.yml | 48 +++++ .../workflows/python-sample-validation.yml | 190 +++++++++--------- .../agent_framework_azurefunctions/_app.py | 6 +- .../packages/core/agent_framework/_types.py | 12 +- .../agent_framework/_workflows/_workflow.py | 1 - .../agent_framework_devui/_deployment.py | 3 +- python/pyproject.toml | 2 + .../create_dynamic_workflow_executor.py | 5 +- python/samples/_sample_validation/report.py | 16 +- 9 files changed, 171 insertions(+), 112 deletions(-) create mode 100644 .github/actions/sample-validation-setup/action.yml diff --git a/.github/actions/sample-validation-setup/action.yml b/.github/actions/sample-validation-setup/action.yml new file mode 100644 index 0000000000..3736348579 --- /dev/null +++ b/.github/actions/sample-validation-setup/action.yml @@ -0,0 +1,48 @@ +name: Sample Validation Setup +description: Sets up the environment for sample validation (checkout, Node.js, Copilot CLI, Azure login, Python) + +inputs: + azure-client-id: + description: Azure Client ID for OIDC login + required: true + azure-tenant-id: + description: Azure Tenant ID for OIDC login + required: true + azure-subscription-id: + description: Azure Subscription ID for OIDC login + required: true + python-version: + description: The Python version to set up + required: false + default: "3.12" + os: + description: The operating system to set up + required: false + default: "Linux" + +runs: + using: "composite" + steps: + - name: Set up Node.js environment + uses: actions/setup-node@v4 + + - name: Install Copilot CLI + shell: bash + run: npm install -g @github/copilot + + - name: Test Copilot CLI + shell: bash + run: copilot -p "What can you do in one sentence?" + + - name: Azure CLI Login + uses: azure/login@v2 + with: + client-id: ${{ inputs.azure-client-id }} + tenant-id: ${{ inputs.azure-tenant-id }} + subscription-id: ${{ inputs.azure-subscription-id }} + + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ inputs.python-version }} + os: ${{ inputs.os }} diff --git a/.github/workflows/python-sample-validation.yml b/.github/workflows/python-sample-validation.yml index ba43394483..1ada1ab113 100644 --- a/.github/workflows/python-sample-validation.yml +++ b/.github/workflows/python-sample-validation.yml @@ -8,32 +8,38 @@ on: env: # Configure a constant location for the uv cache UV_CACHE_DIR: /tmp/.uv-cache + # GitHub Copilot configuration + GITHUB_COPILOT_MODEL: claude-opus-4.6 + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + +permissions: + contents: read + id-token: write jobs: validate-01-get-started: name: Validate 01-get-started runs-on: ubuntu-latest - permissions: - contents: read + environment: integration env: - # Azure AI configuration for get-started samples - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }} - # GitHub Copilot configuration - GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }} + # Required configuration for get-started samples + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} defaults: run: working-directory: python steps: - uses: actions/checkout@v6 - - name: Set up python and install the project - uses: ./.github/actions/python-setup + - name: Setup environment + uses: ./.github/actions/sample-validation-setup with: - python-version: "3.12" + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - name: Run sample validation run: | @@ -49,37 +55,34 @@ jobs: validate-02-agents: name: Validate 02-agents runs-on: ubuntu-latest - permissions: - contents: read + environment: integration env: # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }} + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI_RESPONSES_MODEL_ID }} + OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_NAME }} + OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI_REASONING_MODEL_NAME }} # Observability ENABLE_INSTRUMENTATION: "true" - # GitHub Copilot configuration - GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }} defaults: run: working-directory: python steps: - uses: actions/checkout@v6 - - name: Set up python and install the project - uses: ./.github/actions/python-setup + - name: Setup environment + uses: ./.github/actions/sample-validation-setup with: - python-version: "3.12" + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - name: Run sample validation run: | @@ -95,31 +98,28 @@ jobs: validate-03-workflows: name: Validate 03-workflows runs-on: ubuntu-latest - permissions: - contents: read + environment: integration env: # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }} + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }} - # GitHub Copilot configuration - GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python steps: - uses: actions/checkout@v6 - - name: Set up python and install the project - uses: ./.github/actions/python-setup + - name: Setup environment + uses: ./.github/actions/sample-validation-setup with: - python-version: "3.12" + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - name: Run sample validation run: | @@ -134,31 +134,31 @@ jobs: validate-04-hosting: name: Validate 04-hosting + if: false # Temporarily disabled because of sample complexity runs-on: ubuntu-latest - permissions: - contents: read + environment: integration env: # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }} + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }} - # GitHub Copilot configuration - GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + # A2A configuration + A2A_AGENT_HOST: http://localhost:5001/ defaults: run: working-directory: python steps: - uses: actions/checkout@v6 - - name: Set up python and install the project - uses: ./.github/actions/python-setup + - name: Setup environment + uses: ./.github/actions/sample-validation-setup with: - python-version: "3.12" + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - name: Run sample validation run: | @@ -173,36 +173,36 @@ jobs: validate-05-end-to-end: name: Validate 05-end-to-end + if: false # Temporarily disabled because of sample complexity runs-on: ubuntu-latest - permissions: - contents: read + environment: integration env: # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }} + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure AI Search (for evaluation samples) AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }} AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }} AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }} - # GitHub Copilot configuration - GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }} + # Evaluation sample + AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python steps: - uses: actions/checkout@v6 - - name: Set up python and install the project - uses: ./.github/actions/python-setup + - name: Setup environment + uses: ./.github/actions/sample-validation-setup with: - python-version: "3.12" + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - name: Run sample validation run: | @@ -218,30 +218,31 @@ jobs: validate-autogen-migration: name: Validate autogen-migration runs-on: ubuntu-latest - permissions: - contents: read + environment: integration env: # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }} + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} - # GitHub Copilot configuration - GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + # OpenAI configuration + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_NAME }} + OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI_REASONING_MODEL_NAME }} defaults: run: working-directory: python steps: - uses: actions/checkout@v6 - - name: Set up python and install the project - uses: ./.github/actions/python-setup + - name: Setup environment + uses: ./.github/actions/sample-validation-setup with: - python-version: "3.12" + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - name: Run sample validation run: | @@ -257,16 +258,15 @@ jobs: validate-semantic-kernel-migration: name: Validate semantic-kernel-migration runs-on: ubuntu-latest - permissions: - contents: read + environment: integration env: # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }} + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }} @@ -276,21 +276,19 @@ jobs: COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }} COPILOTSTUDIOAGENT__TENANTID: ${{ secrets.COPILOTSTUDIOAGENT__TENANTID }} COPILOTSTUDIOAGENT__AGENTAPPID: ${{ secrets.COPILOTSTUDIOAGENT__AGENTAPPID }} - # GitHub Copilot configuration - GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }} defaults: run: working-directory: python steps: - uses: actions/checkout@v6 - - name: Set up python and install the project - uses: ./.github/actions/python-setup + - name: Setup environment + uses: ./.github/actions/sample-validation-setup with: - python-version: "3.12" + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - name: Run sample validation run: | diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 01735e28d1..c7d8552b24 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -612,11 +612,11 @@ class AgentFunctionApp(DFAppBase): context: Durable Functions orchestration context invoking the agent. agent_name: Name of the agent registered on this app. - Raises: - ValueError: If the requested agent has not been registered. - Returns: DurableAIAgent[AgentTask] wrapper bound to the orchestration context. + + Raises: + ValueError: If the requested agent has not been registered. """ normalized_name = str(agent_name) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 37ee9f1138..3df0bb20fb 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -93,13 +93,13 @@ def detect_media_type_from_base64( This will look at the actual data to determine the media_type and not at the URI prefix. Will also not compare those two values. - Raises: - ValueError: If not exactly 1 of data_bytes, data_str, or data_uri is provided, or if base64 decoding fails. - Returns: The detected media type (e.g., 'image/png', 'audio/wav', 'application/pdf') or None if the format is not recognized. + Raises: + ValueError: If not exactly 1 of data_bytes, data_str, or data_uri is provided, or if base64 decoding fails. + Examples: .. code-block:: python @@ -670,6 +670,9 @@ class Content: additional_properties: Optional additional properties. raw_representation: Optional raw representation from an underlying implementation. + Returns: + A Content instance with type="data" for data URIs or type="uri" for external URIs. + Raises: ContentError: If the URI is not valid. @@ -693,9 +696,6 @@ class Content: raw_base64_string }" ) - - Returns: - A Content instance with type="data" for data URIs or type="uri" for external URIs. """ return cls( **_validate_uri(uri, media_type), diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index f545fbe5d8..8c6b5fe1fb 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -374,7 +374,6 @@ class Workflow(DictConvertible): with _framework_event_origin(): pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) yield pending_status - # Workflow runs until idle - emit final status based on whether requests are pending if saw_request: with _framework_event_origin(): diff --git a/python/packages/devui/agent_framework_devui/_deployment.py b/python/packages/devui/agent_framework_devui/_deployment.py index 45f99a315a..db2de27ecf 100644 --- a/python/packages/devui/agent_framework_devui/_deployment.py +++ b/python/packages/devui/agent_framework_devui/_deployment.py @@ -92,8 +92,7 @@ class DeploymentManager: break # Get event from queue with short timeout - event = await asyncio.wait_for(event_queue.get(), timeout=0.1) - yield event + yield await asyncio.wait_for(event_queue.get(), timeout=0.1) except asyncio.TimeoutError: # No event in queue, continue waiting continue diff --git a/python/pyproject.toml b/python/pyproject.toml index a03123e9a2..e4e45f0290 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -148,6 +148,8 @@ ignore = [ "**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] "samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201", "CPY"] "*.ipynb" = ["CPY", "E501"] +# RUF070: Assignment before yield is intentional - context manager must exit before yielding +"**/agent_framework/_workflows/_workflow.py" = ["RUF070"] [tool.ruff.format] docstring-code-format = true diff --git a/python/samples/_sample_validation/create_dynamic_workflow_executor.py b/python/samples/_sample_validation/create_dynamic_workflow_executor.py index a8fd2011b4..bff720130d 100644 --- a/python/samples/_sample_validation/create_dynamic_workflow_executor.py +++ b/python/samples/_sample_validation/create_dynamic_workflow_executor.py @@ -53,9 +53,10 @@ class BatchCompletion: AgentInstruction = ( "You are validating exactly one Python sample.\n" - "Analyze the sample code and execute it. Determine if it runs successfully, fails, or times out.\n" + "Analyze the sample code and execute it. Based on the execution result, determine if it " + "runs successfully, fails, or times out. Feel free to install any required dependencies.\n" "The sample can be interactive. If it is interactive, respond to the sample when prompted " - "based on your analysis of the code. You do not need to consult human on what to respond\n" + "based on your analysis of the code. You do not need to consult human on what to respond.\n" "Return ONLY valid JSON with this schema:\n" "{\n" ' "status": "success|failure|timeout|error",\n' diff --git a/python/samples/_sample_validation/report.py b/python/samples/_sample_validation/report.py index d6083f44f6..9d02d342d4 100644 --- a/python/samples/_sample_validation/report.py +++ b/python/samples/_sample_validation/report.py @@ -21,6 +21,14 @@ def generate_report(results: list[RunResult]) -> Report: Returns: Report object with aggregated statistics """ + # Sort results: failures, timeouts, errors first, then successes + status_priority = { + RunStatus.FAILURE: 0, + RunStatus.TIMEOUT: 1, + RunStatus.ERROR: 2, + RunStatus.SUCCESS: 3, + } + sorted_results = sorted(results, key=lambda r: status_priority[r.status]) return Report( timestamp=datetime.now(), @@ -29,7 +37,7 @@ def generate_report(results: list[RunResult]) -> Report: failure_count=sum(1 for r in results if r.status == RunStatus.FAILURE), timeout_count=sum(1 for r in results if r.status == RunStatus.TIMEOUT), error_count=sum(1 for r in results if r.status == RunStatus.ERROR), - results=results, + results=sorted_results, ) @@ -84,9 +92,13 @@ def print_summary(report: Report) -> None: print(f" [PASS] Success: {report.success_count}") print(f" [FAIL] Failure: {report.failure_count}") print(f" [TIMEOUT] Timeout: {report.timeout_count}") - print(f" [ERROR] Error: {report.error_count}") + print(f" [ERR] Errors: {report.error_count}") print("=" * 80) + # Print JSON output for GitHub Actions visibility + print("\nJSON Report:") + print(json.dumps(report.to_dict(), indent=2)) + class GenerateReportExecutor(Executor): """Executor that generates the final validation report.""" From 0d6b9d61a5a7b02a8ca5e60daebc95478bf918aa Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:59:57 +0900 Subject: [PATCH 10/10] Python: Fix executor handler type resolution when using `from __future__ import annotations` (#4317) * Python: Fix Executor handler type checking with __future__ annotations (#3898) Use typing.get_type_hints() in _validate_handler_signature to resolve string annotations from `from __future__ import annotations`. This mirrors the fix applied to FunctionExecutor in #2308. When __future__ annotations are enabled, type annotations are stored as strings. The handler decorator was passing these strings directly to validate_workflow_context_annotation, which uses typing.get_origin and returns None for strings, causing a ValueError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #3898: improve error handling and test coverage - Wrap typing.get_type_hints() in try/except to provide a descriptive ValueError mentioning the handler name when annotations cannot be resolved - Strengthen bare context test to assert output_types and workflow_output_types - Add test for @handler(input=..., output=...) with future annotations covering the skip_message_annotation branch - Add test for union-type context annotations with future annotations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Narrow exception catch and add test for unresolvable annotations (#3898) - Narrow except clause from bare Exception to (NameError, AttributeError, TypeError) to avoid masking unexpected errors. - Add test_handler_unresolvable_annotation_raises to verify that a handler with a forward-reference to a non-existent type raises ValueError with the expected message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix #3898: fall back to raw annotations when get_type_hints fails When typing.get_type_hints(func) raises NameError (unresolvable forward ref), AttributeError, RecursionError, or any other exception, fall back to the raw parameter annotations instead of raising a ValueError. This matches the suggestion from @moonbox3 on PR #4317. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test to match new fallback behavior when get_type_hints fails (#3898) The code now falls back to raw string annotations instead of raising 'Failed to resolve type annotations'. A ValueError is still raised when the raw string ctx annotation is not a valid WorkflowContext type, so update the test to match on ValueError without checking the message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pyupgrade: remove unnecessary string annotation quote * Add noqa for intentionally undefined name in annotation test --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/_workflows/_executor.py | 19 ++- .../tests/workflow/test_executor_future.py | 124 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 python/packages/core/tests/workflow/test_executor_future.py diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index f219c0c28f..d2bb2ac598 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -6,6 +6,7 @@ import functools import inspect import logging import types +import typing from collections.abc import Awaitable, Callable from typing import Any, TypeVar, overload @@ -722,20 +723,30 @@ def _validate_handler_signature( if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty: raise ValueError(f"Handler {func.__name__} must have a type annotation for the message parameter") + # Resolve string annotations from `from __future__ import annotations`. + # Fall back to raw annotations if resolution fails (e.g. unresolvable forward refs, + # AttributeError, or RecursionError), so registration failures are easier to diagnose. + try: + type_hints = typing.get_type_hints(func) + except Exception: + type_hints = {p.name: p.annotation for p in params} + # Validate ctx parameter is WorkflowContext and extract type args ctx_param = params[2] - if skip_message_annotation and ctx_param.annotation == inspect.Parameter.empty: + ctx_annotation = type_hints.get(ctx_param.name, ctx_param.annotation) + if skip_message_annotation and ctx_annotation == inspect.Parameter.empty: # When explicit types are provided via @handler(input=..., output=...), # the ctx parameter doesn't need a type annotation - types come from the decorator. output_types: list[type[Any] | types.UnionType] = [] workflow_output_types: list[type[Any] | types.UnionType] = [] else: output_types, workflow_output_types = validate_workflow_context_annotation( - ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler" + ctx_annotation, f"parameter '{ctx_param.name}'", "Handler" ) - message_type = message_param.annotation if message_param.annotation != inspect.Parameter.empty else None - ctx_annotation = ctx_param.annotation + message_type = type_hints.get(message_param.name, message_param.annotation) + if message_type == inspect.Parameter.empty: + message_type = None return message_type, ctx_annotation, output_types, workflow_output_types diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py new file mode 100644 index 0000000000..c0916b9cf7 --- /dev/null +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -0,0 +1,124 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import BaseModel + +from agent_framework import Executor, WorkflowContext, handler + + +class MyTypeA(BaseModel): + pass + + +class MyTypeB(BaseModel): + pass + + +class MyTypeC(BaseModel): + pass + + +class TestExecutorFutureAnnotations: + """Test suite for Executor with from __future__ import annotations.""" + + def test_handler_decorator_future_annotations(self): + """Test @handler decorator works with stringified annotations (issue #3898).""" + + class MyExecutor(Executor): + @handler + async def example(self, input: str, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["message_type"] is str + assert spec["output_types"] == [MyTypeA] + assert spec["workflow_output_types"] == [MyTypeB] + + def test_handler_decorator_future_annotations_single_type_arg(self): + """Test @handler with single type argument and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, input: int, ctx: WorkflowContext[MyTypeA]) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert int in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["message_type"] is int + assert spec["output_types"] == [MyTypeA] + + def test_handler_decorator_future_annotations_complex(self): + """Test @handler with complex type annotations and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, data: dict[str, Any], ctx: WorkflowContext[list[str]]) -> None: + pass + + exec_instance = MyExecutor(id="test") + spec = exec_instance._handler_specs[0] + assert spec["message_type"] == dict[str, Any] + assert spec["output_types"] == [list[str]] + + def test_handler_decorator_future_annotations_bare_context(self): + """Test @handler with bare WorkflowContext and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, input: str, ctx: WorkflowContext) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["output_types"] == [] + assert spec["workflow_output_types"] == [] + + def test_handler_decorator_future_annotations_explicit_types(self): + """Test @handler with explicit type parameters under future annotations.""" + + class MyExecutor(Executor): + @handler(input=str, output=MyTypeA) + async def example(self, input, ctx) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["message_type"] is str + assert spec["output_types"] == [MyTypeA] + + def test_handler_decorator_future_annotations_union_context(self): + """Test @handler with union type context annotations and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, input: str, ctx: WorkflowContext[MyTypeA | MyTypeB, MyTypeC]) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["output_types"] == [MyTypeA, MyTypeB] + assert spec["workflow_output_types"] == [MyTypeC] + + def test_handler_unresolvable_annotation_raises(self): + """Test that an unresolvable forward-reference annotation raises ValueError. + + When get_type_hints fails (e.g. NameError for NonExistentType), the code falls back + to raw string annotations. The ctx parameter's raw string annotation is then not + recognised as a valid WorkflowContext type, so a ValueError is still raised. + """ + with pytest.raises(ValueError): + + class Bad(Executor): + @handler + async def example(self, input: NonExistentType, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: # noqa: F821 + pass