Merge branch 'main' into copilot/fix-azure-functions-worker-crashes

This commit is contained in:
Laveesh Rohra
2026-03-02 13:08:05 -08:00
committed by GitHub
Unverified
121 changed files with 5159 additions and 320 deletions
@@ -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)
@@ -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
@@ -935,6 +945,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 +958,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)
@@ -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),
@@ -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(
@@ -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] = []
@@ -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)
@@ -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
@@ -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
@@ -369,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():
@@ -564,6 +568,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):
@@ -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)
@@ -180,7 +180,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
client: AzureOpenAIResponsesClient[MyOptions] = AzureOpenAIResponsesClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
if model_id := kwargs.pop("model_id", None) and not deployment_name:
if (model_id := kwargs.pop("model_id", None)) and not deployment_name:
deployment_name = str(model_id)
# Project client path: create OpenAI client from an Azure AI Foundry project
@@ -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,
@@ -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
@@ -90,6 +90,29 @@ def test_init_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_model_id_kwarg(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test that model_id kwarg correctly sets the deployment name (issue #4299)."""
azure_responses_client = AzureOpenAIResponsesClient(model_id="gpt-4o")
assert azure_responses_client.model_id == "gpt-4o"
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_model_id_kwarg_does_not_override_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test that deployment_name takes precedence over model_id kwarg (issue #4299)."""
azure_responses_client = AzureOpenAIResponsesClient(deployment_name="my-deployment", model_id="gpt-4o")
assert azure_responses_client.model_id == "my-deployment"
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_model_id_kwarg_none(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test that model_id=None does not override the env-var deployment name."""
azure_responses_client = AzureOpenAIResponsesClient(model_id=None)
assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
+199 -1
View File
@@ -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
@@ -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()
@@ -880,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}}
@@ -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)
@@ -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")
@@ -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
@@ -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
@@ -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(
@@ -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")
@@ -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
@@ -21,6 +21,13 @@ from .models._discovery_models import EntityInfo
logger = logging.getLogger(__name__)
def _get_event_type(event: Any) -> str | None:
"""Safely get the type of an event, handling both objects and dicts."""
if isinstance(event, dict):
return event.get("type")
return getattr(event, "type", None)
class EntityNotFoundError(Exception):
"""Raised when an entity is not found."""
@@ -264,7 +271,7 @@ class AgentFrameworkExecutor:
elif entity_info.type == "workflow":
async for event in self._execute_workflow(entity_obj, request, trace_collector):
# Log request_info event (type='request_info') for debugging HIL flow
if event.type == "request_info":
if _get_event_type(event) == "request_info":
logger.info(
"🔔 [EXECUTOR] request_info event (type='request_info') detected from workflow!"
)
@@ -330,19 +337,22 @@ class AgentFrameworkExecutor:
# Agent must have run() method - use stream=True for streaming
if hasattr(agent, "run") and callable(agent.run):
# Use Agent Framework's run() with stream=True for streaming
# Capture the stream reference so we can call get_final_response()
# after iteration. This triggers result hooks (after_run providers
# like InMemoryHistoryProvider) that persist conversation history.
run_kwargs: dict[str, Any] = {"stream": True}
if session:
async for update in agent.run(user_message, stream=True, session=session):
for trace_event in trace_collector.get_pending_events():
yield trace_event
run_kwargs["session"] = session
yield update
else:
async for update in agent.run(user_message, stream=True):
for trace_event in trace_collector.get_pending_events():
yield trace_event
stream = agent.run(user_message, **run_kwargs)
async for update in stream:
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield update
yield update
# Finalize stream to trigger result hooks (saves conversation history)
await stream.get_final_response()
else:
raise ValueError("Agent must implement run() method")
@@ -471,7 +481,7 @@ class AgentFrameworkExecutor:
checkpoint_storage=checkpoint_storage,
):
# Enrich new request_info events that may come from subsequent HIL requests
if event.type == "request_info":
if _get_event_type(event) == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -493,7 +503,7 @@ class AgentFrameworkExecutor:
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
):
if event.type == "request_info":
if _get_event_type(event) == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -517,7 +527,7 @@ class AgentFrameworkExecutor:
parsed_input = await self._parse_workflow_input(workflow, request.input)
async for event in workflow.run(parsed_input, stream=True, checkpoint_storage=checkpoint_storage):
if event.type == "request_info":
if _get_event_type(event) == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -741,6 +741,51 @@ async def test_full_pipeline_workflow_output_event_serialization():
assert len(output_events) >= 3, f"Expected 3+ output events for yield_output calls, got {len(output_events)}"
async def test_workflow_error_yields_dict_event_without_crash():
"""Test that workflow errors don't crash execute_entity (#3983).
When a workflow raises an exception, _execute_workflow yields a raw dict
{"type": "error", ...}. The execute_entity caller must handle both dict
events and object events without crashing on attribute access.
"""
from unittest.mock import AsyncMock, MagicMock
from agent_framework_devui.models._discovery_models import EntityInfo
discovery = MagicMock(spec=EntityDiscovery)
mapper = MessageMapper()
executor = AgentFrameworkExecutor(discovery, mapper)
entity_info = EntityInfo(id="bad_wf", name="bad_wf", type="workflow", framework="agent_framework")
discovery.get_entity_info.return_value = entity_info
# Mock workflow whose run() raises
mock_workflow = MagicMock()
mock_workflow.name = "bad_wf"
def failing_run(*args, **kwargs):
raise RuntimeError("Sorry, something went wrong.")
mock_workflow.run = failing_run
discovery.load_entity = AsyncMock(return_value=mock_workflow)
request = AgentFrameworkRequest(
model="test",
input="hello",
metadata={"entity_id": "bad_wf"},
)
events = []
# This should NOT raise AttributeError: 'dict' object has no attribute 'type'
async for event in executor.execute_entity("bad_wf", request):
events.append(event)
# Should get at least one error event
assert len(events) > 0
error_events = [e for e in events if isinstance(e, dict) and e.get("type") == "error"]
assert len(error_events) > 0, f"Expected error dict events, got: {events}"
if __name__ == "__main__":
# Simple test runner
async def run_tests():
@@ -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:
@@ -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])