From b084d0461d74ed282e4d5431e9581e9371aece32 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:19:37 +0900 Subject: [PATCH 01/40] Python: (foundry): stop emitting [TOOLBOXES] warning for every FoundryChatClient call (#5440) * Python: Foundry: make response tool sanitizer internal, drop TOOLBOXES warning sanitize_foundry_response_tool runs on every tool passed to the Foundry Responses API, so its @experimental(TOOLBOXES) decorator was emitting a [TOOLBOXES] ExperimentalWarning for any FoundryChatClient call, even when no toolbox was involved. The function isn't in __all__ and has no external callers. Rename to _sanitize_foundry_response_tool and drop the decorator; the actual toolbox-facing public helpers remain gated. * Python: Foundry: silence pyright on intentional cross-module private import --- python/packages/foundry/agent_framework_foundry/_agent.py | 4 ++-- .../packages/foundry/agent_framework_foundry/_chat_client.py | 4 ++-- python/packages/foundry/agent_framework_foundry/_tools.py | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 0c7f93ba1f..626881b3ac 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -34,7 +34,7 @@ from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from ._tools import sanitize_foundry_response_tool +from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -321,7 +321,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] surface. """ response_tools = super()._prepare_tools_for_openai(tools) - return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: """Extract system/developer messages as instructions for Azure AI. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 735ba9fb57..353268a129 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -33,7 +33,7 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from ._tools import fetch_toolbox, sanitize_foundry_response_tool +from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -235,7 +235,7 @@ class RawFoundryChatClient( # type: ignore[misc] them downstream. """ response_tools = super()._prepare_tools_for_openai(tools) - return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] async def configure_azure_monitor( self, diff --git a/python/packages/foundry/agent_framework_foundry/_tools.py b/python/packages/foundry/agent_framework_foundry/_tools.py index 4c5956fcfe..40b8bf0905 100644 --- a/python/packages/foundry/agent_framework_foundry/_tools.py +++ b/python/packages/foundry/agent_framework_foundry/_tools.py @@ -155,8 +155,7 @@ def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None: ) -@experimental(feature_id=ExperimentalFeature.TOOLBOXES) -def sanitize_foundry_response_tool(tool_item: Any) -> Any: +def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[reportUnusedFunction] """Return a Responses-API-safe tool payload for Foundry hosted tools. Reconciles known mismatches between toolbox reads and the Responses API: From 0989e68d1cee44f07c6b0370106a0a1ccefb10c4 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 23 Apr 2026 16:40:38 -0700 Subject: [PATCH 02/40] Python: Fix user agent prefix (#5455) * Fix hosting user agent missing * Fix other providers * Add more tests * comments * Fix tests --- .../_bedrock_client.py | 4 +- .../agent_framework_anthropic/_chat_client.py | 6 +- .../_foundry_client.py | 6 +- .../_vertex_client.py | 4 +- .../tests/test_anthropic_provider_clients.py | 11 +- .../_context_provider.py | 12 +- .../_checkpoint_storage.py | 4 +- .../_history_provider.py | 5 +- .../agent_framework_bedrock/_chat_client.py | 4 +- .../_embedding_client.py | 4 +- .../core/agent_framework/_telemetry.py | 78 +++++-- .../core/tests/core/test_telemetry.py | 219 ++++++++++++++---- .../foundry/agent_framework_foundry/_agent.py | 4 +- .../agent_framework_foundry/_chat_client.py | 4 +- .../_memory_provider.py | 4 +- .../tests/foundry/test_foundry_chat_client.py | 4 +- .../foundry/test_foundry_memory_provider.py | 5 +- .../_invocations.py | 8 - .../_responses.py | 26 +-- .../agent_framework_gemini/_chat_client.py | 4 +- .../agent_framework_purview/_client.py | 4 +- 21 files changed, 290 insertions(+), 130 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py index 8b7b6bd71b..6c4c32738c 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py @@ -6,13 +6,13 @@ from collections.abc import Sequence from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionInvocationConfiguration, FunctionInvocationLayer, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from anthropic import AsyncAnthropicBedrock @@ -94,7 +94,7 @@ class RawAnthropicBedrockClient(RawAnthropicClient[AnthropicOptionsT], Generic[A aws_profile=settings.get("aws_profile"), aws_session_token=session_token_secret.get_secret_value() if session_token_secret is not None else None, base_url=settings.get("anthropic_bedrock_base_url"), - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) super().__init__( diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 6e5e5f14a6..53d6d6a65e 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -8,7 +8,6 @@ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequenc from typing import Any, ClassVar, Final, Generic, Literal, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, Annotation, BaseChatClient, ChatAndFunctionMiddlewareTypes, @@ -28,6 +27,7 @@ from agent_framework import ( tool, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework._tools import SHELL_TOOL_KIND_VALUE from agent_framework._types import _get_data_bytes_as_str # type: ignore from agent_framework.observability import ChatTelemetryLayer @@ -332,7 +332,7 @@ class RawAnthropicClient( anthropic_client = AsyncAnthropic( api_key=api_key_secret.get_secret_value(), - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) # Initialize parent @@ -604,7 +604,7 @@ class RawAnthropicClient( run_options["betas"] = self._prepare_betas(options) # extra headers - run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT} + run_options["extra_headers"] = {"User-Agent": get_user_agent()} # Handle user option -> metadata.user_id (Anthropic uses metadata.user_id instead of user) if user := run_options.pop("user", None): diff --git a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py index f5fe37296f..997aac0e44 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py @@ -6,13 +6,13 @@ from collections.abc import Awaitable, Callable, Sequence from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionInvocationConfiguration, FunctionInvocationLayer, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from anthropic import AsyncAnthropicFoundry @@ -91,14 +91,14 @@ class RawAnthropicFoundryClient(RawAnthropicClient[AnthropicOptionsT], Generic[A base_url=base_url_setting, api_key=api_key_value, azure_ad_token_provider=azure_ad_token_provider, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) else: anthropic_client = AsyncAnthropicFoundry( resource=resource_setting, api_key=api_key_value, azure_ad_token_provider=azure_ad_token_provider, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) super().__init__( diff --git a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py index 73b16af4a7..7af22be0f0 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py @@ -6,13 +6,13 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionInvocationConfiguration, FunctionInvocationLayer, ) from agent_framework._settings import load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from anthropic import NOT_GIVEN, AsyncAnthropicVertex @@ -89,7 +89,7 @@ class RawAnthropicVertexClient(RawAnthropicClient[AnthropicOptionsT], Generic[An access_token=access_token, credentials=credentials, base_url=settings.get("anthropic_vertex_base_url"), - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) super().__init__( diff --git a/python/packages/anthropic/tests/test_anthropic_provider_clients.py b/python/packages/anthropic/tests/test_anthropic_provider_clients.py index d076062fe6..0fb208b5e4 100644 --- a/python/packages/anthropic/tests/test_anthropic_provider_clients.py +++ b/python/packages/anthropic/tests/test_anthropic_provider_clients.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer +from agent_framework import ChatMiddlewareLayer, FunctionInvocationLayer +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from agent_framework_anthropic import ( @@ -61,7 +62,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path) resource="test-resource", api_key="test-key", azure_ad_token_provider=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) @@ -85,7 +86,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_base_url_settings( base_url="https://test-resource.services.ai.azure.com/anthropic/", api_key="test-key", azure_ad_token_provider=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) @@ -130,7 +131,7 @@ def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> Non aws_profile=None, aws_session_token=None, base_url=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) @@ -152,5 +153,5 @@ def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None access_token=None, credentials=None, base_url=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index de3c190e66..5a0b79f29d 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -14,7 +14,6 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, AgentSession, Annotation, Content, @@ -25,6 +24,7 @@ from agent_framework import ( SupportsGetEmbeddings, load_settings, ) +from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials import AzureKeyCredential, TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -535,7 +535,7 @@ class AzureAISearchContextProvider(ContextProvider): endpoint=self.endpoint, index_name=self.index_name, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) self._index_client: SearchIndexClient | None = None @@ -544,7 +544,7 @@ class AzureAISearchContextProvider(ContextProvider): self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) self._knowledge_base_initialized = False @@ -640,7 +640,7 @@ class AzureAISearchContextProvider(ContextProvider): self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) if not self.index_name: logger.warning("Cannot auto-discover vector field: index_name is not set.") @@ -740,7 +740,7 @@ class AzureAISearchContextProvider(ContextProvider): endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) self._knowledge_base_initialized = True return @@ -802,7 +802,7 @@ class AzureAISearchContextProvider(ContextProvider): endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) async def _agentic_search(self, messages: list[Message]) -> list[Message]: diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py index 496d95d7c3..915eee432b 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -7,8 +7,8 @@ from __future__ import annotations import logging from typing import Any, TypedDict -from agent_framework import AGENT_FRAMEWORK_USER_AGENT from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value from agent_framework.exceptions import WorkflowCheckpointException @@ -194,7 +194,7 @@ class CosmosCheckpointStorage: self._cosmos_client = CosmosClient( url=settings["endpoint"], # type: ignore[arg-type] credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] - user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT, + user_agent_suffix=get_user_agent(), ) self._owns_client = True diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index d13f285249..62a83e6a0f 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -10,9 +10,10 @@ import uuid from collections.abc import Sequence from typing import Any, ClassVar, TypedDict -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message +from agent_framework import Message from agent_framework._sessions import HistoryProvider from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential from azure.cosmos import PartitionKey @@ -121,7 +122,7 @@ class CosmosHistoryProvider(HistoryProvider): self._cosmos_client = CosmosClient( url=settings["endpoint"], # type: ignore[arg-type] credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] - user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT, + user_agent_suffix=get_user_agent(), ) self._owns_client = True diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 3606cdf26b..8e05591372 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -13,7 +13,6 @@ from typing import Any, ClassVar, Generic, Literal, TypedDict from uuid import uuid4 from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, BaseChatClient, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, @@ -31,6 +30,7 @@ from agent_framework import ( validate_tool_mode, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import ChatClientInvalidResponseException from agent_framework.observability import ChatTelemetryLayer from boto3.session import Session as Boto3Session @@ -299,7 +299,7 @@ class BedrockChatClient( self._bedrock_client = session.client( "bedrock-runtime", region_name=region, - config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), + config=BotoConfig(user_agent_extra=get_user_agent()), ) super().__init__( diff --git a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py index 99250b8248..52f5126e3d 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py @@ -11,7 +11,6 @@ from collections.abc import Sequence from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, BaseEmbeddingClient, Embedding, EmbeddingGenerationOptions, @@ -20,6 +19,7 @@ from agent_framework import ( UsageDetails, load_settings, ) +from agent_framework._telemetry import get_user_agent from agent_framework.observability import EmbeddingTelemetryLayer from boto3.session import Session as Boto3Session from botocore.client import BaseClient @@ -140,7 +140,7 @@ class RawBedrockEmbeddingClient( self._bedrock_client = boto3_session.client( "bedrock-runtime", region_name=region_name or resolved_region, - config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), + config=BotoConfig(user_agent_extra=get_user_agent()), ) self.model: str = settings["embedding_model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] diff --git a/python/packages/core/agent_framework/_telemetry.py b/python/packages/core/agent_framework/_telemetry.py index a3b0f74146..f7ca2ce030 100644 --- a/python/packages/core/agent_framework/_telemetry.py +++ b/python/packages/core/agent_framework/_telemetry.py @@ -4,9 +4,6 @@ from __future__ import annotations import logging import os -from collections.abc import Generator -from contextlib import contextmanager -from contextvars import ContextVar from typing import Any, Final from . import __version__ as version_info @@ -29,34 +26,73 @@ USER_AGENT_KEY: Final[str] = "User-Agent" HTTP_USER_AGENT: Final[str] = "agent-framework-python" AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type] -_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=()) +# This environment variable is reserved by the Foundry hosting environment to +# indicate that the agent is running in a hosted environment. +_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT" +# This prefix is added to the user agent string when the agent is running in a hosted environment. +_HOSTED_USER_AGENT_PREFIX = "foundry-hosting" + +_user_agent_prefixes: set[str] = set() +_hosted_env_detected: bool = False -@contextmanager -def user_agent_prefix(prefix: str) -> Generator[None]: - """Context manager that adds a prefix to the user agent string for the current scope. +def _add_user_agent_prefix(prefix: str) -> None: + """Permanently add a prefix to the user agent string. - This is useful for upstream layers that want to identify themselves in telemetry - for the duration of a request without permanently mutating global state. + This is used by hosting layers to identify themselves in telemetry. + Once added, the prefix applies to all subsequent user agent strings. Args: prefix: The prefix to add (e.g. "foundry-hosting"). """ - current = _user_agent_prefixes.get() - token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None + if prefix: + _user_agent_prefixes.add(prefix) + + +def _detect_hosted_environment() -> None: + """Detect if running in a hosted environment and add the user agent prefix. + + Checks the ``FOUNDRY_HOSTING_ENVIRONMENT`` env var first, then falls back + to checking whether the agent server SDK is installed (via + ``importlib.util.find_spec``) before importing it, to avoid unnecessary + import overhead for non-hosted scenarios. + """ + global _hosted_env_detected + if _hosted_env_detected: + return + _hosted_env_detected = True + + env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR) + if env_value is not None: + # Env var exists — trust its value and skip the fallback. + if env_value: + _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) + return + + # Env var not set — fall back to AgentConfig as a second layer of defense. + # Use find_spec to avoid the cost of a full import when the SDK is not installed. + import importlib.util + try: - yield - finally: - if token is not None: - _user_agent_prefixes.reset(token) + if importlib.util.find_spec("azure.ai.agentserver.core") is None: + return + except (ModuleNotFoundError, ValueError): + return + try: + from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports] + + if AgentConfig.from_env().is_hosted: + _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) + except (ImportError, AttributeError): + pass -def _get_user_agent() -> str: - """Return the full user agent string including any context-scoped prefixes.""" - prefixes = _user_agent_prefixes.get() - if not prefixes: +def get_user_agent() -> str: + """Return the full user agent string including any registered prefixes.""" + _detect_hosted_environment() + if not _user_agent_prefixes: return AGENT_FRAMEWORK_USER_AGENT - return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}" + return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}" def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]: @@ -89,7 +125,7 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) """ if not IS_TELEMETRY_ENABLED: return headers or {} - user_agent = _get_user_agent() + user_agent = get_user_agent() if not headers: return {USER_AGENT_KEY: user_agent} headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent diff --git a/python/packages/core/tests/core/test_telemetry.py b/python/packages/core/tests/core/test_telemetry.py index 1ba1df1dde..b0b01706ef 100644 --- a/python/packages/core/tests/core/test_telemetry.py +++ b/python/packages/core/tests/core/test_telemetry.py @@ -1,14 +1,21 @@ # Copyright (c) Microsoft. All rights reserved. -from unittest.mock import patch +import os +from unittest.mock import MagicMock, patch +import agent_framework._telemetry as _telemetry_mod from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, USER_AGENT_KEY, USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, prepend_agent_framework_to_user_agent, ) -from agent_framework._telemetry import user_agent_prefix +from agent_framework._telemetry import ( + _FOUNDRY_HOSTING_ENV_VAR, + _HOSTED_USER_AGENT_PREFIX, + _add_user_agent_prefix, + _detect_hosted_environment, +) # region Test constants @@ -83,7 +90,7 @@ def test_prepend_to_empty_headers(): def test_prepend_to_empty_dict(): """Test prepending to empty headers dict.""" - headers = {} + headers: dict[str, str] = {} result = prepend_agent_framework_to_user_agent(headers) assert "User-Agent" in result @@ -99,54 +106,184 @@ def test_modifies_original_dict(): assert "User-Agent" in headers -# region Test user_agent_prefix context manager +# region Test _add_user_agent_prefix -def test_user_agent_prefix_adds_prefix(): - """Test that the context manager adds a prefix within its scope.""" - with user_agent_prefix("test-host"): - result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"].startswith("test-host/") - assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"] - - # Prefix is removed after exiting the context +def test_add_user_agent_prefix_adds_prefix(): + """Test that _add_user_agent_prefix permanently adds a prefix.""" + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("test-host") result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + assert result["User-Agent"].startswith("test-host/") + assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"] + _telemetry_mod._user_agent_prefixes.clear() -def test_user_agent_prefix_ignores_duplicates(): - """Test that duplicate prefixes are not added within nested scopes.""" - with user_agent_prefix("test-host"), user_agent_prefix("test-host"): - result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"].count("test-host") == 1 +def test_add_user_agent_prefix_ignores_duplicates(): + """Test that duplicate prefixes are not added.""" + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("test-host") + _add_user_agent_prefix("test-host") + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"].count("test-host") == 1 + _telemetry_mod._user_agent_prefixes.clear() -def test_user_agent_prefix_ignores_empty(): +def test_add_user_agent_prefix_ignores_empty(): """Test that empty strings are not added as prefixes.""" - with user_agent_prefix(""): - result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT - - -def test_user_agent_prefix_restores_on_exit(): - """Test that prefixes are fully restored after the context manager exits.""" - with user_agent_prefix("test-host"): - pass + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("") result = prepend_agent_framework_to_user_agent() assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + _telemetry_mod._user_agent_prefixes.clear() -def test_user_agent_prefix_nesting(): - """Test that nested context managers compose prefixes correctly.""" - with user_agent_prefix("outer"): - with user_agent_prefix("inner"): - result = prepend_agent_framework_to_user_agent() - assert "outer" in result["User-Agent"] - assert "inner" in result["User-Agent"] - # Inner prefix removed - result = prepend_agent_framework_to_user_agent() - assert "outer" in result["User-Agent"] - assert "inner" not in result["User-Agent"] - # Both removed +def test_add_user_agent_prefix_multiple(): + """Test that multiple prefixes compose correctly.""" + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("outer") + _add_user_agent_prefix("inner") result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + assert "outer" in result["User-Agent"] + assert "inner" in result["User-Agent"] + _telemetry_mod._user_agent_prefixes.clear() + + +# region Test _detect_hosted_environment + + +def test_detect_hosted_env_var_truthy_adds_prefix(): + """Test that a truthy FOUNDRY_HOSTING_ENVIRONMENT env var adds the prefix.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_env_var_empty_skips_prefix(): + """Test that an empty FOUNDRY_HOSTING_ENVIRONMENT env var does NOT add the prefix.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: ""}): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_env_var_set_skips_agent_config_fallback(): + """Test that when the env var is set, AgentConfig is never consulted even if import would fail.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + import builtins + + real_import = builtins.__import__ + + def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def] + if "agentserver" in name: + raise AssertionError("AgentConfig should not be imported when env var is set") + return real_import(name, *args, **kwargs) + + with ( + patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "prod"}), + patch("builtins.__import__", side_effect=_block_agentconfig), + ): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def _mock_agent_config(*, is_hosted: bool) -> MagicMock: + """Create a mock azure.ai.agentserver.core module with AgentConfig.""" + mock_config = MagicMock() + mock_config.is_hosted = is_hosted + mock_module = MagicMock() + mock_module.AgentConfig.from_env.return_value = mock_config + return mock_module + + +def test_detect_hosted_fallback_agent_config_is_hosted(): + """Test that AgentConfig fallback adds the prefix when is_hosted is True.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR} + mock_module = _mock_agent_config(is_hosted=True) + mock_spec = MagicMock() + with ( + patch.dict("os.environ", env, clear=True), + patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}), + patch("importlib.util.find_spec", return_value=mock_spec), + ): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_fallback_agent_config_not_hosted(): + """Test that AgentConfig fallback does NOT add the prefix when is_hosted is False.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + mock_module = _mock_agent_config(is_hosted=False) + mock_spec = MagicMock() + env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR} + with ( + patch.dict("os.environ", env, clear=True), + patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}), + patch("importlib.util.find_spec", return_value=mock_spec), + ): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_fallback_import_error(): + """Test that ImportError from AgentConfig is silently handled.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR} + with patch.dict("os.environ", env, clear=True): + # The real import may succeed or fail depending on the environment; + # force the ImportError path by making the import raise. + import builtins + + real_import = builtins.__import__ + + def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def] + if "agentserver" in name: + raise ImportError("mocked") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=_block_agentconfig): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +# region Test module-level auto-detection + + +def test_lazy_detection_on_get_user_agent(): + """Test that get_user_agent() lazily detects the hosted environment. + + Since detection is deferred to the first ``get_user_agent()`` call, + this verifies the prefix is included without any explicit call to + ``_detect_hosted_environment()`` by consumer code. + """ + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}): + user_agent = _telemetry_mod.get_user_agent() + + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + assert user_agent.startswith(f"{_HOSTED_USER_AGENT_PREFIX}/") + + # Clean up + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 626881b3ac..b473c787e5 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -15,7 +15,6 @@ from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequen from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, AgentMiddlewareLayer, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, @@ -28,6 +27,7 @@ from agent_framework import ( load_settings, ) from agent_framework._compaction import CompactionStrategy, TokenizerProtocol +from agent_framework._telemetry import get_user_agent from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -190,7 +190,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] project_client_kwargs: dict[str, Any] = { "endpoint": resolved_endpoint, "credential": credential, - "user_agent": AGENT_FRAMEWORK_USER_AGENT, + "user_agent": get_user_agent(), } if allow_preview is not None: project_client_kwargs["allow_preview"] = allow_preview diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 353268a129..4428d69dc6 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -8,7 +8,6 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, Content, FunctionInvocationConfiguration, @@ -17,6 +16,7 @@ from agent_framework import ( ) from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework._feature_stage import ExperimentalFeature, experimental +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -198,7 +198,7 @@ class RawFoundryChatClient( # type: ignore[misc] project_client_kwargs: dict[str, Any] = { "endpoint": project_endpoint, "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, + "user_agent": get_user_agent(), } if allow_preview is not None: project_client_kwargs["allow_preview"] = allow_preview diff --git a/python/packages/foundry/agent_framework_foundry/_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_memory_provider.py index c49f950b6b..169da4fc85 100644 --- a/python/packages/foundry/agent_framework_foundry/_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_memory_provider.py @@ -14,13 +14,13 @@ from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, AgentSession, ContextProvider, Message, SessionContext, load_settings, ) +from agent_framework._telemetry import get_user_agent from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -119,7 +119,7 @@ class FoundryMemoryProvider(ContextProvider): project_client_kwargs: dict[str, Any] = { "endpoint": resolved_endpoint, "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, + "user_agent": get_user_agent(), } if allow_preview is not None: project_client_kwargs["allow_preview"] = allow_preview diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 68e7adc6fb..a7b57029ba 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ChatResponse, Content, Message, SupportsChatGetResponse, tool -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException from agent_framework_openai import OpenAIContentFilterException from azure.ai.projects.models import MCPTool as FoundryMCPTool @@ -199,7 +199,7 @@ def test_init_with_project_endpoint_creates_project_client() -> None: assert factory.call_args.kwargs["endpoint"] == _TEST_FOUNDRY_PROJECT_ENDPOINT assert factory.call_args.kwargs["credential"] is credential assert factory.call_args.kwargs["allow_preview"] is True - assert factory.call_args.kwargs["user_agent"] == AGENT_FRAMEWORK_USER_AGENT + assert factory.call_args.kwargs["user_agent"] == get_user_agent() def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py index 2e25ba38d4..6377dfa602 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py +++ b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py @@ -7,8 +7,9 @@ import os from unittest.mock import AsyncMock, Mock, patch import pytest -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message +from agent_framework import AgentResponse, Message from agent_framework._sessions import AgentSession, SessionContext +from agent_framework._telemetry import get_user_agent from agent_framework_foundry._memory_provider import FoundryMemoryProvider @@ -94,7 +95,7 @@ def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMoc endpoint="https://test.project.endpoint", credential=mock_credential, allow_preview=True, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index b5bf98291b..05105ec768 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework import AgentSession, BaseAgent, SupportsAgentRun -from agent_framework._telemetry import user_agent_prefix from azure.ai.agentserver.invocations import InvocationAgentServerHost from starlette.requests import Request from starlette.responses import JSONResponse, Response, StreamingResponse @@ -11,8 +10,6 @@ from typing_extensions import Any, AsyncGenerator class InvocationsHostServer(InvocationAgentServerHost): """An invocations server host for an agent.""" - USER_AGENT_PREFIX = "foundry-hosting" - def __init__( self, agent: BaseAgent, @@ -42,11 +39,6 @@ class InvocationsHostServer(InvocationAgentServerHost): async def _handle_invoke(self, request: Request) -> Response: """Invoke the agent with the given request.""" - with user_agent_prefix(self.USER_AGENT_PREFIX): - return await self._handle_invoke_inner(request) - - async def _handle_invoke_inner(self, request: Request) -> Response: - """Core invoke handler logic.""" data = await request.json() session_id: str = request.state.session_id diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index cdd4b34b4f..63bc730e78 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -20,7 +20,6 @@ from agent_framework import ( SupportsAgentRun, WorkflowAgent, ) -from agent_framework._telemetry import user_agent_prefix from azure.ai.agentserver.responses import ( ResponseContext, ResponseEventStream, @@ -90,7 +89,6 @@ logger = logging.getLogger(__name__) class ResponsesHostServer(ResponsesAgentServerHost): """A responses server host for an agent.""" - USER_AGENT_PREFIX = "foundry-hosting" # TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally CHECKPOINT_STORAGE_PATH = "/.checkpoints" @@ -150,37 +148,32 @@ class ResponsesHostServer(ResponsesAgentServerHost): self._is_workflow_agent = True self._agent = agent - self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType] + self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType] @staticmethod def _is_streaming_request(request: CreateResponse) -> bool: """Check if the request is a streaming request.""" return request.stream is not None and request.stream is True - async def _handler( + def _handle_response( self, request: CreateResponse, context: ResponseContext, cancellation_signal: asyncio.Event, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: """Handle the creation of a response.""" - with user_agent_prefix(self.USER_AGENT_PREFIX): - async for event in self._handle_inner(request, context, cancellation_signal): - yield event + if self._is_workflow_agent: + # Workflow agents are handled differently because they require checkpoint restoration + return self._handle_workflow_agent(request, context) - async def _handle_inner( + return self._handle_regular_agent(request, context) + + async def _handle_regular_agent( self, request: CreateResponse, context: ResponseContext, - cancellation_signal: asyncio.Event, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: - """Core handler logic.""" - if self._is_workflow_agent: - # Workflow agents are handled differently because they require checkpoint restoration - async for event in self._handle_workflow_agent(request, context, cancellation_signal): - yield event - return - + """Handle the creation of a response for a regular (non-workflow) agent.""" input_text = await context.get_input_text() history = await context.get_history() messages: list[str | Content | Message] = [*_to_messages(history), input_text] @@ -243,7 +236,6 @@ class ResponsesHostServer(ResponsesAgentServerHost): self, request: CreateResponse, context: ResponseContext, - cancellation_signal: asyncio.Event, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: """Handle the creation of a response for a workflow agent. diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index b0fa52a676..59a4033fa2 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -10,7 +10,6 @@ from typing import Any, ClassVar, Generic, cast from uuid import uuid4 from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, BaseChatClient, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, @@ -28,6 +27,7 @@ from agent_framework import ( validate_tool_mode, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from google import genai from google.auth.credentials import Credentials @@ -355,7 +355,7 @@ class RawGeminiChatClient( ) client_kwargs: dict[str, Any] = { - "http_options": {"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}}, + "http_options": {"headers": {"x-goog-api-client": get_user_agent()}}, } if configured_vertexai is not None: client_kwargs["vertexai"] = configured_vertexai diff --git a/python/packages/purview/agent_framework_purview/_client.py b/python/packages/purview/agent_framework_purview/_client.py index af5c3f8224..43c8adce4e 100644 --- a/python/packages/purview/agent_framework_purview/_client.py +++ b/python/packages/purview/agent_framework_purview/_client.py @@ -11,7 +11,7 @@ from typing import Any, Literal, TypeVar, Union, overload from uuid import uuid4 import httpx -from agent_framework import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._telemetry import get_user_agent from agent_framework.observability import get_tracer from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -189,7 +189,7 @@ class PurviewClient: payload = model.model_dump(by_alias=True, exclude_none=True, mode="json") request_headers = { "Authorization": f"Bearer {token}", - "User-Agent": AGENT_FRAMEWORK_USER_AGENT, + "User-Agent": get_user_agent(), "Content-Type": "application/json", } if correlation_id: From 932ceddf95f500e0a8a40e574872d66df5965729 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:12:34 +0900 Subject: [PATCH 03/40] Python: Fix AG-UI reasoning role and multimodal media parsing to follow specification (#5389) * Fix AG-UI reasoning role and multimodal media value field parsing Fix two spec compliance issues in the AG-UI integration: 1. ReasoningMessageStartEvent now uses role='reasoning' instead of role='assistant', matching the AG-UI specification for reasoning messages. 2. _parse_multimodal_media_part now reads the 'value' field from source dicts (with fallback to 'data' for backward compatibility), matching the current AG-UI InputContentSource specification. Bump ag-ui-protocol dependency from ==0.1.13 to >=0.1.16,<0.2 to pick up the SDK fix that accepts role='reasoning' in ReasoningMessageStartEvent. Fix pre-existing pyright reportMissingImports errors for orjson in sample files, and fix import ordering in foundry-hosted-agents sample. Fixes #5340 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix AG-UI reasoning role and multimodal media parsing to follow specification Fixes #5340 * Remove unintended .maf-runtime-ready marker file Address PR review feedback: the .maf-runtime-ready file is not referenced anywhere in the repo and was left over from automation. Fixes #5340 * Python: Fix duplicate AG-UI multimodal 'value' parsing in snapshot path The snapshot normalization path used a second copy of the multimodal source parsing logic that still read the deprecated 'data' field. When clients sent base64 media with source={"type": "base64", "value": ...}, the snapshot event emitted by the server dropped the payload, causing AG-UI-compatible clients to crash on ingest. Extract the shared source-field extraction into _extract_multimodal_source_fields so both _parse_multimodal_media_part and the snapshot _legacy_binary_part stay in sync with the AG-UI spec. Add snapshot-path regression tests covering value-only, value-preferred-over-data, and the legacy data-field fallback. Addresses review feedback on #5389 from @Rickyneer. --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_message_adapters.py | 72 ++++------ .../agent_framework_ag_ui/_run_common.py | 4 +- python/packages/ag-ui/pyproject.toml | 2 +- .../tests/ag_ui/test_message_adapters.py | 135 ++++++++++++++++++ python/packages/ag-ui/tests/ag_ui/test_run.py | 33 ++++- python/uv.lock | 8 +- 6 files changed, 204 insertions(+), 50 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 5e4fced97c..c4d2e9b2cd 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -263,27 +263,21 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]: return unique_messages -def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None: - """Convert a multimodal media part into Agent Framework content.""" - part_type = str(part.get("type", "")).lower() - source = part.get("source") +def _extract_multimodal_source_fields( + part: dict[str, Any], +) -> tuple[str | None, str | None, str | None, str | None]: + """Extract ``(url, data, binary_id, mime_type)`` from an AG-UI multimodal part. - mime_type = cast( - str | None, - part.get("mimeType") - or part.get("mime_type") - or { - "image": "image/*", - "audio": "audio/*", - "video": "video/*", - "document": "application/octet-stream", - "binary": "application/octet-stream", - }.get(part_type, "application/octet-stream"), - ) + Handles both the current AG-UI spec (``source.value`` for base64 payloads) and the + legacy ``source.data`` field for backward compatibility. Returned values are the + raw extracted strings (or ``None`` when absent); callers apply their own defaults. + """ + mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type")) url = cast(str | None, part.get("url") or part.get("uri")) data = cast(str | None, part.get("data")) binary_id = cast(str | None, part.get("id")) + source = part.get("source") if isinstance(source, dict): source_dict = cast(dict[str, Any], source) source_type = str(source_dict.get("type", "")).lower() @@ -294,14 +288,31 @@ def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None: if source_type in {"url", "uri"}: url = cast(str | None, source_dict.get("url") or source_dict.get("uri")) elif source_type in {"base64", "data", "binary"}: - data = cast(str | None, source_dict.get("data")) + data = cast(str | None, source_dict.get("value") or source_dict.get("data")) elif source_type in {"id", "file"}: binary_id = cast(str | None, source_dict.get("id")) else: url = cast(str | None, source_dict.get("url") or source_dict.get("uri") or url) - data = cast(str | None, source_dict.get("data") or data) + data = cast(str | None, source_dict.get("value") or source_dict.get("data") or data) binary_id = cast(str | None, source_dict.get("id") or binary_id) + return url, data, binary_id, mime_type + + +def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None: + """Convert a multimodal media part into Agent Framework content.""" + part_type = str(part.get("type", "")).lower() + url, data, binary_id, mime_type = _extract_multimodal_source_fields(part) + + if not mime_type: + mime_type = { + "image": "image/*", + "audio": "audio/*", + "video": "video/*", + "document": "application/octet-stream", + "binary": "application/octet-stream", + }.get(part_type, "application/octet-stream") + if isinstance(url, str) and url: return Content.from_uri(uri=url, media_type=mime_type) @@ -389,30 +400,7 @@ def _normalize_snapshot_content(content: Any) -> Any: def _legacy_binary_part(part: dict[str, Any]) -> dict[str, Any]: """Convert draft/legacy multimodal parts to AG-UI snapshot binary shape.""" normalized: dict[str, Any] = {"type": "binary"} - - mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type")) - url = cast(str | None, part.get("url") or part.get("uri")) - data = cast(str | None, part.get("data")) - binary_id = cast(str | None, part.get("id")) - - source = part.get("source") - if isinstance(source, dict): - source_part = cast(dict[str, Any], source) - source_mime = source_part.get("mimeType") or source_part.get("mime_type") - if isinstance(source_mime, str) and source_mime: - mime_type = source_mime - - source_type = str(source_part.get("type", "")).lower() - if source_type in {"url", "uri"}: - url = cast(str | None, source_part.get("url") or source_part.get("uri")) - elif source_type in {"base64", "data", "binary"}: - data = cast(str | None, source_part.get("data")) - elif source_type in {"id", "file"}: - binary_id = cast(str | None, source_part.get("id")) - else: - url = cast(str | None, source_part.get("url") or source_part.get("uri") or url) - data = cast(str | None, source_part.get("data") or data) - binary_id = cast(str | None, source_part.get("id") or binary_id) + url, data, binary_id, mime_type = _extract_multimodal_source_fields(part) if isinstance(mime_type, str) and mime_type: normalized["mimeType"] = mime_type diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 58236cdf0e..b89124ca16 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -596,7 +596,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis events.extend(_close_reasoning_block(flow)) # Open new reasoning block. events.append(ReasoningStartEvent(message_id=message_id)) - events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning")) flow.reasoning_message_id = message_id if text: @@ -613,7 +613,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis else: # No flow -- backward-compatible full sequence per call. events.append(ReasoningStartEvent(message_id=message_id)) - events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning")) if text: events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 0bba0b3006..5c79328794 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.1.1,<2", - "ag-ui-protocol==0.1.13", + "ag-ui-protocol>=0.1.16,<0.2", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" ] diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index 9508b53085..69f7b7bdb3 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -536,6 +536,77 @@ def test_agui_snapshot_format_preserves_multimodal_content(): assert content_parts[1]["url"] == "https://example.com/image.png" +def test_agui_snapshot_format_reads_base64_value_field(): + """Snapshot normalization reads the spec 'value' field for base64 sources.""" + payload = base64.b64encode(b"abc").decode("utf-8") + normalized = agui_messages_to_snapshot_format( + [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": {"type": "base64", "value": payload, "mimeType": "image/png"}, + }, + ], + } + ] + ) + + binary_part = normalized[0]["content"][0] + assert binary_part["type"] == "binary" + assert binary_part["mimeType"] == "image/png" + assert binary_part["data"] == payload + + +def test_agui_snapshot_format_base64_value_preferred_over_data(): + """Snapshot normalization prefers 'value' when both 'value' and 'data' are set.""" + value_payload = base64.b64encode(b"new-spec").decode("utf-8") + data_payload = base64.b64encode(b"legacy").decode("utf-8") + normalized = agui_messages_to_snapshot_format( + [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "value": value_payload, + "data": data_payload, + "mimeType": "image/png", + }, + }, + ], + } + ] + ) + + binary_part = normalized[0]["content"][0] + assert binary_part["data"] == value_payload + + +def test_agui_snapshot_format_base64_data_field_backward_compat(): + """Snapshot normalization still reads the legacy 'data' field when 'value' is absent.""" + payload = base64.b64encode(b"legacy").decode("utf-8") + normalized = agui_messages_to_snapshot_format( + [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": {"type": "base64", "data": payload, "mimeType": "image/png"}, + }, + ], + } + ] + ) + + binary_part = normalized[0]["content"][0] + assert binary_part["data"] == payload + + def test_agui_with_tool_calls_to_agent_framework(): """Assistant message with tool_calls is converted to FunctionCallContent.""" agui_msg = { @@ -1760,3 +1831,67 @@ class TestReasoningRoundTrip: assert "First answer" in texts assert "Follow-up question" in texts assert "Prior reasoning" not in texts + + +def test_parse_multimodal_media_part_base64_value_field(): + """Source with type='base64' reads data from the 'value' field per AG-UI spec.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "base64", "value": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri + + +def test_parse_multimodal_media_part_data_source_value_field(): + """Source with type='data' reads data from the 'value' field per AG-UI spec.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "data", "value": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri + + +def test_parse_multimodal_media_part_base64_data_field_backward_compat(): + """Source with type='base64' still supports deprecated 'data' field.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "base64", "data": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri + + +def test_parse_multimodal_media_part_value_preferred_over_data(): + """When both 'value' and 'data' are present, 'value' takes precedence.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + { + "type": "image", + "source": { + "type": "base64", + "value": "dmFsdWU=", + "data": "ZGF0YQ==", + "mimeType": "image/png", + }, + } + ) + assert result is not None + # 'value' field content should be used (base64 of "value") + assert "dmFsdWU=" in result.uri + + +def test_parse_multimodal_media_part_unknown_source_value_fallback(): + """Unknown source type falls back to 'value' field before 'data' field.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "custom", "value": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 70af4c064a..a901b61e6e 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -1244,7 +1244,7 @@ class TestEmitTextReasoning: assert events[0].message_id == "reason_1" assert isinstance(events[1], ReasoningMessageStartEvent) assert events[1].message_id == "reason_1" - assert events[1].role == "assistant" + assert events[1].role == "reasoning" assert isinstance(events[2], ReasoningMessageContentEvent) assert events[2].message_id == "reason_1" assert events[2].delta == "The user is asking about weather, so I should call the weather tool." @@ -1642,6 +1642,37 @@ class TestReasoningInSnapshot: assert close[0].message_id == "block2" +class TestReasoningEventRole: + """Tests that reasoning events use role='reasoning' per AG-UI spec.""" + + def test_reasoning_role_without_flow(self): + """ReasoningMessageStartEvent uses role='reasoning' in non-flow mode.""" + content = Content.from_text_reasoning( + id="reason_role_1", + text="Thinking about the question.", + ) + + events = _emit_text_reasoning(content) + + msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)] + assert len(msg_starts) == 1 + assert msg_starts[0].role == "reasoning" + + def test_reasoning_role_with_flow(self): + """ReasoningMessageStartEvent uses role='reasoning' in streaming flow mode.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_role_2", + text="Reasoning in streaming mode.", + ) + + events = _emit_text_reasoning(content, flow) + + msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)] + assert len(msg_starts) == 1 + assert msg_starts[0].role == "reasoning" + + async def test_session_id_matches_thread_id(): """Session created by run_agent_stream uses the client thread_id as session_id.""" from conftest import StubAgent diff --git a/python/uv.lock b/python/uv.lock index a1acd778bb..ecaed87fe4 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -84,14 +84,14 @@ wheels = [ [[package]] name = "ag-ui-protocol" -version = "0.1.13" +version = "0.1.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/b5/fc0b65b561d00d88811c8a7d98ee735833f81554be244340950e7b65820c/ag_ui_protocol-0.1.13.tar.gz", hash = "sha256:811d7d7dcce4783dec252918f40b717ebfa559399bf6b071c4ba47c0c1e21bcb", size = 5671, upload-time = "2026-02-19T18:40:38.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0f/5a8ce5eb5cd7adf3f733da87d7a5a8a38f24ec9e029c1300f9495ae9c7fa/ag_ui_protocol-0.1.17.tar.gz", hash = "sha256:5fae4cfced8245c8ac329b85702fd166ff226d99dc1ea8a1ae95890826aa69e5", size = 6273, upload-time = "2026-04-20T21:09:19.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/9f/b833c1ab1999da35ebad54841ae85d2c2764c931da9a6f52d8541b6901b2/ag_ui_protocol-0.1.13-py3-none-any.whl", hash = "sha256:1393fa894c1e8416efe184168a50689e760d05b32f4646eebb8ff423dddf8e8f", size = 8053, upload-time = "2026-02-19T18:40:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/a0/82/e5f1686b4c4e232818c75598d651b82088f584e59adef71802920d74f82f/ag_ui_protocol-0.1.17-py3-none-any.whl", hash = "sha256:6a9065590d21c7b9b8ae9bb1a3410ecf4d18cb8041a077d25574f792dfa504fe", size = 8648, upload-time = "2026-04-20T21:09:20.488Z" }, ] [[package]] @@ -183,7 +183,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ag-ui-protocol", specifier = "==0.1.13" }, + { name = "ag-ui-protocol", specifier = ">=0.1.16,<0.2" }, { name = "agent-framework-core", editable = "packages/core" }, { name = "fastapi", specifier = ">=0.115.0,<0.133.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" }, From 4adfd244acb042c03e624ebd027166f18aab53d5 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Fri, 24 Apr 2026 00:27:17 -0700 Subject: [PATCH 04/40] Python: Upgrade hosting server dependency and add more type support (#5459) * Upgrade hosting server dependency and add more type support * Comments --- .../_responses.py | 295 +++- .../packages/foundry_hosting/pyproject.toml | 8 +- .../foundry_hosting/tests/test_responses.py | 1219 ++++++++++++++++- python/uv.lock | 26 +- 4 files changed, 1492 insertions(+), 56 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 63bc730e78..cac0ac3790 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -28,13 +28,35 @@ from azure.ai.agentserver.responses import ( ) from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost from azure.ai.agentserver.responses.models import ( + ApplyPatchToolCallItemParam, + ApplyPatchToolCallOutputItemParam, + ComputerCallOutputItemParam, ComputerScreenshotContent, CreateResponse, FunctionCallOutputItemParam, FunctionShellAction, + FunctionShellCallItemParam, FunctionShellCallOutputContent, FunctionShellCallOutputExitOutcome, + FunctionShellCallOutputItemParam, + Item, + ItemCodeInterpreterToolCall, + ItemComputerToolCall, + ItemCustomToolCall, + ItemCustomToolCallOutput, + ItemFileSearchToolCall, + ItemFunctionToolCall, + ItemImageGenToolCall, + ItemLocalShellToolCall, + ItemLocalShellToolCallOutput, + ItemMcpApprovalRequest, + ItemMcpToolCall, + ItemMessage, + ItemOutputMessage, + ItemReasoningItem, + ItemWebSearchToolCall, LocalEnvironmentResource, + MCPApprovalResponse, MessageContent, MessageContentInputFileContent, MessageContentInputImageContent, @@ -174,9 +196,11 @@ class ResponsesHostServer(ResponsesAgentServerHost): context: ResponseContext, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: """Handle the creation of a response for a regular (non-workflow) agent.""" - input_text = await context.get_input_text() + input_items = await context.get_input_items() + input_messages = _items_to_messages(input_items) + history = await context.get_history() - messages: list[str | Content | Message] = [*_to_messages(history), input_text] + messages: list[str | Content | Message] = [*_output_items_to_messages(history), *input_messages] chat_options, are_options_set = _to_chat_options(request) @@ -243,7 +267,9 @@ class ResponsesHostServer(ResponsesAgentServerHost): The sandbox may be deactivated after some period of inactivity, and only data managed by the hosting infrastructure or files will be preserved upon deactivation. """ - input_text = await context.get_input_text() + input_items = await context.get_input_items() + input_messages = _items_to_messages(input_items) + is_streaming_request = self._is_streaming_request(request) _, are_options_set = _to_chat_options(request) @@ -296,7 +322,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): if not is_streaming_request: # Run the agent in non-streaming mode - response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage) + response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage) for message in response.messages: for content in message.contents: @@ -308,7 +334,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): return # Run the agent in streaming mode - response_stream = self._agent.run(input_text, stream=True, checkpoint_storage=checkpoint_storage) + response_stream = self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage) # Track the current active output item builder for streaming; # lazily created on matching content, closed when a different type arrives. @@ -532,7 +558,260 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]: # region Input Message Conversion -def _to_messages(history: Sequence[OutputItem]) -> list[Message]: +def _items_to_messages(input_items: Sequence[Item]) -> list[Message]: + """Converts a sequence of input items to a list of Messages, one per item. + + Args: + input_items: The input items to convert. + + Returns: + A list of Messages, one per supported input item. + """ + messages: list[Message] = [] + for item in input_items: + messages.append(_item_to_message(item)) + return messages + + +def _item_to_message(item: Item) -> Message: + """Converts an Item to a Message. + + Args: + item: The Item to convert. + + Returns: + The converted Message. + + Raises: + ValueError: If the Item type is not supported. + """ + if item.type == "message": + msg = cast(ItemMessage, item) + if isinstance(msg.content, str): + return Message(role=msg.role, contents=[Content.from_text(msg.content)]) + return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content]) + + if item.type == "output_message": + output_msg = cast(ItemOutputMessage, item) + return Message( + role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content] + ) + + if item.type == "function_call": + fc = cast(ItemFunctionToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)], + ) + + if item.type == "function_call_output": + fco = cast(FunctionCallOutputItemParam, item) + output = fco.output if isinstance(fco.output, str) else str(fco.output) + return Message( + role="tool", + contents=[Content.from_function_result(fco.call_id, result=output)], + ) + + if item.type == "reasoning": + reasoning = cast(ItemReasoningItem, item) + reason_contents: list[Content] = [] + if reasoning.summary: + for summary in reasoning.summary: + reason_contents.append(Content.from_text(summary.text)) + return Message(role="assistant", contents=reason_contents) + + if item.type == "mcp_call": + mcp = cast(ItemMcpToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + mcp.id, + mcp.name, + server_name=mcp.server_label, + arguments=mcp.arguments, + ) + ], + ) + + if item.type == "mcp_approval_request": + mcp_req = cast(ItemMcpApprovalRequest, item) + mcp_call_content = Content.from_mcp_server_tool_call( + mcp_req.id, + mcp_req.name, + server_name=mcp_req.server_label, + arguments=mcp_req.arguments, + ) + return Message( + role="assistant", + contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)], + ) + + if item.type == "mcp_approval_response": + mcp_resp = cast(MCPApprovalResponse, item) + placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval") + return Message( + role="user", + contents=[ + Content.from_function_approval_response( + mcp_resp.approve, mcp_resp.approval_request_id, placeholder_content + ) + ], + ) + + if item.type == "code_interpreter_call": + ci = cast(ItemCodeInterpreterToolCall, item) + return Message( + role="assistant", + contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)], + ) + + if item.type == "image_generation_call": + ig = cast(ItemImageGenToolCall, item) + return Message( + role="assistant", + contents=[Content.from_image_generation_tool_call(image_id=ig.id)], + ) + + if item.type == "shell_call": + sc = cast(FunctionShellCallItemParam, item) + return Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=sc.call_id, + commands=sc.action.commands, + status=str(sc.status), + ) + ], + ) + + if item.type == "shell_call_output": + sco = cast(FunctionShellCallOutputItemParam, item) + outputs = [ + Content.from_shell_command_output( + stdout=out.stdout or "", + stderr=out.stderr or "", + exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None, + ) + for out in (sco.output or []) + ] + return Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=sco.call_id, + outputs=outputs, + max_output_length=sco.max_output_length, + ) + ], + ) + + if item.type == "local_shell_call": + lsc = cast(ItemLocalShellToolCall, item) + commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else [] + return Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=lsc.call_id, + commands=commands, + status=str(lsc.status), + ) + ], + ) + + if item.type == "local_shell_call_output": + lsco = cast(ItemLocalShellToolCallOutput, item) + return Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=lsco.id, + outputs=[Content.from_shell_command_output(stdout=lsco.output)], + ) + ], + ) + + if item.type == "file_search_call": + fs = cast(ItemFileSearchToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + fs.id, + "file_search", + arguments=json.dumps({"queries": fs.queries}), + ) + ], + ) + + if item.type == "web_search_call": + ws = cast(ItemWebSearchToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(ws.id, "web_search")], + ) + + if item.type == "computer_call": + cc = cast(ItemComputerToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + cc.call_id, + "computer_use", + arguments=str(cc.action), + ) + ], + ) + + if item.type == "computer_call_output": + cco = cast(ComputerCallOutputItemParam, item) + return Message( + role="tool", + contents=[Content.from_function_result(cco.call_id, result=str(cco.output))], + ) + + if item.type == "custom_tool_call": + ct = cast(ItemCustomToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)], + ) + + if item.type == "custom_tool_call_output": + cto = cast(ItemCustomToolCallOutput, item) + output = cto.output if isinstance(cto.output, str) else str(cto.output) + return Message( + role="tool", + contents=[Content.from_function_result(cto.call_id, result=output)], + ) + + if item.type == "apply_patch_call": + ap = cast(ApplyPatchToolCallItemParam, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + ap.call_id, + "apply_patch", + arguments=str(ap.operation), + ) + ], + ) + + if item.type == "apply_patch_call_output": + apo = cast(ApplyPatchToolCallOutputItemParam, item) + return Message( + role="tool", + contents=[Content.from_function_result(apo.call_id, result=apo.output or "")], + ) + + raise ValueError(f"Unsupported Item type: {item.type}") + + +def _output_items_to_messages(history: Sequence[OutputItem]) -> list[Message]: """Converts a sequence of OutputItem objects to a list of Message objects. Args: @@ -543,11 +822,11 @@ def _to_messages(history: Sequence[OutputItem]) -> list[Message]: """ messages: list[Message] = [] for item in history: - messages.append(_to_message(item)) + messages.append(_output_item_to_message(item)) return messages -def _to_message(item: OutputItem) -> Message: +def _output_item_to_message(item: OutputItem) -> Message: """Converts an OutputItem to a Message. Args: diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index fcd1e8e21b..a9d0393a1d 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260423" +version = "1.0.0a260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,9 +24,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.1.1,<2", - "azure-ai-agentserver-core==2.0.0b2", - "azure-ai-agentserver-responses==1.0.0b4", - "azure-ai-agentserver-invocations==1.0.0b2", + "azure-ai-agentserver-core==2.0.0b3", + "azure-ai-agentserver-responses==1.0.0b5", + "azure-ai-agentserver-invocations==1.0.0b3", ] [tool.uv] diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index f30d033009..13538b6c9a 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -29,7 +29,10 @@ from azure.ai.agentserver.responses import InMemoryResponseProvider from typing_extensions import Any from agent_framework_foundry_hosting import ResponsesHostServer -from agent_framework_foundry_hosting._responses import _to_message # pyright: ignore[reportPrivateUsage] +from agent_framework_foundry_hosting._responses import ( + _item_to_message, # pyright: ignore[reportPrivateUsage] + _output_item_to_message, # pyright: ignore[reportPrivateUsage] +) # region Helpers @@ -525,11 +528,11 @@ class TestStreaming: # endregion -# region _to_message conversion +# region _output_item_to_message conversion -class TestToMessage: - """Tests for _to_message covering all supported OutputItem types.""" +class TestOutputItemToMessage: + """Tests for _output_item_to_message covering all supported OutputItem types.""" def test_output_message(self) -> None: from azure.ai.agentserver.responses.models import OutputItemOutputMessage, OutputMessageContentOutputTextContent @@ -541,7 +544,7 @@ class TestToMessage: "status": "completed", "id": "msg-1", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert len(msg.contents) == 1 assert msg.contents[0].type == "text" @@ -555,7 +558,7 @@ class TestToMessage: "role": "user", "content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "user" assert len(msg.contents) == 1 assert msg.contents[0].text == "hi" @@ -571,7 +574,7 @@ class TestToMessage: "status": "completed", "id": "fc-1", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].call_id == "call_1" @@ -581,7 +584,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"}) - msg = _to_message(item) # type: ignore[arg-type] + msg = _output_item_to_message(item) # type: ignore[arg-type] assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].call_id == "call_1" @@ -595,7 +598,7 @@ class TestToMessage: "id": "r-1", "summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert len(msg.contents) == 1 assert msg.contents[0].text == "thinking hard" @@ -604,7 +607,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import OutputItemReasoningItem item = OutputItemReasoningItem({"type": "reasoning", "id": "r-2"}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents == [] @@ -618,7 +621,7 @@ class TestToMessage: "name": "search", "arguments": '{"q": "test"}', }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "mcp_server_tool_call" assert msg.contents[0].server_name == "my_server" @@ -634,7 +637,7 @@ class TestToMessage: "name": "dangerous_tool", "arguments": "{}", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_approval_request" @@ -647,7 +650,7 @@ class TestToMessage: "approval_request_id": "apr-1", "approve": True, }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "user" assert msg.contents[0].type == "function_approval_response" assert msg.contents[0].approved is True @@ -663,7 +666,7 @@ class TestToMessage: "code": "print('hi')", "outputs": [], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "code_interpreter_tool_call" @@ -671,7 +674,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import OutputItemImageGenToolCall item = OutputItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "image_generation_tool_call" @@ -690,7 +693,7 @@ class TestToMessage: "status": "completed", "environment": FunctionShellCallEnvironment({"type": "local"}), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["ls", "-la"] @@ -717,7 +720,7 @@ class TestToMessage: ], "max_output_length": 1024, }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "shell_tool_result" assert msg.contents[0].call_id == "call_sc" @@ -732,7 +735,7 @@ class TestToMessage: "action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}), "status": "completed", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["echo", "hello"] @@ -745,7 +748,7 @@ class TestToMessage: "id": "lsco-1", "output": "hello\n", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "shell_tool_result" @@ -758,7 +761,7 @@ class TestToMessage: "status": "completed", "queries": ["what is AI"], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "file_search" @@ -773,7 +776,7 @@ class TestToMessage: "status": "completed", "action": WebSearchActionSearch({"type": "search", "query": "test"}), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "web_search" @@ -789,7 +792,7 @@ class TestToMessage: "pending_safety_checks": [], "status": "completed", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "computer_use" @@ -808,7 +811,7 @@ class TestToMessage: "image_url": "data:image/png;base64,abc", }), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].call_id == "call_cc" @@ -822,7 +825,7 @@ class TestToMessage: "name": "my_tool", "input": '{"key": "value"}', }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "my_tool" @@ -836,7 +839,7 @@ class TestToMessage: "call_id": "call_ct", "output": "result text", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].result == "result text" @@ -855,7 +858,7 @@ class TestToMessage: "diff": "+ new line", }), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "apply_patch" @@ -870,7 +873,7 @@ class TestToMessage: "status": "completed", "output": "patch applied", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].result == "patch applied" @@ -884,7 +887,7 @@ class TestToMessage: "consent_link": "https://example.com/consent", "server_label": "my_server", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "oauth_consent_request" assert msg.contents[0].consent_link == "https://example.com/consent" @@ -893,7 +896,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-1", "output": {"answer": 42}}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "text" assert json.loads(msg.contents[0].text or "") == {"answer": 42} @@ -902,7 +905,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-2", "output": "plain text"}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].text == "plain text" @@ -911,7 +914,1161 @@ class TestToMessage: item = OutputItem({"type": "some_unknown_type"}) with pytest.raises(ValueError, match="Unsupported OutputItem type: some_unknown_type"): - _to_message(item) + _output_item_to_message(item) + + +# endregion + + +# region _item_to_message conversion + + +class TestItemToMessage: + """Tests for _item_to_message covering all supported Item types.""" + + def test_message_with_string_content(self) -> None: + from azure.ai.agentserver.responses.models import ItemMessage + + item = ItemMessage({"type": "message", "role": "user", "content": "hello"}) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "user" + assert len(msg.contents) == 1 + assert msg.contents[0].type == "text" + assert msg.contents[0].text == "hello" + + def test_message_with_input_text_content(self) -> None: + from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent + + item = ItemMessage({ + "type": "message", + "role": "user", + "content": [MessageContentInputTextContent({"type": "input_text", "text": "hi there"})], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "user" + assert len(msg.contents) == 1 + assert msg.contents[0].text == "hi there" + + def test_message_with_multiple_contents(self) -> None: + from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent + + item = ItemMessage({ + "type": "message", + "role": "user", + "content": [ + MessageContentInputTextContent({"type": "input_text", "text": "first"}), + MessageContentInputTextContent({"type": "input_text", "text": "second"}), + ], + }) + msg = _item_to_message(item) + assert msg is not None + assert len(msg.contents) == 2 + assert msg.contents[0].text == "first" + assert msg.contents[1].text == "second" + + def test_output_message(self) -> None: + from azure.ai.agentserver.responses.models import ItemOutputMessage, OutputMessageContentOutputTextContent + + item = ItemOutputMessage({ + "type": "output_message", + "role": "assistant", + "content": [OutputMessageContentOutputTextContent({"type": "output_text", "text": "response"})], + "status": "completed", + "id": "msg-1", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert len(msg.contents) == 1 + assert msg.contents[0].type == "text" + assert msg.contents[0].text == "response" + + def test_function_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemFunctionToolCall + + item = ItemFunctionToolCall({ + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"city": "NYC"}', + "status": "completed", + "id": "fc-1", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].call_id == "call_1" + assert msg.contents[0].name == "get_weather" + assert msg.contents[0].arguments == '{"city": "NYC"}' + + def test_function_call_output(self) -> None: + from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam + + item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"}) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].call_id == "call_1" + assert msg.contents[0].result == "sunny" + + def test_function_call_output_non_string(self) -> None: + from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam + + item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_2", "output": 42}) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].result == "42" + + def test_reasoning_with_summary(self) -> None: + from azure.ai.agentserver.responses.models import ItemReasoningItem, SummaryTextContent + + item = ItemReasoningItem({ + "type": "reasoning", + "id": "r-1", + "summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert len(msg.contents) == 1 + assert msg.contents[0].text == "thinking hard" + + def test_reasoning_no_summary(self) -> None: + from azure.ai.agentserver.responses.models import ItemReasoningItem + + item = ItemReasoningItem({"type": "reasoning", "id": "r-2"}) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents == [] + + def test_mcp_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemMcpToolCall + + item = ItemMcpToolCall({ + "type": "mcp_call", + "id": "mcp-1", + "server_label": "my_server", + "name": "search", + "arguments": '{"q": "test"}', + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "mcp_server_tool_call" + assert msg.contents[0].server_name == "my_server" + assert msg.contents[0].tool_name == "search" + + def test_mcp_approval_request(self) -> None: + from azure.ai.agentserver.responses.models import ItemMcpApprovalRequest + + item = ItemMcpApprovalRequest({ + "type": "mcp_approval_request", + "id": "apr-1", + "server_label": "srv", + "name": "dangerous_tool", + "arguments": "{}", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_approval_request" + + def test_mcp_approval_response(self) -> None: + from azure.ai.agentserver.responses.models import MCPApprovalResponse + + item = MCPApprovalResponse({ + "type": "mcp_approval_response", + "approval_request_id": "apr-1", + "approve": True, + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "user" + assert msg.contents[0].type == "function_approval_response" + assert msg.contents[0].approved is True + + def test_code_interpreter_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemCodeInterpreterToolCall + + item = ItemCodeInterpreterToolCall({ + "type": "code_interpreter_call", + "id": "ci-1", + "status": "completed", + "container_id": "c-1", + "code": "print('hi')", + "outputs": [], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "code_interpreter_tool_call" + + def test_image_generation_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemImageGenToolCall + + item = ItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"}) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "image_generation_tool_call" + + def test_shell_call(self) -> None: + from azure.ai.agentserver.responses.models import FunctionShellAction, FunctionShellCallItemParam + + item = FunctionShellCallItemParam({ + "type": "shell_call", + "call_id": "call_sc", + "action": FunctionShellAction({"commands": ["ls", "-la"], "timeout_ms": 5000, "max_output_length": 1024}), + "status": "in_progress", + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "shell_tool_call" + assert msg.contents[0].commands == ["ls", "-la"] + assert msg.contents[0].call_id == "call_sc" + + def test_shell_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ( + FunctionShellCallOutputContent, + FunctionShellCallOutputExitOutcome, + FunctionShellCallOutputItemParam, + ) + + item = FunctionShellCallOutputItemParam({ + "type": "shell_call_output", + "call_id": "call_sc", + "output": [ + FunctionShellCallOutputContent({ + "stdout": "file.txt", + "stderr": "", + "outcome": FunctionShellCallOutputExitOutcome({"exit_code": 0}), + }) + ], + "max_output_length": 1024, + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "shell_tool_result" + assert msg.contents[0].call_id == "call_sc" + + def test_local_shell_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemLocalShellToolCall, LocalShellExecAction + + item = ItemLocalShellToolCall({ + "type": "local_shell_call", + "id": "lsc-1", + "call_id": "call_lsc", + "action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}), + "status": "completed", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "shell_tool_call" + assert msg.contents[0].commands == ["echo", "hello"] + + def test_local_shell_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ItemLocalShellToolCallOutput + + item = ItemLocalShellToolCallOutput({ + "type": "local_shell_call_output", + "id": "lsco-1", + "output": "hello\n", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "shell_tool_result" + + def test_file_search_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemFileSearchToolCall + + item = ItemFileSearchToolCall({ + "type": "file_search_call", + "id": "fs-1", + "status": "completed", + "queries": ["what is AI"], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "file_search" + assert '"what is AI"' in (msg.contents[0].arguments or "") + + def test_web_search_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemWebSearchToolCall + + item = ItemWebSearchToolCall({ + "type": "web_search_call", + "id": "ws-1", + "status": "completed", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "web_search" + + def test_computer_call(self) -> None: + from azure.ai.agentserver.responses.models import ComputerAction, ItemComputerToolCall + + item = ItemComputerToolCall({ + "type": "computer_call", + "id": "cc-1", + "call_id": "call_cc", + "action": ComputerAction({"type": "click"}), + "pending_safety_checks": [], + "status": "completed", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "computer_use" + + def test_computer_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ComputerCallOutputItemParam, ComputerScreenshotImage + + item = ComputerCallOutputItemParam({ + "type": "computer_call_output", + "call_id": "call_cc", + "output": ComputerScreenshotImage({ + "type": "computer_screenshot", + "image_url": "data:image/png;base64,abc", + }), + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].call_id == "call_cc" + + def test_custom_tool_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemCustomToolCall + + item = ItemCustomToolCall({ + "type": "custom_tool_call", + "call_id": "call_ct", + "name": "my_tool", + "input": '{"key": "value"}', + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "my_tool" + assert msg.contents[0].arguments == '{"key": "value"}' + + def test_custom_tool_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput + + item = ItemCustomToolCallOutput({ + "type": "custom_tool_call_output", + "call_id": "call_ct", + "output": "result text", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].result == "result text" + + def test_custom_tool_call_output_non_string(self) -> None: + from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput + + item = ItemCustomToolCallOutput({ + "type": "custom_tool_call_output", + "call_id": "call_ct2", + "output": 123, + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.contents[0].result == "123" + + def test_apply_patch_call(self) -> None: + from azure.ai.agentserver.responses.models import ApplyPatchToolCallItemParam, ApplyPatchUpdateFileOperation + + item = ApplyPatchToolCallItemParam({ + "type": "apply_patch_call", + "call_id": "call_ap", + "operation": ApplyPatchUpdateFileOperation({ + "type": "update_file", + "path": "file.py", + "diff": "+ new line", + }), + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "apply_patch" + + def test_apply_patch_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ApplyPatchToolCallOutputItemParam + + item = ApplyPatchToolCallOutputItemParam({ + "type": "apply_patch_call_output", + "call_id": "call_ap", + "output": "patch applied", + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].result == "patch applied" + + def test_unsupported_type_raises(self) -> None: + from azure.ai.agentserver.responses.models import Item + + item = Item({"type": "some_unknown_type"}) + with pytest.raises(ValueError, match="Unsupported Item type: some_unknown_type"): + _item_to_message(item) + + +# endregion + + +# region Multi-turn with mixed content + + +async def _post_json( + server: ResponsesHostServer, + payload: dict[str, Any], +) -> httpx.Response: + """Send a POST /responses request with a raw JSON payload.""" + transport = httpx.ASGITransport(app=server) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/responses", json=payload) + + +def _make_multi_response_agent( + responses: list[AgentResponse], + stream_updates_list: list[list[AgentResponseUpdate]] | None = None, +) -> MagicMock: + """Create a mock agent that returns different responses on successive calls.""" + agent = MagicMock(spec=RawAgent) + agent.id = "test-agent" + agent.name = "Test Agent" + agent.description = "A mock agent for testing" + agent.context_providers = [] + + call_index = [0] + + async def run_non_streaming(*args: Any, **kwargs: Any) -> AgentResponse: + idx = call_index[0] + call_index[0] += 1 + return responses[idx] + + async def _stream_gen(updates: list[AgentResponseUpdate]) -> AsyncIterator[AgentResponseUpdate]: + for update in updates: + yield update + + def run_dispatch(*args: Any, **kwargs: Any) -> Any: + idx = call_index[0] + call_index[0] += 1 + if kwargs.get("stream") and stream_updates_list is not None: + return ResponseStream(_stream_gen(stream_updates_list[idx])) # type: ignore + if not kwargs.get("stream"): + # Need to return a coroutine for non-streaming + async def _ret() -> AgentResponse: + return responses[idx] + + return _ret() + raise NotImplementedError("Streaming not configured for this call index") + + if stream_updates_list is not None: + agent.run = MagicMock(side_effect=run_dispatch) + else: + agent.run = AsyncMock(side_effect=run_non_streaming) + + return agent + + +class TestMultiTurnMixedContent: + """End-to-end multi-turn tests with mixed text and non-text content types.""" + + async def test_text_and_image_input_single_turn(self) -> None: + """Agent receives a message with text and image content via URL.""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("I see a cat!")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this animal"}, + {"type": "input_image", "image_url": "https://example.com/cat.jpg"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + # Verify agent received text + image + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert messages[0].role == "user" + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Describe this animal" + assert messages[0].contents[1].type == "uri" + assert messages[0].contents[1].uri == "https://example.com/cat.jpg" + + async def test_text_and_file_input_single_turn(self) -> None: + """Agent receives a message with text and file content via URL.""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("File received")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Summarize this document"}, + {"type": "input_file", "file_url": "https://example.com/doc.pdf", "filename": "doc.pdf"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Summarize this document" + assert messages[0].contents[1].type == "uri" + assert messages[0].contents[1].uri == "https://example.com/doc.pdf" + + async def test_mixed_text_and_image_input(self) -> None: + """Agent receives a single message with both text and image content.""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Got it!")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What's in this image?"}, + {"type": "input_image", "image_url": "https://example.com/photo.jpg"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "What's in this image?" + assert messages[0].contents[1].type == "uri" + assert messages[0].contents[1].uri == "https://example.com/photo.jpg" + + async def test_function_call_items_in_input(self) -> None: + """Input contains function_call and function_call_output items.""" + agent = _make_agent( + response=AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("Weather is sunny!")])] + ) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + {"type": "message", "role": "user", "content": "What's the weather?"}, + { + "type": "function_call", + "id": "fc-1", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"city": "NYC"}', + "status": "completed", + }, + {"type": "function_call_output", "call_id": "call_1", "output": "sunny, 72F"}, + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 3 + assert messages[0].role == "user" + assert messages[0].contents[0].type == "text" + assert messages[1].role == "assistant" + assert messages[1].contents[0].type == "function_call" + assert messages[1].contents[0].name == "get_weather" + assert messages[2].role == "tool" + assert messages[2].contents[0].type == "function_result" + assert messages[2].contents[0].result == "sunny, 72F" + + async def test_multi_turn_text_then_text_with_image(self) -> None: + """First turn sends text, second turn sends text + image with previous_response_id.""" + agent = _make_multi_response_agent([ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Send me an image")])]), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Nice cat!")])]), + ]) + server = _make_server(agent) + + # Turn 1: simple text + resp1 = await _post(server, input_text="Hello", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + + # Turn 2: text + image input referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Here is my cat photo"}, + {"type": "input_image", "image_url": "https://example.com/cat.jpg"}, + ], + } + ], + "stream": False, + "previous_response_id": response_id, + }, + ) + + assert resp2.status_code == 200 + body2 = resp2.json() + assert body2["status"] == "completed" + + # Verify second call receives history from turn 1 + text+image input + second_call_messages = agent.run.call_args_list[1].args[0] + # History: output message from turn 1 ("Send me an image") + # Input: message with text + image + assert len(second_call_messages) >= 2 + # Last message should be the text+image input + last_msg = second_call_messages[-1] + assert last_msg.role == "user" + assert len(last_msg.contents) == 2 + assert last_msg.contents[0].type == "text" + assert last_msg.contents[0].text == "Here is my cat photo" + assert last_msg.contents[1].type == "uri" + assert last_msg.contents[1].uri == "https://example.com/cat.jpg" + # History should include the assistant response from turn 1 + history_msgs = second_call_messages[:-1] + assistant_texts = [ + c.text for m in history_msgs if m.role == "assistant" for c in m.contents if c.type == "text" + ] + assert "Send me an image" in assistant_texts + + async def test_multi_turn_function_call_in_history(self) -> None: + """Turn 1 produces function call + result, turn 2 sees them in history.""" + agent = _make_multi_response_agent([ + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "search", arguments='{"q": "cats"}')], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result="found 10 cats")]), + Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]), + ] + ), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Here are more details")])]), + ]) + server = _make_server(agent) + + # Turn 1 + resp1 = await _post(server, input_text="Search for cats", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + + # Verify turn 1 output has function_call, function_call_output, and message + types1 = [item["type"] for item in resp1.json()["output"]] + assert "function_call" in types1 + assert "function_call_output" in types1 + assert "message" in types1 + + # Turn 2 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": "Tell me more", + "stream": False, + "previous_response_id": response_id, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify turn 2 received history including function call/result + second_call_messages = agent.run.call_args_list[1].args[0] + roles = [m.role for m in second_call_messages] + assert "assistant" in roles + assert "tool" in roles + # The function call should be in the history + fc_contents = [ + c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call" + ] + assert len(fc_contents) >= 1 + assert fc_contents[0].name == "search" + + async def test_multi_turn_reasoning_in_history(self) -> None: + """Turn 1 produces reasoning + text, turn 2 sees them in history.""" + agent = _make_multi_response_agent([ + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_text_reasoning(text="Let me think about this..."), + Content.from_text("The answer is 42"), + ], + ), + ] + ), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Indeed, it is 42")])]), + ]) + server = _make_server(agent) + + # Turn 1 + resp1 = await _post(server, input_text="What is the answer?", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + types1 = [item["type"] for item in resp1.json()["output"]] + assert "reasoning" in types1 + assert "message" in types1 + + # Turn 2 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": "Are you sure?", + "stream": False, + "previous_response_id": response_id, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify history includes the reasoning and text from turn 1 + second_call_messages = agent.run.call_args_list[1].args[0] + assert len(second_call_messages) >= 2 # history + new input + + async def test_multi_turn_with_mixed_content_and_streaming(self) -> None: + """Turn 1 non-streaming, turn 2 streaming with image input.""" + turn2_updates = [ + AgentResponseUpdate(contents=[Content.from_text("I see ")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("a cat!")], role="assistant"), + ] + + agent = _make_multi_response_agent( + responses=[ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Send me an image")])]), + AgentResponse(messages=[]), # placeholder, not used for streaming + ], + stream_updates_list=[ + [], # placeholder for turn 1 (non-streaming) + turn2_updates, + ], + ) + server = _make_server(agent) + + # Turn 1: non-streaming text + resp1 = await _post(server, input_text="Hello", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + + # Turn 2: streaming with image input + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this:"}, + {"type": "input_image", "image_url": "https://example.com/cat.jpg"}, + ], + } + ], + "stream": True, + "previous_response_id": response_id, + }, + ) + + assert resp2.status_code == 200 + assert "text/event-stream" in resp2.headers["content-type"] + + events = _parse_sse_events(resp2.text) + types = _sse_event_types(events) + assert types[0] == "response.created" + assert types[-1] == "response.completed" + assert "response.output_text.delta" in types + + # Verify accumulated text + text_done = [e for e in events if e["event"] == "response.output_text.done"] + assert len(text_done) == 1 + assert text_done[0]["data"]["text"] == "I see a cat!" + + async def test_text_with_mcp_call_items(self) -> None: + """Input contains text message + mcp_call item and the agent processes it.""" + agent = _make_agent( + response=AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("MCP result received")])] + ) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + {"type": "message", "role": "user", "content": "Search using MCP"}, + { + "type": "mcp_call", + "id": "mcp-1", + "server_label": "my_server", + "name": "search", + "arguments": '{"query": "test"}', + }, + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 2 + assert messages[0].role == "user" + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Search using MCP" + assert messages[1].role == "assistant" + assert messages[1].contents[0].type == "mcp_server_tool_call" + assert messages[1].contents[0].server_name == "my_server" + assert messages[1].contents[0].tool_name == "search" + + async def test_three_turn_conversation_with_mixed_content(self) -> None: + """Three-turn conversation: text → function call → image input.""" + agent = _make_multi_response_agent([ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Hello! How can I help?")])]), + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "analyze", arguments='{"mode": "deep"}')], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result="analysis complete")]), + Message(role="assistant", contents=[Content.from_text("Analysis done!")]), + ] + ), + AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("The image shows a chart")])] + ), + ]) + server = _make_server(agent) + + # Turn 1: text + resp1 = await _post(server, input_text="Hi", stream=False) + assert resp1.status_code == 200 + id1 = resp1.json()["id"] + + # Turn 2: text, referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": "Analyze something", + "stream": False, + "previous_response_id": id1, + }, + ) + assert resp2.status_code == 200 + id2 = resp2.json()["id"] + + # Turn 3: image input, referencing turn 2 + resp3 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What about this image?"}, + {"type": "input_image", "image_url": "https://example.com/chart.png"}, + ], + } + ], + "stream": False, + "previous_response_id": id2, + }, + ) + + assert resp3.status_code == 200 + assert resp3.json()["status"] == "completed" + + # Verify turn 3 received full history from turns 1+2 plus new image input + third_call_messages = agent.run.call_args_list[2].args[0] + # Should have: history from turn 1 (assistant text) + history from turn 2 + # (function_call, function_call_output, text) + new input (text + image) + assert len(third_call_messages) >= 5 + + # Last message should contain the image + last_msg = third_call_messages[-1] + assert last_msg.role == "user" + image_contents = [c for c in last_msg.contents if c.type == "uri"] + assert len(image_contents) == 1 + assert image_contents[0].uri == "https://example.com/chart.png" + + # History should include function call from turn 2 + fc_contents = [ + c + for m in third_call_messages[:-1] + if m.role == "assistant" + for c in m.contents + if c.type == "function_call" + ] + assert any(c.name == "analyze" for c in fc_contents) + + async def test_input_with_hosted_file_image(self) -> None: + """Input contains an image referenced by file_id (hosted file).""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Image analyzed")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Analyze this image"}, + {"type": "input_image", "file_id": "file-abc123"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Analyze this image" + assert messages[0].contents[1].type == "hosted_file" + assert messages[0].contents[1].file_id == "file-abc123" + + async def test_multi_turn_text_and_image_then_text_and_file(self) -> None: + """Turn 1 sends text+image, turn 2 sends text+file, both in history.""" + agent = _make_multi_response_agent([ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("I see a landscape")])]), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Document summarized")])]), + ]) + server = _make_server(agent) + + # Turn 1: text + image + resp1 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What is in this photo?"}, + {"type": "input_image", "image_url": "https://example.com/landscape.jpg"}, + ], + } + ], + "stream": False, + }, + ) + assert resp1.status_code == 200 + id1 = resp1.json()["id"] + + # Turn 2: text + file, referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Now summarize this report"}, + { + "type": "input_file", + "file_url": "https://example.com/report.pdf", + "filename": "report.pdf", + }, + ], + } + ], + "stream": False, + "previous_response_id": id1, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify turn 2 received history from turn 1 + new text+file input + second_call_messages = agent.run.call_args_list[1].args[0] + assert len(second_call_messages) >= 2 + + # History should include the assistant response from turn 1 + assistant_texts = [ + c.text for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "text" + ] + assert "I see a landscape" in assistant_texts + + # Last message should be text + file + last_msg = second_call_messages[-1] + assert last_msg.role == "user" + assert len(last_msg.contents) == 2 + assert last_msg.contents[0].type == "text" + assert last_msg.contents[0].text == "Now summarize this report" + assert last_msg.contents[1].type == "uri" + assert last_msg.contents[1].uri == "https://example.com/report.pdf" + + async def test_multi_turn_function_call_then_text_and_image(self) -> None: + """Turn 1: text + function call + result, turn 2: text + image.""" + agent = _make_multi_response_agent([ + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "get_info", arguments='{"id": 1}')], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result="info data")]), + Message(role="assistant", contents=[Content.from_text("Here is the info")]), + ] + ), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Image matches the data")])]), + ]) + server = _make_server(agent) + + # Turn 1: text triggers function call + resp1 = await _post(server, input_text="Get info for item 1", stream=False) + assert resp1.status_code == 200 + id1 = resp1.json()["id"] + + types1 = [item["type"] for item in resp1.json()["output"]] + assert "function_call" in types1 + assert "function_call_output" in types1 + assert "message" in types1 + + # Turn 2: text + image referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Does this image match?"}, + {"type": "input_image", "image_url": "https://example.com/item1.jpg"}, + ], + } + ], + "stream": False, + "previous_response_id": id1, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify turn 2 received history with function call + new text+image + second_call_messages = agent.run.call_args_list[1].args[0] + # History should contain function_call and function_result from turn 1 + fc_contents = [ + c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call" + ] + assert any(c.name == "get_info" for c in fc_contents) + tool_contents = [ + c for m in second_call_messages if m.role == "tool" for c in m.contents if c.type == "function_result" + ] + assert any(c.result == "info data" for c in tool_contents) + + # Last message should be text + image + last_msg = second_call_messages[-1] + assert last_msg.role == "user" + assert len(last_msg.contents) == 2 + assert last_msg.contents[0].type == "text" + assert last_msg.contents[0].text == "Does this image match?" + assert last_msg.contents[1].type == "uri" + assert last_msg.contents[1].uri == "https://example.com/item1.jpg" # endregion diff --git a/python/uv.lock b/python/uv.lock index ecaed87fe4..6f1b6b0bbe 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -504,7 +504,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-hosting" -version = "1.0.0a260423" +version = "1.0.0a260424" source = { editable = "packages/foundry_hosting" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -516,9 +516,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-ai-agentserver-core", specifier = "==2.0.0b2" }, - { name = "azure-ai-agentserver-invocations", specifier = "==1.0.0b2" }, - { name = "azure-ai-agentserver-responses", specifier = "==1.0.0b4" }, + { name = "azure-ai-agentserver-core", specifier = "==2.0.0b3" }, + { name = "azure-ai-agentserver-invocations", specifier = "==1.0.0b3" }, + { name = "azure-ai-agentserver-responses", specifier = "==1.0.0b5" }, ] [[package]] @@ -1068,7 +1068,7 @@ wheels = [ [[package]] name = "azure-ai-agentserver-core" -version = "2.0.0b2" +version = "2.0.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1078,26 +1078,26 @@ dependencies = [ { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/25/25865cfa76cbc20c18c4e9ed337456fd7374c01e930dd151463b4c183ac0/azure_ai_agentserver_core-2.0.0b2.tar.gz", hash = "sha256:cc6c90fdc4c2b2ce594f0e85288fda84910c04939d1427a64a485b2d48d6d684", size = 41605, upload-time = "2026-04-19T08:58:09.27Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/29/1a9606d5252b02d77070a1b633dd0c26fe65a0f4a0fb0cfdaa751e2ed458/azure_ai_agentserver_core-2.0.0b3.tar.gz", hash = "sha256:e295b19a65d53c513929f52f0862bbb815cc9e9fc29d2a2825452f3136260123", size = 42573, upload-time = "2026-04-23T04:13:16.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/35/cf8a034f86d653fa902edb5ffa0a86005ea941f2840d2fa27302484856c1/azure_ai_agentserver_core-2.0.0b2-py3-none-any.whl", hash = "sha256:931e7a2d82275a01d7eb5ef08a70dba230938e3646be64c03d82749dd7be8afc", size = 27494, upload-time = "2026-04-19T08:58:10.588Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/1fc87c05b55821f33c46c5e8a3b97a573aa2fc4bff387e75cca1a87800b4/azure_ai_agentserver_core-2.0.0b3-py3-none-any.whl", hash = "sha256:5ef921eb9fd9c0f15682fe930320fae50dccfa915d7518f9a16d99014bbcb3cb", size = 29127, upload-time = "2026-04-23T04:13:17.976Z" }, ] [[package]] name = "azure-ai-agentserver-invocations" -version = "1.0.0b2" +version = "1.0.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/ef/11a161fa400f28390e9885854c434417fbd204ae006ca02b3a45ab285069/azure_ai_agentserver_invocations-1.0.0b2.tar.gz", hash = "sha256:cf352fd11b0057a2af28b1a921c84fb11f2fcbb9b4185cae9d93f2a45980227b", size = 30242, upload-time = "2026-04-19T09:43:31.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/95/ebab2b06777352b33dd4c407fa5624765b7443d3b4b5fb6cb1f51660643b/azure_ai_agentserver_invocations-1.0.0b3.tar.gz", hash = "sha256:1eaad3ae8dc6a28038b9a16c7b5f853fda33202c1ea57559992a6c6fe71952a4", size = 31002, upload-time = "2026-04-23T04:30:29.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/f4/057206e0fca266b30ea68a531fa425078fd883500e779d5552858fe33d5b/azure_ai_agentserver_invocations-1.0.0b2-py3-none-any.whl", hash = "sha256:e799a9e6e54a10499296ee4f61720377fb31f540204832b654bac6f20e801597", size = 11432, upload-time = "2026-04-19T09:43:32.744Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/a421671296ae33b62af3a034869fa82ff1979e5f455a29924d30ae1b8307/azure_ai_agentserver_invocations-1.0.0b3-py3-none-any.whl", hash = "sha256:771a15a3509e049b56f71c43c87a3fdeecd12addddcae0f80339990adc41e678", size = 11433, upload-time = "2026-04-23T04:30:30.412Z" }, ] [[package]] name = "azure-ai-agentserver-responses" -version = "1.0.0b4" +version = "1.0.0b5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1105,9 +1105,9 @@ dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/01/614dafa9366a5bdfe50ec112b15faa57e32a96866796bc2812ba329f4fec/azure_ai_agentserver_responses-1.0.0b4.tar.gz", hash = "sha256:2fa69db26ff52d8d2cd667a1461675e5124aabf8f268b842402e36f50d6c7176", size = 397007, upload-time = "2026-04-20T07:33:18.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/3ecb7fe704ff8764199bfbe4cc1e584a520a9affe042470d9d50b6e1e73a/azure_ai_agentserver_responses-1.0.0b5.tar.gz", hash = "sha256:0b627b810359c792ea7b6fa6782abaf6df32d9bc9e5a569ad722afcffd0ce8d9", size = 410908, upload-time = "2026-04-23T04:31:15.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/bd/c56df7c9257f10014ae1cd161ac08784bd9fe682233ab1a987c98b5b78c0/azure_ai_agentserver_responses-1.0.0b4-py3-none-any.whl", hash = "sha256:7684c6bef57bdcd1941cce2d6b5e2ea07edd7ce9f90e84f171804cc728b60fcc", size = 263375, upload-time = "2026-04-20T07:33:19.956Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/1e5c0d7ce95ca8b022e69e4ca6b23e413fc2d57f0191429c4633e02213d2/azure_ai_agentserver_responses-1.0.0b5-py3-none-any.whl", hash = "sha256:4c2a6ab56e71eeb330aa52b7cb2cc71b8ec6b5bbe0e7dc84310f2c7fbda393a3", size = 268362, upload-time = "2026-04-23T04:31:17.014Z" }, ] [[package]] From b00465d7bef4858f35c0cef15de1a30298a2dde4 Mon Sep 17 00:00:00 2001 From: Shubham Kumar <41825906+Shubham-Kumar-2000@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:05:40 +0530 Subject: [PATCH 05/40] Python: feat: Add Agent Framework to A2A bridge support (#2403) * feat: Add Agent Framework to A2A bridge support - Implement A2A event adapter for converting agent messages to A2A protocol - Add A2A execution context for managing agent execution state - Implement A2A executor for running agents in A2A environment - Add comprehensive unit tests for event adapter, execution context, and executor - Update agent framework core A2A module exports and type stubs - Integrate thread management utilities for async execution - Add getting started sample for A2A agent framework integration - Update dependencies in uv.lock This integration enables agent framework agents to communicate and execute within the A2A (Agent to Agent) infrastructure. * fix: Update references from agent_thread_storage to _agent_thread_storage in A2A executor tests * Refactor A2A agent framework and improve code structure - Reordered imports in various files for consistency and clarity. - Updated `__all__` definitions to maintain a consistent order across modules. - Simplified method signatures by removing unnecessary line breaks. - Enhanced readability by adjusting formatting in several sections. - Removed redundant comments and example scenarios in the execution context. - Improved handling of agent messages in the event adapter. - Added type hints for better clarity and type checking. - Cleaned up test cases for better organization and readability. * fix: Lint fix new line added * test: Add unit tests for AgentThreadStorage and InMemoryAgentThreadStorage * refactor: Update type hints to use new syntax for Union and List * fix: Validate RequestContext for context_id and message before execution * Refactor tests and remove A2aExecutionContext references - Deleted the test file for A2aExecutionContext as it is no longer needed. - Updated A2aExecutor tests to remove dependencies on A2aExecutionContext and adjusted method calls accordingly. - Modified event adapter tests to use ChatMessage instead of AgentRunResponseUpdate. - Removed A2aExecutionContext from imports in agent_framework.a2a module and updated type hints accordingly. * Refactor A2AExecutor tests and remove event adapter - Updated test cases to use A2AExecutor instead of A2aExecutor for consistency. - Removed mock_event_adapter fixture and related tests as A2aEventAdapter is deprecated. - Consolidated event handling tests into TestA2AExecutorEventAdapter. - Adjusted imports in various files to reflect the removal of deprecated components. - Ensured all references to A2aExecutor are updated to A2AExecutor across the codebase. * refactor: Remove AgentThreadStorage and InMemoryAgentThreadStorage classes from threads and tests * feat: A2AExecutor to have its own override able save and get threads methods for persistent storage. * fix: linter bugs * removed unnecessary changes form core package * new line added * Refactor A2AExecutor tests and update imports - Consolidated mock agent fixtures in test_a2a_executor.py to simplify agent mocking. - Removed redundant tests related to thread storage and agent types, focusing on A2AExecutor's core functionality. - Updated test assertions to reflect changes in message handling with new Message and Content classes. - Enhanced integration tests to ensure compatibility with the new agent framework structure. - Added A2AExecutor to the module exports in __init__.py and __init__.pyi for better accessibility. * Update A2A documentation: enhance usage examples for A2AAgent and A2AExecutor * Updated uv lock * Fix metadata assertion in TestA2AExecutorHandleEvents and reorder load_dotenv call in agent_framework_to_a2a.py * Update agent card configuration: add default input and output modes, and fix agent creation method * Fix assertion for metadata in TestA2AExecutorHandleEvents * Fix formatting issues in TestA2AExecutorExecute and TestA2AExecutorIntegration * Enhance A2AExecutor documentation with examples and clarify agent execution process * Revert uv lock to main * Refactor A2AExecutor: Improve formatting and streamline constructor parameters * Apply suggestions from code review Co-authored-by: Eduard van Valkenburg * Refactor A2AExecutor to use SupportsAgentRun and enhance logging; update agent framework sample for flight and hotel booking capabilities * Enhance A2AExecutor with streaming support and custom run arguments; update tests for initialization and execution scenarios * Enhance A2AExecutor event handling with streamed artifact tracking; update tests for new behavior * Refactor A2AExecutor to enforce type hints for stream and run_kwargs attributes * Refactor A2AExecutor and tests: replace AsyncMock with MagicMock for response stream handling; clean up imports in agent_framework_to_a2a.py * refactor: streamline imports and improve code readability across multiple files * feat: enhance A2AExecutor cancel method with context validation and fixed review comments * feat: implement get_uri_data utility function for extracting base64 data from data URIs and update references * fix: update import path for get_uri_data utility function in A2AExecutor and A2AAgent * fix: correct error message handling in A2AExecutor and update test assertions --------- Co-authored-by: Eduard van Valkenburg --- python/packages/a2a/AGENTS.md | 36 +- python/packages/a2a/README.md | 38 + .../a2a/agent_framework_a2a/__init__.py | 2 + .../a2a/agent_framework_a2a/_a2a_executor.py | 275 ++++++ .../a2a/agent_framework_a2a/_agent.py | 13 +- .../a2a/agent_framework_a2a/_utils.py | 24 + python/packages/a2a/tests/test_a2a_agent.py | 10 +- .../packages/a2a/tests/test_a2a_executor.py | 910 ++++++++++++++++++ python/packages/a2a/tests/test_utils.py | 41 + .../core/agent_framework/a2a/__init__.py | 3 +- .../core/agent_framework/a2a/__init__.pyi | 8 +- python/samples/04-hosting/a2a/README.md | 4 + .../04-hosting/a2a/agent_framework_to_a2a.py | 70 ++ 13 files changed, 1407 insertions(+), 27 deletions(-) create mode 100644 python/packages/a2a/agent_framework_a2a/_a2a_executor.py create mode 100644 python/packages/a2a/agent_framework_a2a/_utils.py create mode 100644 python/packages/a2a/tests/test_a2a_executor.py create mode 100644 python/packages/a2a/tests/test_utils.py create mode 100644 python/samples/04-hosting/a2a/agent_framework_to_a2a.py diff --git a/python/packages/a2a/AGENTS.md b/python/packages/a2a/AGENTS.md index af6e4a492b..1474c59ef5 100644 --- a/python/packages/a2a/AGENTS.md +++ b/python/packages/a2a/AGENTS.md @@ -4,20 +4,48 @@ Agent-to-Agent (A2A) protocol support for inter-agent communication. ## Main Classes -- **`A2AAgent`** - Agent wrapper that exposes an agent via the A2A protocol +- **`A2AAgent`** - Client to connect to remote A2A-compliant agents. +- **`A2AExecutor`** - Bridge to expose Agent Framework agents via the A2A protocol. ## Usage +### A2AAgent (Client) + ```python from agent_framework.a2a import A2AAgent -a2a_agent = A2AAgent(agent=my_agent) +# Connect to a remote A2A agent +a2a_agent = A2AAgent(url="http://remote-agent/a2a") +response = await a2a_agent.run("Hello!") +``` + +### A2AExecutor (Server/Bridge) + +```python +from agent_framework.a2a import A2AExecutor +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore + +# Create an A2A executor for your agent +executor = A2AExecutor(agent=my_agent) + +# Set up the request handler and server application +request_handler = DefaultRequestHandler( + agent_executor=executor, + task_store=InMemoryTaskStore(), +) + +app = A2AStarletteApplication( + agent_card=my_agent_card, + http_handler=request_handler, +).build() ``` ## Import Path ```python -from agent_framework.a2a import A2AAgent +from agent_framework.a2a import A2AAgent, A2AExecutor # or directly: -from agent_framework_a2a import A2AAgent +from agent_framework_a2a import A2AAgent, A2AExecutor ``` diff --git a/python/packages/a2a/README.md b/python/packages/a2a/README.md index 5ae15e3647..4bdfa9221e 100644 --- a/python/packages/a2a/README.md +++ b/python/packages/a2a/README.md @@ -10,11 +10,49 @@ pip install agent-framework-a2a --pre The A2A agent integration enables communication with remote A2A-compliant agents using the standardized A2A protocol. This allows your Agent Framework applications to connect to agents running on different platforms, languages, or services. +### A2AAgent (Client) + +The `A2AAgent` class is a client that wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents. + +```python +from agent_framework.a2a import A2AAgent + +# Connect to a remote A2A agent +a2a_agent = A2AAgent(url="http://remote-agent/a2a") +response = await a2a_agent.run("Hello!") +``` + +### A2AExecutor (Hosting) + +The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients. + +```python +from agent_framework.a2a import A2AExecutor +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore + +# Create an A2A executor for your agent +executor = A2AExecutor(agent=my_agent) + +# Set up the request handler and server application +request_handler = DefaultRequestHandler( + agent_executor=executor, + task_store=InMemoryTaskStore(), +) + +app = A2AStarletteApplication( + agent_card=my_agent_card, + http_handler=request_handler, +).build() +``` + ### Basic Usage Example See the [A2A agent examples](../../samples/04-hosting/a2a/) which demonstrate: - Connecting to remote A2A agents +- Hosting local agents via A2A protocol - Sending messages and receiving responses - Handling different content types (text, files, data) - Streaming responses and real-time interaction diff --git a/python/packages/a2a/agent_framework_a2a/__init__.py b/python/packages/a2a/agent_framework_a2a/__init__.py index 4b4d54ecc3..c5338965c2 100644 --- a/python/packages/a2a/agent_framework_a2a/__init__.py +++ b/python/packages/a2a/agent_framework_a2a/__init__.py @@ -2,6 +2,7 @@ import importlib.metadata +from ._a2a_executor import A2AExecutor from ._agent import A2AAgent, A2AContinuationToken try: @@ -12,5 +13,6 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "A2AAgent", "A2AContinuationToken", + "A2AExecutor", "__version__", ] diff --git a/python/packages/a2a/agent_framework_a2a/_a2a_executor.py b/python/packages/a2a/agent_framework_a2a/_a2a_executor.py new file mode 100644 index 0000000000..0cf6d835a6 --- /dev/null +++ b/python/packages/a2a/agent_framework_a2a/_a2a_executor.py @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +from asyncio import CancelledError +from collections.abc import Mapping +from functools import partial +from typing import Any + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart +from a2a.utils import new_task +from agent_framework import ( + AgentResponseUpdate, + AgentSession, + Message, + SupportsAgentRun, +) +from typing_extensions import override + +from agent_framework_a2a._utils import get_uri_data + +logger = logging.getLogger("agent_framework.a2a") + + +class A2AExecutor(AgentExecutor): + """Execute AI agents using the A2A (Agent-to-Agent) protocol. + + The A2AExecutor bridges AI agents built with the agent_framework library and the A2A protocol, + enabling structured agent execution with event-driven communication. It handles execution + contexts, delegates history management to the agent's session, and converts agent + responses into A2A protocol events. + + The executor supports executing an Agent or WorkflowAgent. It provides comprehensive + error handling with task status updates and supports various content types including text, + binary data, and URI-based content. + + Example: + .. code-block:: python + + from a2a.server.apps import A2AStarletteApplication + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.tasks import InMemoryTaskStore + from a2a.types import AgentCapabilities, AgentCard + from agent_framework.a2a import A2AExecutor + from agent_framework.openai import OpenAIResponsesClient + + public_agent_card = AgentCard( + name="Food Agent", + description="A simple agent that provides food-related information.", + url="http://localhost:9999/", + version="1.0.0", + defaultInputModes=["text"], + defaultOutputModes=["text"], + capabilities=AgentCapabilities(streaming=True), + skills=[], + ) + + # Create an agent + agent = OpenAIResponsesClient().as_agent( + name="Food Agent", + instructions="A simple agent that provides food-related information.", + ) + + # Set up the A2A server with the A2AExecutor enabled for streaming + # and passing custom keyword arguments to the agent's run method. + request_handler = DefaultRequestHandler( + agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}), + task_store=InMemoryTaskStore(), + ) + + server = A2AStarletteApplication( + agent_card=public_agent_card, + http_handler=request_handler, + ).build() + + Args: + agent: The AI agent to execute. + stream: Whether to stream the agent response. Defaults to False. + run_kwargs: Additional keyword arguments to pass to the agent's run method. + """ + + def __init__(self, agent: SupportsAgentRun, stream: bool = False, run_kwargs: Mapping[str, Any] | None = None): + """Initialize the A2AExecutor with the specified agent. + + Args: + agent: The AI agent or workflow to execute. + stream: Whether to stream the agent response. Defaults to False. + run_kwargs: Additional keyword arguments to pass to the agent's run method. + Cannot contain 'session' or 'stream' as these are managed by the executor. + + Raises: + ValueError: If run_kwargs contains 'session' or 'stream'. + """ + super().__init__() + self._agent: SupportsAgentRun = agent + self._stream: bool = stream + if run_kwargs: + if "session" in run_kwargs: + raise ValueError("run_kwargs cannot contain 'session' as it is managed by the executor.") + if "stream" in run_kwargs: + raise ValueError("run_kwargs cannot contain 'stream' as it is managed by the executor.") + self._run_kwargs: Mapping[str, Any] = run_kwargs or {} + + @override + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Cancel agent execution for the given request context. + + Uses a TaskUpdater to send a cancellation event through the provided event queue. + + Args: + context: The request context identifying the task to cancel. + event_queue: The event queue to publish the cancellation event to. + + Raises: + ValueError: If context_id is not provided in the RequestContext. + """ + if context.context_id is None: + raise ValueError("Context ID must be provided in the RequestContext") + + updater = TaskUpdater( + event_queue=event_queue, + task_id=context.task_id or "", + context_id=context.context_id, + ) + + await updater.cancel() + + @override + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Execute the agent with the given context and event queue. + + Orchestrates the agent execution process: sets up the agent session, + executes the agent, processes response messages, and handles errors with appropriate task status updates. + """ + if context.context_id is None: + raise ValueError("Context ID must be provided in the RequestContext") + if context.message is None: + raise ValueError("Message must be provided in the RequestContext") + + query = context.get_user_input() + task = context.current_task + + if not task: + task = new_task(context.message) + await event_queue.enqueue_event(task) + + updater = TaskUpdater(event_queue, task.id, context.context_id) + await updater.submit() + + try: + await updater.start_work() + + session = self._agent.create_session(session_id=task.context_id) + + if self._stream: + await self._run_stream(query, session, updater) + else: + await self._run(query, session, updater) + + # Mark as complete + await updater.complete() + except CancelledError: + await updater.update_status(state=TaskState.canceled, final=True) + except Exception as e: + logger.exception("A2AExecutor encountered an error during execution.", exc_info=e) + await updater.update_status( + state=TaskState.failed, + final=True, + message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]), + ) + + async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None: + """Run the agent in streaming mode and publish updates to the task updater.""" + response_stream = self._agent.run(query, session=session, stream=True, **self._run_kwargs) + streamed_artifact_ids: set[str] = set() + await ( + response_stream.with_transform_hook( + partial(self.handle_events, updater=updater, streamed_artifact_ids=streamed_artifact_ids) + ) + ).get_final_response() + + async def _run(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None: + """Run the agent in non-streaming mode and publish messages to the task updater.""" + response = await self._agent.run(query, session=session, stream=False, **self._run_kwargs) + response_messages = response.messages + + if not isinstance(response_messages, list): + response_messages = [response_messages] + + for message in response_messages: + await self.handle_events(message, updater) + + async def handle_events( + self, item: Message | AgentResponseUpdate, updater: TaskUpdater, streamed_artifact_ids: set[str] | None = None + ) -> None: + """Convert agent response items (Messages or Updates) to A2A protocol events. + + Processes Message or AgentResponseUpdate objects and converts them into A2A protocol format. + Handles text, data, and URI content. USER role messages are skipped. + + Users can override this method in a subclass to implement custom transformations + from their agent's output format to A2A protocol events. + + Args: + item: The agent response item (Message or AgentResponseUpdate) to process. + updater: The task updater to publish events to. + streamed_artifact_ids: A set of artifact IDs that have already been streamed. + Used to prevent duplicate updates for the same artifact. + + Example: + .. code-block:: python + + class CustomA2AExecutor(A2AExecutor): + async def handle_events( + self, + item: Message | AgentResponseUpdate, + updater: TaskUpdater, + streamed_artifact_ids: set[str] | None = None, + ) -> None: + # Custom logic to transform item contents + if item.role == "assistant" and item.contents: + parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))] + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message(parts=parts), + ) + else: + await super().handle_events(item, updater) + """ + role = getattr(item, "role", None) + if role == "user": + # This is a user message, we can ignore it in the context of task updates + return + + parts: list[Part] = [] + metadata = getattr(item, "additional_properties", None) + + # AgentResponseUpdate uses 'contents', Message uses 'contents' + contents = getattr(item, "contents", []) + + for content in contents: + if content.type == "text" and content.text: + parts.append(Part(root=TextPart(text=content.text))) + elif content.type == "data" and content.uri: + base64_str = get_uri_data(content.uri) + parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type)))) + elif content.type == "uri" and content.uri: + parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type)))) + else: + # Silently skip unsupported content types + logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type) + + if parts: + if isinstance(item, AgentResponseUpdate): + # For streaming updates, we send TaskArtifactUpdateEvent via add_artifact + await updater.add_artifact( + parts=parts, + artifact_id=item.message_id, + metadata=metadata, + append=( + True + if streamed_artifact_ids is not None and item.message_id in (streamed_artifact_ids or set()) + else None + ), + ) + if item.message_id and streamed_artifact_ids is not None: + streamed_artifact_ids.add(item.message_id) + else: + # For final messages, we send TaskStatusUpdateEvent with 'working' state + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message(parts=parts, metadata=metadata), + ) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 696a160cf6..7fa95cd07f 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -4,7 +4,6 @@ from __future__ import annotations import base64 import json -import re import uuid from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence from typing import Any, Final, Literal, TypeAlias, overload @@ -49,7 +48,7 @@ from agent_framework.observability import AgentTelemetryLayer __all__ = ["A2AAgent", "A2AContinuationToken"] -URI_PATTERN = re.compile(r"^data:(?P[^;]+);base64,(?P[A-Za-z0-9+/=]+)$") +from agent_framework_a2a._utils import get_uri_data class A2AContinuationToken(ContinuationToken): @@ -78,14 +77,6 @@ A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpda A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent -def _get_uri_data(uri: str) -> str: - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid data URI format: {uri}") - - return match.group("base64_data") - - class A2AAgent(AgentTelemetryLayer, BaseAgent): """Agent2Agent (A2A) protocol implementation. @@ -652,7 +643,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): A2APart( root=FilePart( file=FileWithBytes( - bytes=_get_uri_data(content.uri), + bytes=get_uri_data(content.uri), mime_type=content.media_type, ), metadata=content.additional_properties, diff --git a/python/packages/a2a/agent_framework_a2a/_utils.py b/python/packages/a2a/agent_framework_a2a/_utils.py new file mode 100644 index 0000000000..2b0a8e1600 --- /dev/null +++ b/python/packages/a2a/agent_framework_a2a/_utils.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft. All rights reserved. + +import re + +URI_PATTERN = re.compile(r"^data:(?P[^;]+);base64,(?P[A-Za-z0-9+/=]+)$") + + +def get_uri_data(uri: str) -> str: + """Extracts the base64-encoded data from a data URI. + + Args: + uri: The data URI to parse. + + Returns: + The base64-encoded data part of the URI. + + Raises: + ValueError: If the URI format is invalid. + """ + match = URI_PATTERN.match(uri) + if not match: + raise ValueError(f"Invalid data URI format: {uri}") + + return match.group("base64_data") diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index dbbad8a865..909311e184 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -35,7 +35,7 @@ from agent_framework.a2a import A2AAgent from pytest import fixture, mark, raises from agent_framework_a2a import A2AContinuationToken -from agent_framework_a2a._agent import _get_uri_data # type: ignore +from agent_framework_a2a._utils import get_uri_data class MockA2AClient: @@ -353,18 +353,18 @@ def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None: def test_get_uri_data_valid_uri() -> None: - """Test _get_uri_data with valid data URI.""" + """Test get_uri_data with valid data URI.""" uri = "data:application/json;base64,eyJ0ZXN0IjoidmFsdWUifQ==" - result = _get_uri_data(uri) + result = get_uri_data(uri) assert result == "eyJ0ZXN0IjoidmFsdWUifQ==" def test_get_uri_data_invalid_uri() -> None: - """Test _get_uri_data with invalid URI format.""" + """Test get_uri_data with invalid URI format.""" with raises(ValueError, match="Invalid data URI format"): - _get_uri_data("not-a-valid-data-uri") + get_uri_data("not-a-valid-data-uri") def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None: diff --git a/python/packages/a2a/tests/test_a2a_executor.py b/python/packages/a2a/tests/test_a2a_executor.py new file mode 100644 index 0000000000..bd3ead046e --- /dev/null +++ b/python/packages/a2a/tests/test_a2a_executor.py @@ -0,0 +1,910 @@ +# Copyright (c) Microsoft. All rights reserved. +from asyncio import CancelledError +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +from a2a.types import Task, TaskState, TextPart +from agent_framework import ( + AgentResponseUpdate, + Content, + Message, + SupportsAgentRun, +) +from agent_framework._types import AgentResponse +from agent_framework.a2a import A2AExecutor +from pytest import fixture, raises + + +@fixture +def mock_agent() -> MagicMock: + """Fixture that provides a mock SupportsAgentRun.""" + agent = MagicMock(spec=SupportsAgentRun) + agent.run = AsyncMock() + return agent + + +@fixture +def mock_request_context() -> MagicMock: + """Fixture that provides a mock RequestContext.""" + request_context = MagicMock() + request_context.context_id = str(uuid4()) + request_context.get_user_input = MagicMock(return_value="Test query") + request_context.current_task = None + request_context.message = None + return request_context + + +@fixture +def mock_event_queue() -> MagicMock: + """Fixture that provides a mock EventQueue.""" + queue = AsyncMock() + queue.enqueue_event = AsyncMock() + return queue + + +@fixture +def mock_task() -> Task: + """Fixture that provides a mock Task.""" + task = MagicMock(spec=Task) + task.id = str(uuid4()) + task.context_id = str(uuid4()) + task.state = TaskState.completed + return task + + +@fixture +def mock_task_updater() -> MagicMock: + """Fixture that provides a mock TaskUpdater.""" + updater = MagicMock() + updater.submit = AsyncMock() + updater.start_work = AsyncMock() + updater.complete = AsyncMock() + updater.update_status = AsyncMock() + updater.new_agent_message = MagicMock() + return updater + + +@fixture +def executor(mock_agent: MagicMock) -> A2AExecutor: + """Fixture that provides an A2AExecutor.""" + return A2AExecutor(agent=mock_agent) + + +class TestA2AExecutorInitialization: + """Tests for A2AExecutor initialization.""" + + def test_initialization_with_agent_only(self, mock_agent: MagicMock) -> None: + """Arrange: Create mock agent + Act: Initialize A2AExecutor with only agent + Assert: Executor is created with default values + """ + # Act + executor = A2AExecutor(agent=mock_agent) + + # Assert + assert executor._agent is mock_agent + assert executor._stream is False + assert executor._run_kwargs == {} + + def test_initialization_with_stream_and_kwargs(self, mock_agent: MagicMock) -> None: + """Arrange: Create mock agent + Act: Initialize A2AExecutor with stream and run_kwargs + Assert: Executor is created with specified values + """ + # Arrange + run_kwargs = {"temperature": 0.5} + + # Act + executor = A2AExecutor(agent=mock_agent, stream=True, run_kwargs=run_kwargs) + + # Assert + assert executor._agent is mock_agent + assert executor._stream is True + assert executor._run_kwargs == run_kwargs + + def test_initialization_with_invalid_run_kwargs(self, mock_agent: MagicMock) -> None: + """Arrange: Create mock agent + Act: Initialize A2AExecutor with reserved keys in run_kwargs + Assert: ValueError is raised + """ + # Act & Assert + with raises(ValueError, match="run_kwargs cannot contain 'session'"): + A2AExecutor(agent=mock_agent, run_kwargs={"session": "something"}) + + with raises(ValueError, match="run_kwargs cannot contain 'stream'"): + A2AExecutor(agent=mock_agent, run_kwargs={"stream": True}) + + +class TestA2AExecutorCancel: + """Tests for the cancel method.""" + + async def test_cancel_method_completes( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create executor with dependencies + Act: Call cancel method + Assert: Method completes without raising error + """ + # Arrange + mock_request_context.task_id = "task-123" + + # Act & Assert (should not raise) + await executor.cancel(mock_request_context, mock_event_queue) # type: ignore + + async def test_cancel_handles_different_contexts( + self, + executor: A2AExecutor, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create executor with multiple request contexts + Act: Call cancel with different contexts + Assert: Each cancel completes successfully + """ + # Arrange + context1 = MagicMock() + context1.context_id = "ctx-1" + context1.task_id = "task-1" + context2 = MagicMock() + context2.context_id = "ctx-2" + context2.task_id = "task-2" + + # Act & Assert + await executor.cancel(context1, mock_event_queue) # type: ignore + await executor.cancel(context2, mock_event_queue) # type: ignore + + async def test_cancel_raises_error_when_context_id_missing( + self, + executor: A2AExecutor, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create context without context_id + Act: Call cancel method + Assert: ValueError is raised + """ + # Arrange + mock_context = MagicMock() + mock_context.context_id = None + + # Act & Assert + with raises(ValueError) as excinfo: + await executor.cancel(mock_context, mock_event_queue) # type: ignore + + # Assert + assert "Context ID" in str(excinfo.value) + + +class TestA2AExecutorExecute: + """Tests for the execute method.""" + + async def test_execute_with_existing_task_succeeds( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with mocked dependencies and existing task + Act: Call execute method + Assert: Execution completes successfully + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message = Message(role="assistant", contents=[Content.from_text(text="Hello back")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_updater.submit.assert_called_once() + mock_updater.start_work.assert_called_once() + mock_updater.complete.assert_called_once() + executor._agent.create_session.assert_called_once() + executor._agent.run.assert_called_once() + + async def test_execute_creates_task_when_not_exists( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create executor with request context without task + Act: Call execute method + Assert: New task is created and enqueued + """ + # Arrange + mock_message = MagicMock() + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = None + mock_request_context.message = mock_message + mock_request_context.context_id = "ctx-123" + + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task: + mock_task = MagicMock(spec=Task) + mock_task.id = "task-new" + mock_task.context_id = "ctx-123" + mock_new_task.return_value = mock_task + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_new_task.assert_called_once() + mock_event_queue.enqueue_event.assert_called_once() + + async def test_execute_raises_error_when_context_id_missing( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create context without context_id + Act: Call execute method + Assert: ValueError is raised + """ + # Arrange + mock_request_context.context_id = None + mock_request_context.message = MagicMock() + + # Act & Assert + with raises(ValueError) as excinfo: + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + assert "Context ID" in str(excinfo.value) + + async def test_execute_raises_error_when_message_missing( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create context without message + Act: Call execute method + Assert: ValueError is raised + """ + # Arrange + mock_request_context.context_id = "ctx-123" + mock_request_context.message = None + + # Act & Assert + with raises(ValueError) as excinfo: + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + assert "Message" in str(excinfo.value) + + async def test_execute_handles_cancelled_error( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor that raises CancelledError + Act: Call execute method + Assert: Error is caught and task is marked as canceled + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + executor._agent.run = AsyncMock(side_effect=CancelledError()) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) # type: ignore + + # Assert + mock_updater.update_status.assert_called() + call_args_list = mock_updater.update_status.call_args_list + assert any( + call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list + ) + + async def test_execute_handles_generic_exception( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor that raises generic exception + Act: Call execute method + Assert: Error is caught and task is marked as failed + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + error_message = "Test error" + executor._agent.run = AsyncMock(side_effect=ValueError(error_message)) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="error_message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_updater.new_agent_message.assert_called_once() + args, _ = mock_updater.new_agent_message.call_args + parts = args[0] + assert len(parts) == 1 + assert isinstance(parts[0].root, TextPart) + assert parts[0].root.text == error_message + + call_args_list = mock_updater.update_status.call_args_list + assert any( + call[1].get("state") == TaskState.failed + and call[1].get("final") is True + and call[1].get("message") == "error_message_obj" + for call in call_args_list + ) + + async def test_execute_processes_multiple_response_messages( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor that returns multiple response messages + Act: Call execute method + Assert: All messages are processed through handle_events + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message1 = Message(role="assistant", contents=[Content.from_text(text="First")]) + response_message2 = Message(role="assistant", contents=[Content.from_text(text="Second")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message1, response_message2] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + # Mock handle_events + executor.handle_events = AsyncMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + assert executor.handle_events.call_count == 2 + + async def test_execute_passes_query_to_run( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with request + Act: Call execute method + Assert: Query text is passed to run method with default stream and kwargs + """ + # Arrange + query_text = "Hello agent" + mock_request_context.get_user_input = MagicMock(return_value=query_text) + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + executor._agent.run.assert_called_once_with( + query_text, session=executor._agent.create_session(), stream=False + ) + + async def test_execute_with_stream_enabled( + self, + mock_agent: MagicMock, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with stream=True + Act: Call execute method + Assert: _run_stream is called and passes stream=True to run + """ + # Arrange + executor = A2AExecutor(agent=mock_agent, stream=True) + query_text = "Hello agent" + mock_request_context.get_user_input = MagicMock(return_value=query_text) + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + mock_response_stream = MagicMock() + mock_response_stream.with_transform_hook = MagicMock(return_value=mock_response_stream) + mock_response_stream.get_final_response = AsyncMock() + mock_agent.run = MagicMock(return_value=mock_response_stream) + mock_agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_agent.run.assert_called_once_with(query_text, session=mock_agent.create_session(), stream=True) + mock_response_stream.with_transform_hook.assert_called_once() + mock_response_stream.get_final_response.assert_called_once() + + async def test_execute_with_run_kwargs( + self, + mock_agent: MagicMock, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with run_kwargs + Act: Call execute method + Assert: run_kwargs are passed to run method + """ + # Arrange + run_kwargs = {"temperature": 0.5, "max_tokens": 100} + executor = A2AExecutor(agent=mock_agent, run_kwargs=run_kwargs) + query_text = "Hello agent" + mock_request_context.get_user_input = MagicMock(return_value=query_text) + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + mock_agent.run = AsyncMock(return_value=response) + mock_agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_agent.run.assert_called_once_with( + query_text, session=mock_agent.create_session(), stream=False, **run_kwargs + ) + + +class TestA2AExecutorHandleEvents: + """Tests for A2AExecutor.handle_events method.""" + + async def test_run_method_with_single_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test the private _run method with a single message (not a list).""" + # Arrange + query = "test query" + session = MagicMock() + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = response_message # Not a list + executor._agent.run = AsyncMock(return_value=response) + executor.handle_events = AsyncMock() + + # Act + await executor._run(query, session, mock_updater) + + # Assert + executor.handle_events.assert_called_once_with(response_message, mock_updater) + + @fixture + def mock_updater(self) -> MagicMock: + """Create a mock execution context.""" + updater = MagicMock() + updater.update_status = AsyncMock() + updater.new_agent_message = MagicMock(return_value="mock_message") + return updater + + async def test_ignore_user_messages(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that messages from USER role are ignored.""" + # Arrange + message = Message( + contents=[Content.from_text(text="User input")], + role="user", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_not_called() + + async def test_ignore_messages_with_no_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that messages with no contents are ignored.""" + # Arrange + message = Message( + contents=[], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_not_called() + + async def test_handle_text_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with text content.""" + # Arrange + text = "Hello, this is a test message" + message = Message( + contents=[Content.from_text(text=text)], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + assert mock_updater.new_agent_message.called + + async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with multiple text contents.""" + # Arrange + message = Message( + contents=[ + Content.from_text(text="First message"), + Content.from_text(text="Second message"), + ], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + assert mock_updater.new_agent_message.called + + async def test_handle_data_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with data content.""" + # Arrange + data = b"test file data" + message = Message( + contents=[Content.from_data(data=data, media_type="application/octet-stream")], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + + async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with URI content.""" + # Arrange + uri = "https://example.com/file.pdf" + message = Message( + contents=[Content.from_uri(uri=uri, media_type="application/pdf")], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + + async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with mixed content types.""" + # Arrange + data = b"file data" + + message = Message( + contents=[ + Content.from_text(text="Processing file..."), + Content.from_data(data=data, media_type="application/octet-stream"), + Content.from_uri(uri="https://example.com/reference.pdf", media_type="application/pdf"), + ], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + + async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with additional properties metadata.""" + # Arrange + additional_props = {"custom_field": "custom_value", "priority": "high"} + message = Message( + contents=[Content.from_text(text="Test message")], + role="assistant", + additional_properties=additional_props, + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + mock_updater.new_agent_message.assert_called_once() + call_args = mock_updater.new_agent_message.call_args + assert call_args.kwargs["metadata"] == additional_props + + async def test_handle_with_no_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages without additional properties.""" + # Arrange + message = Message( + contents=[Content.from_text(text="Test message")], + role="assistant", + additional_properties=None, + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + mock_updater.new_agent_message.assert_called_once() + call_args = mock_updater.new_agent_message.call_args + assert call_args.kwargs["metadata"] == {} + + async def test_parts_list_passed_to_new_agent_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that parts list is correctly passed to new_agent_message.""" + # Arrange + message = Message( + contents=[ + Content.from_text(text="Message 1"), + Content.from_text(text="Message 2"), + ], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.new_agent_message.assert_called_once() + call_kwargs = mock_updater.new_agent_message.call_args.kwargs + assert "parts" in call_kwargs + parts_list = call_kwargs["parts"] + assert len(parts_list) == 2 + + async def test_task_state_always_working(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that task state is always set to working.""" + # Arrange + message = Message( + contents=[Content.from_text(text="Any message")], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + call_kwargs = mock_updater.update_status.call_args.kwargs + assert call_kwargs["state"] == TaskState.working + + async def test_handle_agent_response_update_no_streamed_set( + self, executor: A2AExecutor, mock_updater: MagicMock + ) -> None: + """Test handling AgentResponseUpdate (streaming) without a tracking set.""" + # Arrange + update = AgentResponseUpdate( + contents=[Content.from_text(text="Streaming chunk")], + role="assistant", + message_id="msg-1", + ) + mock_updater.add_artifact = AsyncMock() + + # Act + await executor.handle_events(update, mock_updater) + + # Assert + mock_updater.add_artifact.assert_called_once() + call_kwargs = mock_updater.add_artifact.call_args.kwargs + assert call_kwargs["artifact_id"] == "msg-1" + assert call_kwargs["append"] is None + + async def test_handle_agent_response_update_first_time( + self, executor: A2AExecutor, mock_updater: MagicMock + ) -> None: + """Test handling AgentResponseUpdate (streaming) for the first time with a tracking set.""" + # Arrange + update = AgentResponseUpdate( + contents=[Content.from_text(text="Streaming chunk")], + role="assistant", + message_id="msg-1", + ) + mock_updater.add_artifact = AsyncMock() + streamed_artifact_ids = set() + + # Act + await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids) + + # Assert + mock_updater.add_artifact.assert_called_once() + call_kwargs = mock_updater.add_artifact.call_args.kwargs + assert call_kwargs["append"] is None + assert "msg-1" in streamed_artifact_ids + + async def test_handle_agent_response_update_subsequent_time( + self, executor: A2AExecutor, mock_updater: MagicMock + ) -> None: + """Test handling AgentResponseUpdate (streaming) for subsequent times with a tracking set.""" + # Arrange + update = AgentResponseUpdate( + contents=[Content.from_text(text="Next chunk")], + role="assistant", + message_id="msg-1", + ) + mock_updater.add_artifact = AsyncMock() + streamed_artifact_ids = {"msg-1"} + + # Act + await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids) + + # Assert + mock_updater.add_artifact.assert_called_once() + call_kwargs = mock_updater.add_artifact.call_args.kwargs + assert call_kwargs["append"] is True + + async def test_handle_unsupported_content_type(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with unsupported content types.""" + # Arrange + message = Message( + contents=[Content(type="unknown", text="Some text")], + role="assistant", + ) + + # Act + with patch("agent_framework_a2a._a2a_executor.logger") as mock_logger: + await executor.handle_events(message, mock_updater) + + # Assert + mock_logger.warning.assert_called_once() + mock_updater.update_status.assert_not_called() + + +class TestA2AExecutorIntegration: + """Integration tests for A2AExecutor.""" + + async def test_full_execution_flow_with_responses( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with all mocked dependencies + Act: Execute full flow from request to completion + Assert: All components interact correctly + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello agent") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response = MagicMock(spec=AgentResponse) + response_message = MagicMock(spec=Message) + response.messages = [response_message] + response_message.contents = [Content.from_text(text="Hello user")] + response_message.role = "assistant" + response_message.additional_properties = None + + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + executor.handle_events = AsyncMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_updater.submit.assert_called_once() + mock_updater.start_work.assert_called_once() + executor.handle_events.assert_called_once() + mock_updater.complete.assert_called_once() diff --git a/python/packages/a2a/tests/test_utils.py b/python/packages/a2a/tests/test_utils.py new file mode 100644 index 0000000000..2c73b2e7cf --- /dev/null +++ b/python/packages/a2a/tests/test_utils.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest + +from agent_framework_a2a._utils import get_uri_data + + +def test_get_uri_data_valid() -> None: + """Test get_uri_data with valid data URIs.""" + # Simple text/plain + uri = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" + assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ==" + + # Image png + uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + assert get_uri_data(uri) == "iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + + # Application octet-stream + uri = "data:application/octet-stream;base64,AQIDBA==" + assert get_uri_data(uri) == "AQIDBA==" + + +def test_get_uri_data_invalid_format() -> None: + """Test get_uri_data with invalid URI formats.""" + invalid_uris = [ + "not-a-uri", + "http://example.com", + "data:text/plain;SGVsbG8sIFdvcmxkIQ==", # Missing base64 marker + "data:base64,SGVsbG8sIFdvcmxkIQ==", # Missing media type + "data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ==", # Extra parameters (current regex doesn't support) + "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ== extra", + ] + for uri in invalid_uris: + with pytest.raises(ValueError, match="Invalid data URI format"): + get_uri_data(uri) + + +def test_get_uri_data_empty() -> None: + """Test get_uri_data with empty string.""" + with pytest.raises(ValueError, match="Invalid data URI format"): + get_uri_data("") diff --git a/python/packages/core/agent_framework/a2a/__init__.py b/python/packages/core/agent_framework/a2a/__init__.py index 7c7de63456..90daf5380a 100644 --- a/python/packages/core/agent_framework/a2a/__init__.py +++ b/python/packages/core/agent_framework/a2a/__init__.py @@ -7,6 +7,7 @@ This module lazily re-exports objects from: Supported classes: - A2AAgent +- A2AExecutor """ import importlib @@ -14,7 +15,7 @@ from typing import Any IMPORT_PATH = "agent_framework_a2a" PACKAGE_NAME = "agent-framework-a2a" -_IMPORTS = ["A2AAgent"] +_IMPORTS = ["A2AAgent", "A2AExecutor"] def __getattr__(name: str) -> Any: diff --git a/python/packages/core/agent_framework/a2a/__init__.pyi b/python/packages/core/agent_framework/a2a/__init__.pyi index 5a54bb22a9..65aa8f1a37 100644 --- a/python/packages/core/agent_framework/a2a/__init__.pyi +++ b/python/packages/core/agent_framework/a2a/__init__.pyi @@ -1,9 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_a2a import ( - A2AAgent, -) +from agent_framework_a2a import A2AAgent, A2AExecutor -__all__ = [ - "A2AAgent", -] +__all__ = ["A2AAgent", "A2AExecutor"] diff --git a/python/samples/04-hosting/a2a/README.md b/python/samples/04-hosting/a2a/README.md index f377eed8ba..aca25a4dab 100644 --- a/python/samples/04-hosting/a2a/README.md +++ b/python/samples/04-hosting/a2a/README.md @@ -12,6 +12,7 @@ The remaining files are supporting modules used by the server: | File | Description | |------|-------------| +| [`agent_framework_to_a2a.py`](agent_framework_to_a2a.py) | Exposes an agent_framework agent as an A2A-compliant server. Demonstrates how to wrap an agent_framework agent and expose it as an A2A service that other A2A clients can discover and communicate with. | | [`agent_definitions.py`](agent_definitions.py) | Agent and AgentCard factory definitions for invoice, policy, and logistics agents. | | [`agent_executor.py`](agent_executor.py) | Bridges the a2a-sdk `AgentExecutor` interface to Agent Framework agents. | | [`invoice_data.py`](invoice_data.py) | Mock invoice data and tool functions for the invoice agent. | @@ -60,6 +61,9 @@ In a separate terminal (from the same directory), point the client at a running ```powershell $env:A2A_AGENT_HOST = "http://localhost:5001/" uv run python agent_with_a2a.py + +# A2A server exposing an agent_framework agent +uv run python agent_framework_to_a2a.py ``` ### 3. Run the Function Tools Sample diff --git a/python/samples/04-hosting/a2a/agent_framework_to_a2a.py b/python/samples/04-hosting/a2a/agent_framework_to_a2a.py new file mode 100644 index 0000000000..0693b61b0a --- /dev/null +++ b/python/samples/04-hosting/a2a/agent_framework_to_a2a.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft. All rights reserved. + +import uvicorn +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, +) +from agent_framework import Agent +from agent_framework.a2a import A2AExecutor +from agent_framework.openai import OpenAIChatClient +from dotenv import load_dotenv + +load_dotenv() + +if __name__ == "__main__": + # --8<-- [start:AgentSkill] + flight_skill = AgentSkill( + id="Flight_Booking", + name="Flight Booking", + description="Search and book flights across Europe.", + tags=["flights", "travel", "europe"], + examples=[], + ) + hotel_skill = AgentSkill( + id="Hotel_Booking", + name="Hotel Booking", + description="Search and book hotels across Europe.", + tags=["hotels", "travel", "accommodation"], + examples=[], + ) + # --8<-- [end:AgentSkill] + + # --8<-- [start:AgentCard] + # This will be the public-facing agent card + public_agent_card = AgentCard( + name="Europe Travel Agent", + description="A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe.", + url="http://localhost:9999/", + version="1.0.0", + defaultInputModes=["text"], + defaultOutputModes=["text"], + capabilities=AgentCapabilities(streaming=True), + skills=[flight_skill, hotel_skill], + ) + # --8<-- [end:AgentCard] + + agent = Agent( + client=OpenAIChatClient(), + name="Europe Travel Agent", + instructions="You are a helpful Europe Travel Agent. You can help users search and book flights and hotels across Europe." + ) + + request_handler = DefaultRequestHandler( + agent_executor=A2AExecutor(agent), + task_store=InMemoryTaskStore(), + ) + + server = A2AStarletteApplication( + agent_card=public_agent_card, + http_handler=request_handler, + ) + + server = server.build() + # print(schemas.get_schema(server.routes)) + + uvicorn.run(server, host="0.0.0.0", port=9999) From 63c0a517970722e0765702019958a1830e055348 Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:14:44 +0530 Subject: [PATCH 06/40] Python: Add OpenTelemetry integration for GitHubCopilotAgent (#5142) * Python: Add OpenTelemetry integration for GitHubCopilotAgent - Split GitHubCopilotAgent into RawGitHubCopilotAgent (core, no OTel) and GitHubCopilotAgent(AgentTelemetryLayer, RawGitHubCopilotAgent) with tracing - Add default_options property to expose model for span attributes - Export RawGitHubCopilotAgent from all public namespaces - Add github_copilot_with_observability.py sample and update README * Python: Fix OTEL_SERVICE_NAME default in GitHub Copilot README Co-Authored-By: Claude Sonnet 4.6 * Python: Add unit tests for RawGitHubCopilotAgent.default_options property * Python: Address review feedback on GitHubCopilotAgent OTel integration - Add middleware param to GitHubCopilotAgent.run() overloads so per-call middleware is explicitly forwarded through AgentTelemetryLayer - Remove github_copilot_with_observability.py sample per feedback; replace with inline snippet + link to observability samples in README * Python: Address review feedback on log_level and session kwargs typing - Add middleware param to RawGitHubCopilotAgent.run() overloads for interface compatibility with AgentTelemetryLayer - Fix import in README observability snippet to use agent_framework.github * Python: Add AgentMiddlewareLayer to GitHubCopilotAgent MRO Follow FoundryAgent pattern: AgentMiddlewareLayer runs outside the telemetry span so middleware execution time is not captured in traces. Overloads removed as AgentMiddlewareLayer.run() handles dispatch via MRO. * Python: Add explicit __init__ to GitHubCopilotAgent for auto-complete and docstrings * Python: Address review feedback on middleware warning and test assertions - Add assert "timeout" not in opts to test_default_options_includes_model_for_telemetry to document the intentional asymmetry where timeout is extracted into _settings and not returned in default_options. - Replace silent del middleware with a logged warning when per-run middleware is passed to RawGitHubCopilotAgent, making it clear that the GitHub Copilot SDK handles tool execution internally and chat/function middleware cannot be injected. * Python: Use Self for __aenter__ return type in RawGitHubCopilotAgent Address review feedback: use typing.Self (3.11+) / typing_extensions.Self (3.10) for __aenter__ so subclasses like GitHubCopilotAgent get the correct return type from async context manager usage. --------- Co-authored-by: Claude Sonnet 4.6 --- .../core/agent_framework/github/__init__.py | 2 + .../core/agent_framework/github/__init__.pyi | 2 + .../__init__.py | 3 +- .../agent_framework_github_copilot/_agent.py | 164 +++++++++++++++--- .../tests/test_github_copilot_agent.py | 25 +++ .../providers/github_copilot/README.md | 16 ++ 6 files changed, 191 insertions(+), 21 deletions(-) diff --git a/python/packages/core/agent_framework/github/__init__.py b/python/packages/core/agent_framework/github/__init__.py index 831a838c0d..0ff654fa17 100644 --- a/python/packages/core/agent_framework/github/__init__.py +++ b/python/packages/core/agent_framework/github/__init__.py @@ -9,6 +9,7 @@ Supported classes: - GitHubCopilotAgent - GitHubCopilotOptions - GitHubCopilotSettings +- RawGitHubCopilotAgent """ import importlib @@ -18,6 +19,7 @@ _IMPORTS: dict[str, tuple[str, str]] = { "GitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"), "GitHubCopilotOptions": ("agent_framework_github_copilot", "agent-framework-github-copilot"), "GitHubCopilotSettings": ("agent_framework_github_copilot", "agent-framework-github-copilot"), + "RawGitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"), } diff --git a/python/packages/core/agent_framework/github/__init__.pyi b/python/packages/core/agent_framework/github/__init__.pyi index 567ab9490d..f7b68966cf 100644 --- a/python/packages/core/agent_framework/github/__init__.pyi +++ b/python/packages/core/agent_framework/github/__init__.pyi @@ -4,10 +4,12 @@ from agent_framework_github_copilot import ( GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, + RawGitHubCopilotAgent, ) __all__ = [ "GitHubCopilotAgent", "GitHubCopilotOptions", "GitHubCopilotSettings", + "RawGitHubCopilotAgent", ] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py index 432427fd9d..56b46f8dee 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings +from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, RawGitHubCopilotAgent try: __version__ = importlib.metadata.version(__name__) @@ -13,5 +13,6 @@ __all__ = [ "GitHubCopilotAgent", "GitHubCopilotOptions", "GitHubCopilotSettings", + "RawGitHubCopilotAgent", "__version__", ] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 8015f561d9..b7fc709773 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -9,7 +9,13 @@ import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence from typing import Any, ClassVar, Generic, Literal, TypedDict, overload +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + from agent_framework import ( + AgentMiddlewareLayer, AgentMiddlewareTypes, AgentResponse, AgentResponseUpdate, @@ -27,6 +33,7 @@ from agent_framework._settings import load_settings from agent_framework._tools import FunctionTool, ToolTypes from agent_framework._types import AgentRunInputs, normalize_tools from agent_framework.exceptions import AgentException +from agent_framework.observability import AgentTelemetryLayer try: from copilot import CopilotClient, CopilotSession, SubprocessConfig @@ -135,8 +142,11 @@ OptionsT = TypeVar( ) -class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): - """A GitHub Copilot Agent. +class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]): + """A GitHub Copilot Agent without telemetry layers. + + This is the core GitHub Copilot agent implementation without OpenTelemetry instrumentation. + For most use cases, prefer :class:`GitHubCopilotAgent` which includes telemetry support. This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities within the Agent Framework. It supports both streaming and non-streaming responses, @@ -149,7 +159,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): .. code-block:: python - async with GitHubCopilotAgent() as agent: + async with RawGitHubCopilotAgent() as agent: response = await agent.run("Hello, world!") print(response) @@ -157,22 +167,11 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): .. code-block:: python - from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions + from agent_framework_github_copilot import RawGitHubCopilotAgent, GitHubCopilotOptions - agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent( default_options={"model": "claude-sonnet-4", "timeout": 120} ) - - With tools: - - .. code-block:: python - - def get_weather(city: str) -> str: - return f"Weather in {city} is sunny" - - - async with GitHubCopilotAgent(tools=[get_weather]) as agent: - response = await agent.run("What's the weather in Seattle?") """ AGENT_PROVIDER_NAME: ClassVar[str] = "github.copilot" @@ -200,9 +199,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Keyword Args: client: Optional pre-configured CopilotClient instance. If not provided, a new client will be created using the other parameters. - id: ID of the GitHubCopilotAgent. - name: Name of the GitHubCopilotAgent. - description: Description of the GitHubCopilotAgent. + id: ID of the RawGitHubCopilotAgent. + name: Name of the RawGitHubCopilotAgent. + description: Description of the RawGitHubCopilotAgent. context_providers: Context Providers, to be used by the agent. middleware: Agent middleware used by the agent. tools: Tools to use for the agent. Can be functions @@ -258,7 +257,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): self._default_options = opts self._started = False - async def __aenter__(self) -> GitHubCopilotAgent[OptionsT]: + async def __aenter__(self) -> Self: """Start the agent when entering async context.""" await self.start() return self @@ -308,6 +307,20 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): self._started = False + @property + def default_options(self) -> dict[str, Any]: + """Expose default options including model from settings. + + Returns a merged dict of ``_default_options`` with the resolved ``model`` + from settings injected under the ``model`` key. This is read by + :class:`AgentTelemetryLayer` to include the model name in span attributes. + """ + opts = dict(self._default_options) + model = self._settings.get("model") + if model: + opts["model"] = model + return opts + @overload def run( self, @@ -315,7 +328,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, stream: Literal[False] = False, session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload @@ -325,7 +340,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, stream: Literal[True], session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( @@ -334,7 +351,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, stream: bool = False, session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, options: OptionsT | None = None, + **kwargs: Any, # type: ignore[override] ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -348,7 +367,12 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Keyword Args: stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). + middleware: Not used by this agent directly. Accepted for interface + compatibility; pass middleware via :class:`GitHubCopilotAgent` which + forwards it through :class:`AgentTelemetryLayer`. options: Runtime options (model, timeout, etc.). + kwargs: Additional keyword arguments for compatibility with the shared agent + interface (e.g. compaction_strategy, tokenizer). Not used by this agent. Returns: When stream=False: An Awaitable[AgentResponse]. @@ -357,6 +381,12 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Raises: AgentException: If the request fails. """ + if middleware: + logger.warning( + "Per-run middleware is not supported by RawGitHubCopilotAgent: the GitHub Copilot SDK " + "handles tool execution internally, so chat/function middleware cannot be injected into " + "the tool call path. Use agent-level middleware via the GitHubCopilotAgent constructor instead." + ) if stream: ctx_holder: dict[str, Any] = {} @@ -767,3 +797,97 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): mcp_servers=self._mcp_servers or None, provider=self._provider or None, ) + + +class GitHubCopilotAgent( # type: ignore[misc] + AgentMiddlewareLayer, + AgentTelemetryLayer, + RawGitHubCopilotAgent[OptionsT], + Generic[OptionsT], +): + """A GitHub Copilot Agent with full middleware and telemetry support. + + This is the recommended agent class for most use cases. It includes + middleware support and OpenTelemetry-based telemetry for observability, + with middleware running outside the telemetry span so middleware execution + time is not captured in traces. For a minimal implementation without these + layers, use :class:`RawGitHubCopilotAgent`. + + Examples: + Basic usage: + + .. code-block:: python + + async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello, world!") + print(response) + + With explicitly typed options: + + .. code-block:: python + + from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions + + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "claude-sonnet-4-5", "timeout": 120} + ) + + With observability: + + .. code-block:: python + + from agent_framework.observability import configure_otel_providers + + configure_otel_providers() + async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello, world!") + """ + + def __init__( + self, + instructions: str | None = None, + *, + client: CopilotClient | None = None, + id: str | None = None, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + default_options: OptionsT | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a GitHub Copilot Agent with full middleware and telemetry. + + Args: + instructions: System message for the agent. + + Keyword Args: + client: Optional pre-configured CopilotClient instance. If not provided, + a new client will be created using the other parameters. + id: ID of the agent. + name: Name of the agent. + description: Description of the agent. + context_providers: Context providers to be used by the agent. + middleware: Agent middleware used by the agent. + tools: Tools to use for the agent. Can be functions or tool definition dicts. + These are converted to Copilot SDK tools internally. + default_options: Default options for the agent. Can include cli_path, model, + timeout, log_level, etc. + env_file_path: Optional path to .env file for loading configuration. + env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. + """ + super().__init__( + instructions, + client=client, + id=id, + name=name, + description=description, + context_providers=context_providers, + middleware=middleware, + tools=tools, + default_options=default_options, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 9e1459db93..4d953d9c36 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -189,6 +189,31 @@ class TestGitHubCopilotAgentInit: "content": "Direct instructions", } + def test_default_options_includes_model_for_telemetry(self) -> None: + """Test that default_options merges model from settings for AgentTelemetryLayer span attributes.""" + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "claude-sonnet-4-5", "timeout": 120} + ) + opts = agent.default_options + assert opts["model"] == "claude-sonnet-4-5" + assert "timeout" not in opts # timeout is extracted into _settings, not returned in default_options + + def test_default_options_without_model_configured(self) -> None: + """Test that default_options works correctly when no model is configured.""" + agent = GitHubCopilotAgent(instructions="Helper") + opts = agent.default_options + assert "model" not in opts + assert opts.get("system_message") == {"mode": "append", "content": "Helper"} + + def test_default_options_returns_independent_copy(self) -> None: + """Test that mutating the returned dict does not affect internal state.""" + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "gpt-5.1-mini"} + ) + opts = agent.default_options + opts["model"] = "mutated" + assert agent._settings.get("model") == "gpt-5.1-mini" + class TestGitHubCopilotAgentLifecycle: """Test cases for agent lifecycle management.""" diff --git a/python/samples/02-agents/providers/github_copilot/README.md b/python/samples/02-agents/providers/github_copilot/README.md index 572ec9c444..5f8ee344d6 100644 --- a/python/samples/02-agents/providers/github_copilot/README.md +++ b/python/samples/02-agents/providers/github_copilot/README.md @@ -24,6 +24,22 @@ The following environment variables can be configured: | `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` | | `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` | +## Observability + +`GitHubCopilotAgent` has OpenTelemetry tracing built-in. To enable it, call `configure_otel_providers()` before running the agent: + +```python +from agent_framework.observability import configure_otel_providers +from agent_framework.github import GitHubCopilotAgent + +configure_otel_providers(enable_console_exporters=True) + +async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello!") +``` + +See the [observability samples](../../../02-agents/observability/) for full examples with OTLP exporters. + ## Examples | File | Description | From 62e02da698b4b85932323b3067154071b41d74d7 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 24 Apr 2026 11:25:03 +0200 Subject: [PATCH 07/40] Python: update FoundryAgent for hosted agent sessions (#5447) * fixes to FoundryAgent to connect to new hosted agents Co-authored-by: Copilot * fix mypy Co-authored-by: Copilot * Python: remove Foundry service session helpers Remove the public hosted-agent service session CRUD helpers from FoundryAgent and drop the related feature-stage inventory entry. Update the hosted-agent sample to create and delete service sessions directly through the preview AIProjectClient APIs, and tighten a few test harnesses surfaced by full workspace validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix from merge * fix hosted env detection Co-authored-by: Copilot * reverted sample update * fix tests and code Co-authored-by: Copilot * remove aenter * skipping some tests Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_telemetry.py | 11 +- .../core/agent_framework/foundry/__init__.py | 1 + .../tests/devui/test_ui_memory_regression.py | 8 +- .../agent_framework_foundry/__init__.py | 3 +- .../foundry/agent_framework_foundry/_agent.py | 243 ++++++++++++++++-- .../agent_framework_foundry/_chat_client.py | 6 +- .../tests/foundry/test_foundry_agent.py | 200 +++++++++++--- .../foundry/test_foundry_embedding_client.py | 1 + .../_responses.py | 58 ++--- .../foundry_hosting/tests/test_responses.py | 65 +++-- .../gemini/tests/test_gemini_client.py | 4 +- .../openai/test_openai_chat_client_azure.py | 2 + .../chat_client/built_in_chat_clients.py | 21 +- .../foundry-hosted-agents/responses/README.md | 2 +- .../responses/using_deployed_agent.py | 156 ++++++++--- 15 files changed, 601 insertions(+), 180 deletions(-) diff --git a/python/packages/core/agent_framework/_telemetry.py b/python/packages/core/agent_framework/_telemetry.py index f7ca2ce030..ec3d55be4b 100644 --- a/python/packages/core/agent_framework/_telemetry.py +++ b/python/packages/core/agent_framework/_telemetry.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import logging import os from typing import Any, Final @@ -60,13 +61,12 @@ def _detect_hosted_environment() -> None: global _hosted_env_detected if _hosted_env_detected: return - _hosted_env_detected = True - env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR) - if env_value is not None: + if (env_value := os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)) is not None: # Env var exists — trust its value and skip the fallback. if env_value: _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) + _hosted_env_detected = True return # Env var not set — fall back to AgentConfig as a second layer of defense. @@ -78,13 +78,12 @@ def _detect_hosted_environment() -> None: return except (ModuleNotFoundError, ValueError): return - try: + with contextlib.suppress(ImportError, AttributeError): from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports] if AgentConfig.from_env().is_hosted: _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) - except (ImportError, AttributeError): - pass + _hosted_env_detected = True def get_user_agent() -> str: diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index c1e47cd6b8..79736b5ca7 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -14,6 +14,7 @@ from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), diff --git a/python/packages/devui/tests/devui/test_ui_memory_regression.py b/python/packages/devui/tests/devui/test_ui_memory_regression.py index 7e5cd10e27..b042764f6c 100644 --- a/python/packages/devui/tests/devui/test_ui_memory_regression.py +++ b/python/packages/devui/tests/devui/test_ui_memory_regression.py @@ -655,7 +655,13 @@ async def test_devui_streaming_renderer_memory_is_bounded( ) try: - websocket_url = await _get_devtools_websocket_url(debug_port) + try: + websocket_url = await _get_devtools_websocket_url(debug_port) + except RuntimeError as exc: + return_code = browser_process.poll() + if return_code is not None: + pytest.skip(f"Chromium exited before DevTools became available (code {return_code}).") + pytest.skip(str(exc)) async with websocket_connect(websocket_url, max_size=None) as websocket: client = _CDPClient(websocket) diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index b70d1720f2..93953d667a 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient +from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient from ._embedding_client import ( FoundryEmbeddingClient, @@ -25,6 +25,7 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "FoundryAgent", + "FoundryAgentOptions", "FoundryChatClient", "FoundryChatOptions", "FoundryEmbeddingClient", diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index b473c787e5..da64b65cd9 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast from agent_framework import ( AgentMiddlewareLayer, + AgentSession, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, ContextProvider, @@ -52,11 +53,13 @@ else: if TYPE_CHECKING: from agent_framework import ( Agent, + AgentRunInputs, ChatAndFunctionMiddlewareTypes, ContextProvider, MiddlewareTypes, ToolTypes, ) + from agent_framework._agents import _RunContext # pyright: ignore[reportPrivateUsage] logger: logging.Logger = logging.getLogger("agent_framework.foundry") @@ -81,14 +84,54 @@ class FoundryAgentSettings(TypedDict, total=False): agent_version: str | None +class FoundryAgentOptions(OpenAIChatOptions, total=False): + """Microsoft Foundry agent-specific chat options. + + Extends ``OpenAIChatOptions`` with hosted-agent session configuration used by + ``FoundryAgent`` / ``RawFoundryAgent``. + + Keyword Args: + extra_body: Additional request body values sent to the Responses API. + isolation_key: Isolation key used when lazily creating a hosted-agent + session through ``project_client.beta.agents.create_session(...)``. + """ + + extra_body: dict[str, Any] + isolation_key: str + + FoundryAgentOptionsT = TypeVar( "FoundryAgentOptionsT", bound=TypedDict, # type: ignore[valid-type] - default="OpenAIChatOptions", + default="FoundryAgentOptions", covariant=True, ) +def _merge_extra_body(extra_body: Any | None, *, additions: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Normalize and merge provider-specific extra_body values.""" + if extra_body is None: + merged: dict[str, Any] = {} + elif isinstance(extra_body, Mapping): + merged = dict(cast(Mapping[str, Any], extra_body)) + else: + raise TypeError(f"extra_body must be a mapping when provided, got {type(extra_body).__name__}.") + + if additions: + merged.update(additions) + return merged + + +def _uses_foundry_agent_session(conversation_id: Any) -> bool: + """Return whether a conversation_id should be treated as a Foundry agent session id.""" + return ( + isinstance(conversation_id, str) + and bool(conversation_id) + and not conversation_id.startswith("resp_") + and not conversation_id.startswith("conv_") + ) + + class RawFoundryAgentChatClient( # type: ignore[misc] RawOpenAIChatClient[FoundryAgentOptionsT], Generic[FoundryAgentOptionsT], @@ -167,13 +210,15 @@ class RawFoundryAgentChatClient( # type: ignore[misc] ) resolved_endpoint = settings.get("project_endpoint") - self.agent_name = settings.get("agent_name") - self.agent_version = settings.get("agent_version") + agent_name_setting = settings.get("agent_name") + self.agent_version: str | None = settings.get("agent_version") + self.allow_preview = allow_preview or False - if not self.agent_name: + if not agent_name_setting: raise ValueError( "Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable." ) + self.agent_name = agent_name_setting # Create or use provided project client self._should_close_client = False @@ -197,11 +242,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc] self.project_client = AIProjectClient(**project_client_kwargs) self._should_close_client = True - # Get OpenAI client from project - async_client = self.project_client.get_openai_client() - + openai_client_kwargs: dict[str, Any] = {} + if default_headers: + openai_client_kwargs["default_headers"] = dict(default_headers) + if allow_preview: + openai_client_kwargs["agent_name"] = self.agent_name super().__init__( - async_client=async_client, + async_client=self.project_client.get_openai_client(**openai_client_kwargs), default_headers=default_headers, instruction_role=instruction_role, compaction_strategy=compaction_strategy, @@ -209,13 +256,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc] additional_properties=additional_properties, ) - def _get_agent_reference(self) -> dict[str, str]: - """Build the agent reference dict for the Responses API.""" - ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item] - if self.agent_version: - ref["version"] = self.agent_version - return ref - @override def as_agent( self, @@ -270,7 +310,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: - """Prepare options for the Responses API, injecting agent reference and validating tools.""" + """Prepare options for the Responses API and validate client-side tools.""" # Validate tools — only FunctionTool allowed tools = options.get("tools", []) if tools: @@ -292,18 +332,58 @@ class RawFoundryAgentChatClient( # type: ignore[misc] if "input" in run_options and isinstance(run_options["input"], list): run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) - # Inject agent reference - run_options["extra_body"] = {"agent_reference": self._get_agent_reference()} + # Merge caller-supplied extra_body with any agent-specific request payload. + conversation_id = options.get("conversation_id") + extra_body = _merge_extra_body(run_options.pop("extra_body", None)) + if _uses_foundry_agent_session(conversation_id): + run_options.pop("previous_response_id", None) + run_options.pop("conversation", None) + extra_body["agent_session_id"] = conversation_id + if extra_body: + run_options["extra_body"] = extra_body + + run_options.pop("isolation_key", None) # Strip tools from request body - Foundry API rejects requests with both - # agent_reference and tools present. FunctionTools are invoked client-side + # agent endpoint and tools present. FunctionTools are invoked client-side # by the function invocation layer, not sent to the service. - run_options.pop("tools", None) - run_options.pop("tool_choice", None) - run_options.pop("parallel_tool_calls", None) + run_options.pop("model", None) + if not self.allow_preview: + run_options.pop("tools", None) + run_options.pop("tool_choice", None) + run_options.pop("parallel_tool_calls", None) return run_options + @override + def _parse_response_from_openai( + self, + response: Any, + options: dict[str, Any], + ) -> Any: + parsed_response = super()._parse_response_from_openai(response, options) + if _uses_foundry_agent_session(options.get("conversation_id")): + parsed_response.conversation_id = None + return parsed_response + + @override + def _parse_chunk_from_openai( + self, + event: Any, + options: dict[str, Any], + function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, + ) -> Any: + parsed_chunk = super()._parse_chunk_from_openai( + event, + options, + function_call_ids, + seen_reasoning_delta_item_ids, + ) + if _uses_foundry_agent_session(options.get("conversation_id")): + parsed_chunk.conversation_id = None + return parsed_chunk + @override def _check_model_presence(self, options: dict[str, Any]) -> None: """Skip model check — model is configured on the Foundry agent.""" @@ -368,6 +448,26 @@ class RawFoundryAgentChatClient( # type: ignore[misc] return transformed + async def get_agent_version(self) -> str | None: + """Return the agent version if available, else None.""" + if self.agent_version is not None: + return self.agent_version + if not self.allow_preview: + return None + agent_details = await cast(Any, self.project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] + agent_name=self.agent_name + ) + versions_object = getattr(agent_details, "versions", None) + if not isinstance(versions_object, Mapping): + raise TypeError("Foundry agent details did not include a versions mapping.") + versions = cast(Mapping[str, Any], versions_object) + latest_version = versions.get("latest") + agent_version = getattr(cast(Any, latest_version), "version", None) + if not isinstance(agent_version, str): + raise TypeError("Foundry agent details did not include a latest version string.") + self.agent_version = agent_version + return agent_version + async def close(self) -> None: """Close the project client if we created it.""" if self._should_close_client: @@ -395,7 +495,7 @@ class _FoundryAgentChatClient( # type: ignore[misc] client = FoundryAgentClient( project_endpoint="https://your-project.services.ai.azure.com", agent_name="my-prompt-agent", - agent_version="1.0", + agent_version="1", credential=AzureCliCredential(), ) @@ -477,7 +577,7 @@ class RawFoundryAgent( # type: ignore[misc] agent = RawFoundryAgent( project_endpoint="https://your-project.services.ai.azure.com", agent_name="my-prompt-agent", - agent_version="1.0", + agent_version="1", credential=AzureCliCredential(), ) result = await agent.run("Hello!") @@ -570,7 +670,7 @@ class RawFoundryAgent( # type: ignore[misc] client=client, # type: ignore[arg-type] instructions=instructions, id=id, - name=name, + name=name or agent_name, description=description, tools=tools, # type: ignore[arg-type] default_options=cast(FoundryAgentOptionsT | None, default_options), @@ -582,6 +682,81 @@ class RawFoundryAgent( # type: ignore[misc] additional_properties=dict(additional_properties) if additional_properties is not None else None, ) + def _resolve_service_session_isolation_key(self, isolation_key: str | None = None) -> str: + """Resolve the isolation key from an explicit value or default_options.""" + resolved_isolation_key = ( + isolation_key if isolation_key is not None else self.default_options.get("isolation_key") + ) + if resolved_isolation_key is None: + raise ValueError("isolation_key is required. Pass it explicitly or set default_options['isolation_key'].") + return resolved_isolation_key + + async def _create_service_session_id( + self, + *, + isolation_key: str | None = None, + ) -> str: + """Create a hosted Foundry service session and return the service session ID.""" + if not isinstance(self.client, RawFoundryAgentChatClient): + raise TypeError("_create_service_session_id requires a RawFoundryAgentChatClient-based client.") + if not self.client.allow_preview: + raise RuntimeError("Hosted Foundry service sessions require allow_preview=True.") + + create_session_kwargs: dict[str, Any] = { + "agent_name": self.client.agent_name, + "isolation_key": self._resolve_service_session_isolation_key(isolation_key), + } + if version := await self.client.get_agent_version(): + from azure.ai.projects.models import VersionRefIndicator + + create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=version) # type: ignore + + service_session = await self.client.project_client.beta.agents.create_session(**create_session_kwargs) + agent_session_id = getattr(service_session, "agent_session_id", None) + if not isinstance(agent_session_id, str) or not agent_session_id: + raise ValueError("Hosted Foundry session creation did not return a non-empty agent_session_id.") + + return agent_session_id + + @override + async def _prepare_run_context( + self, + *, + messages: AgentRunInputs | None, + session: AgentSession | None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, + options: Mapping[str, Any] | None, + compaction_strategy: CompactionStrategy | None, + tokenizer: TokenizerProtocol | None, + function_invocation_kwargs: Mapping[str, Any] | None, + client_kwargs: Mapping[str, Any] | None, + ) -> _RunContext: + runtime_options = dict(options) if options else {} + effective_options = { + **{key: value for key, value in self.default_options.items() if value is not None}, + **{key: value for key, value in runtime_options.items() if value is not None}, + } + + if ( + session is not None + and session.service_session_id is None + and effective_options.get("isolation_key") is not None + ): + session.service_session_id = await self._create_service_session_id( + isolation_key=cast(str | None, effective_options.get("isolation_key")), + ) + + return await super()._prepare_run_context( + messages=messages, + session=session, + tools=tools, + options=runtime_options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + ) + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, @@ -708,6 +883,19 @@ class FoundryAgent( # type: ignore[misc] ) -> None: """Initialize a Foundry Agent with full middleware and telemetry. + ``FoundryAgent`` supports both PromptAgents and HostedAgents. PromptAgents + typically provide ``agent_version`` directly. HostedAgents can omit + ``agent_version`` and, when they need preview-only session APIs, should + opt in with ``allow_preview=True`` when this class creates the underlying + ``AIProjectClient``. If you pass ``project_client`` explicitly, it must + already be configured for preview APIs before being passed to + ``FoundryAgent``. + + To lazily create HostedAgent service sessions inside the agent, pass an + ``isolation_key`` through ``default_options`` (or per-run options). The + agent stores the resulting HostedAgent session ID in + ``AgentSession.service_session_id`` and reuses it on subsequent runs. + Keyword Args: project_endpoint: The Foundry project endpoint URL. agent_name: The name of the Foundry agent to connect to. @@ -715,6 +903,9 @@ class FoundryAgent( # type: ignore[misc] credential: Azure credential for authentication. project_client: An existing AIProjectClient to use. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + Set this to ``True`` for HostedAgents that need preview-only + session APIs, including lazy service session creation from + ``isolation_key``. tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. context_providers: Optional context providers. middleware: Optional agent-level middleware. @@ -726,6 +917,8 @@ class FoundryAgent( # type: ignore[misc] description: Optional local description for the local agent wrapper. instructions: Optional instructions for the local agent wrapper. default_options: Default chat options for the local agent wrapper. + ``FoundryAgentOptions`` can include ``isolation_key`` and + ``extra_body`` when working with HostedAgents. require_per_service_call_history_persistence: Whether to require per-service-call chat history persistence when using local history providers. function_invocation_configuration: Optional function invocation configuration override. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 4428d69dc6..57522fb886 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -204,9 +204,13 @@ class RawFoundryChatClient( # type: ignore[misc] project_client_kwargs["allow_preview"] = allow_preview project_client = AIProjectClient(**project_client_kwargs) + openai_kwargs: dict[str, Any] = {} + if default_headers: + openai_kwargs["default_headers"] = default_headers + super().__init__( model=resolved_model, - async_client=project_client.get_openai_client(), + async_client=project_client.get_openai_client(**openai_kwargs), default_headers=default_headers, instruction_role=instruction_role, compaction_strategy=compaction_strategy, diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 829af6ab87..73670d0bbc 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -5,11 +5,12 @@ from __future__ import annotations import inspect import os import sys +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool +from agent_framework import AgentResponse, AgentSession, ChatContext, ChatMiddleware, ChatResponse, Message, tool from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential @@ -54,7 +55,7 @@ def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None: def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: - """Test construction with agent_name and project_client.""" + """Test construction with agent_name and project_client without preview agent binding.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() @@ -67,6 +68,27 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: assert client.agent_name == "test-agent" assert client.agent_version == "1.0" + mock_project.get_openai_client.assert_called_once_with() + + +def test_raw_foundry_agent_chat_client_init_passes_agent_name_when_preview_enabled() -> None: + """Test preview-enabled clients bind the OpenAI client to the agent endpoint.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + allow_preview=True, + default_headers={"x-test": "1"}, + ) + + assert client.agent_name == "hosted-agent" + mock_project.get_openai_client.assert_called_once_with( + agent_name="hosted-agent", + default_headers={"x-test": "1"}, + ) def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: @@ -80,38 +102,6 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) -def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None: - """Test agent reference includes version when provided.""" - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="my-agent", - agent_version="2.0", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} - - -def test_raw_foundry_agent_chat_client_get_agent_reference_without_version() -> None: - """Test agent reference omits version for HostedAgents.""" - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="hosted-agent", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "hosted-agent", "type": "agent_reference"} - assert "version" not in ref - - def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" @@ -196,12 +186,11 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to options={"tools": [my_func]}, ) - assert "extra_body" in result - assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + assert result == {} -async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> None: - """Test that _prepare_options strips tools, tool_choice, and parallel_tool_calls from run_options.""" +async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None: + """Test that _prepare_options strips model and tool-loop fields from run_options.""" mock_project = MagicMock() mock_openai = MagicMock() @@ -222,6 +211,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", new_callable=AsyncMock, return_value={ + "model": "gpt-4.1", "tools": [{"type": "function", "function": {"name": "my_func"}}], "tool_choice": "auto", "parallel_tool_calls": True, @@ -232,11 +222,69 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N options={"tools": [my_func]}, ) + assert "model" not in result assert "tools" not in result assert "tool_choice" not in result assert "parallel_tool_calls" not in result - assert "extra_body" in result - assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + assert result == {} + + +async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None: + """Test that service_session_id is forwarded as agent_session_id for hosted sessions.""" + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={ + "extra_body": {"custom": "value"}, + "previous_response_id": "should-be-removed", + }, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"conversation_id": "agent-session-123", "isolation_key": "iso-key"}, + ) + + assert result["extra_body"] == { + "custom": "value", + "agent_session_id": "agent-session-123", + } + assert "previous_response_id" not in result + assert "conversation" not in result + assert "isolation_key" not in result + + +def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id_for_agent_sessions() -> None: + """Test that agent-session continuations do not overwrite session.service_session_id.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + parsed = ChatResponse(conversation_id="resp_123") + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._parse_response_from_openai", + return_value=parsed, + ): + result = client._parse_response_from_openai( + response=MagicMock(), + options={"conversation_id": "agent-session-123"}, + ) + + assert result.conversation_id is None def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: @@ -366,6 +414,74 @@ def test_raw_foundry_agent_init_with_function_tools() -> None: assert agent.default_options.get("tools") is not None +async def test_raw_foundry_agent_prepare_run_context_creates_service_session_from_isolation_key() -> None: + """Test that RawFoundryAgent lazily creates a hosted session and stores it on service_session_id.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.beta = SimpleNamespace( + agents=SimpleNamespace( + create_session=AsyncMock(return_value=SimpleNamespace(agent_session_id="agent-session-123")) + ) + ) + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + allow_preview=True, + ) + session = AgentSession() + + with patch( + "agent_framework._agents.RawAgent._prepare_run_context", + new=AsyncMock(return_value={"ok": True}), + ) as mock_prepare_run_context: + result = await agent._prepare_run_context( + messages="hi", + session=session, + tools=None, + options={"isolation_key": "iso-key"}, + compaction_strategy=None, + tokenizer=None, + function_invocation_kwargs=None, + client_kwargs=None, + ) + + assert result == {"ok": True} + assert session.service_session_id == "agent-session-123" + mock_project.beta.agents.create_session.assert_awaited_once() + create_session_kwargs = mock_project.beta.agents.create_session.await_args.kwargs + assert create_session_kwargs["agent_name"] == "test-agent" + assert create_session_kwargs["isolation_key"] == "iso-key" + assert "version_indicator" in create_session_kwargs + mock_prepare_run_context.assert_awaited_once() + + +async def test_raw_foundry_agent_prepare_run_context_requires_preview_for_hosted_sessions() -> None: + """Test that hosted-agent sessions require allow_preview=True.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + ) + + with pytest.raises(RuntimeError, match="allow_preview=True"): + await agent._prepare_run_context( + messages="hi", + session=AgentSession(), + tools=None, + options={"isolation_key": "iso-key"}, + compaction_strategy=None, + tokenizer=None, + function_invocation_kwargs=None, + client_kwargs=None, + ) + + def test_foundry_agent_init() -> None: """Test construction of the full-middleware agent.""" @@ -483,9 +599,10 @@ async def test_foundry_agent_configure_azure_monitor_import_error() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_agent_integration_tests_disabled +@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.") async def test_foundry_agent_basic_run() -> None: """Smoke-test FoundryAgent against a real configured agent.""" - async with FoundryAgent(credential=AzureCliCredential()) as agent: + async with FoundryAgent(credential=AzureCliCredential(), allow_preview=True) as agent: response = await agent.run("Please respond with exactly: 'This is a response test.'") assert isinstance(response, AgentResponse) @@ -496,6 +613,7 @@ async def test_foundry_agent_basic_run() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_agent_integration_tests_disabled +@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.") async def test_foundry_agent_custom_client_run() -> None: """Smoke-test FoundryAgent against a real configured agent.""" async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent: diff --git a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py index e9e342d675..664123637d 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py @@ -198,6 +198,7 @@ class TestRawFoundryEmbeddingClient: "FOUNDRY_MODELS_API_KEY": "env-key", "FOUNDRY_EMBEDDING_MODEL": "env-model", }, + clear=True, ), patch("agent_framework_foundry._embedding_client.EmbeddingsClient"), patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"), diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index cac0ac3790..9078c59d22 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -172,12 +172,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): self._agent = agent self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType] - @staticmethod - def _is_streaming_request(request: CreateResponse) -> bool: - """Check if the request is a streaming request.""" - return request.stream is not None and request.stream is True - - def _handle_response( + async def _handle_response( self, request: CreateResponse, context: ResponseContext, @@ -186,11 +181,10 @@ class ResponsesHostServer(ResponsesAgentServerHost): """Handle the creation of a response.""" if self._is_workflow_agent: # Workflow agents are handled differently because they require checkpoint restoration - return self._handle_workflow_agent(request, context) + return self._handle_inner_workflow(request, context) + return self._handle_inner_agent(request, context) - return self._handle_regular_agent(request, context) - - async def _handle_regular_agent( + async def _handle_inner_agent( self, request: CreateResponse, context: ResponseContext, @@ -200,25 +194,24 @@ class ResponsesHostServer(ResponsesAgentServerHost): input_messages = _items_to_messages(input_items) history = await context.get_history() - messages: list[str | Content | Message] = [*_output_items_to_messages(history), *input_messages] + run_kwargs: dict[str, Any] = {"messages": [*_output_items_to_messages(history), *input_messages]} + is_streaming_request = request.stream is not None and request.stream is True chat_options, are_options_set = _to_chat_options(request) - is_streaming_request = self._is_streaming_request(request) response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model) yield response_event_stream.emit_created() yield response_event_stream.emit_in_progress() + if are_options_set and not isinstance(self._agent, RawAgent): + logger.warning("Agent doesn't support runtime options. They will be ignored.") + else: + run_kwargs["options"] = chat_options + if not is_streaming_request: # Run the agent in non-streaming mode - if isinstance(self._agent, RawAgent): - raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType] - response = await raw_agent.run(messages, stream=False, options=chat_options) - else: - if are_options_set: - logger.warning("Agent doesn't support runtime options. They will be ignored.") - response = await self._agent.run(messages, stream=False) + response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType] for message in response.messages: for content in message.contents: @@ -228,20 +221,12 @@ class ResponsesHostServer(ResponsesAgentServerHost): yield response_event_stream.emit_completed() return - # Run the agent in streaming mode - if isinstance(self._agent, RawAgent): - raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType] - response_stream = raw_agent.run(messages, stream=True, options=chat_options) - else: - if are_options_set: - logger.warning("Agent doesn't support runtime options. They will be ignored.") - response_stream = self._agent.run(messages, stream=True) - # Track the current active output item builder for streaming; # lazily created on matching content, closed when a different type arrives. tracker = _OutputItemTracker(response_event_stream) - async for update in response_stream: + # Run the agent in streaming mode + async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType] for content in update.contents: for event in tracker.handle(content): yield event @@ -256,7 +241,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): yield response_event_stream.emit_completed() - async def _handle_workflow_agent( + async def _handle_inner_workflow( self, request: CreateResponse, context: ResponseContext, @@ -269,8 +254,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): """ input_items = await context.get_input_items() input_messages = _items_to_messages(input_items) - - is_streaming_request = self._is_streaming_request(request) + is_streaming_request = request.stream is not None and request.stream is True _, are_options_set = _to_chat_options(request) if are_options_set: @@ -311,7 +295,8 @@ class ResponsesHostServer(ResponsesAgentServerHost): response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model) # Create a new checkpoint storage for this response based on the following rules: - # - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response + # - If no previous response ID or conversation ID is provided, + # create a new checkpoint storage for this response # - If a previous response ID is provided, create a new checkpoint storage for this response # - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation context_id = context.conversation_id or context.response_id @@ -333,14 +318,12 @@ class ResponsesHostServer(ResponsesAgentServerHost): yield response_event_stream.emit_completed() return - # Run the agent in streaming mode - response_stream = self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage) - # Track the current active output item builder for streaming; # lazily created on matching content, closed when a different type arrives. tracker = _OutputItemTracker(response_event_stream) - async for update in response_stream: + # Run the workflow agent in streaming mode + async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage): for content in update.contents: for event in tracker.handle(content): yield event @@ -355,7 +338,6 @@ class ResponsesHostServer(ResponsesAgentServerHost): await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name) yield response_event_stream.emit_completed() - return @staticmethod async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None: diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 13538b6c9a..237a3c7634 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -41,9 +41,10 @@ def _make_agent( *, response: AgentResponse | None = None, stream_updates: list[AgentResponseUpdate] | None = None, + raw_agent: bool = True, ) -> MagicMock: """Create a mock agent implementing SupportsAgentRun.""" - agent = MagicMock(spec=RawAgent) + agent = MagicMock(spec=RawAgent) if raw_agent else MagicMock() agent.id = "test-agent" agent.name = "Test Agent" agent.description = "A mock agent for testing" @@ -267,10 +268,18 @@ class TestNonStreaming: async def test_chat_options_forwarded(self) -> None: agent = _make_agent( - response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]) + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]), + raw_agent=True, ) server = _make_server(agent) - resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024) + resp = await _post( + server, + stream=False, + temperature=0.5, + top_p=0.9, + max_output_tokens=1024, + parallel_tool_calls=True, + ) assert resp.status_code == 200 agent.run.assert_awaited_once() @@ -280,6 +289,7 @@ class TestNonStreaming: assert options["temperature"] == 0.5 assert options["top_p"] == 0.9 assert options["max_tokens"] == 1024 + assert options["allow_multiple_tool_calls"] is True # endregion @@ -289,6 +299,31 @@ class TestNonStreaming: class TestStreaming: + async def test_chat_options_forwarded(self) -> None: + agent = _make_agent( + stream_updates=[AgentResponseUpdate(contents=[Content.from_text("ok")], role="assistant")], + raw_agent=True, + ) + server = _make_server(agent) + resp = await _post( + server, + stream=True, + temperature=0.5, + top_p=0.9, + max_output_tokens=1024, + parallel_tool_calls=True, + ) + + assert resp.status_code == 200 + agent.run.assert_called_once() + call_kwargs = agent.run.call_args.kwargs + assert call_kwargs["stream"] is True + options = call_kwargs["options"] + assert options["temperature"] == 0.5 + assert options["top_p"] == 0.9 + assert options["max_tokens"] == 1024 + assert options["allow_multiple_tool_calls"] is True + async def test_basic_text_streaming(self) -> None: agent = _make_agent( stream_updates=[ @@ -1426,7 +1461,7 @@ class TestMultiTurnMixedContent: assert body["status"] == "completed" # Verify agent received text + image - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert messages[0].role == "user" assert len(messages[0].contents) == 2 @@ -1464,7 +1499,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert len(messages[0].contents) == 2 assert messages[0].contents[0].type == "text" @@ -1501,7 +1536,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert len(messages[0].contents) == 2 assert messages[0].contents[0].type == "text" @@ -1542,7 +1577,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 3 assert messages[0].role == "user" assert messages[0].contents[0].type == "text" @@ -1591,7 +1626,7 @@ class TestMultiTurnMixedContent: assert body2["status"] == "completed" # Verify second call receives history from turn 1 + text+image input - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] # History: output message from turn 1 ("Send me an image") # Input: message with text + image assert len(second_call_messages) >= 2 @@ -1652,7 +1687,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify turn 2 received history including function call/result - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] roles = [m.role for m in second_call_messages] assert "assistant" in roles assert "tool" in roles @@ -1703,7 +1738,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify history includes the reasoning and text from turn 1 - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] assert len(second_call_messages) >= 2 # history + new input async def test_multi_turn_with_mixed_content_and_streaming(self) -> None: @@ -1795,7 +1830,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 2 assert messages[0].role == "user" assert messages[0].contents[0].type == "text" @@ -1867,7 +1902,7 @@ class TestMultiTurnMixedContent: assert resp3.json()["status"] == "completed" # Verify turn 3 received full history from turns 1+2 plus new image input - third_call_messages = agent.run.call_args_list[2].args[0] + third_call_messages = agent.run.call_args_list[2].kwargs["messages"] # Should have: history from turn 1 (assistant text) + history from turn 2 # (function_call, function_call_output, text) + new input (text + image) assert len(third_call_messages) >= 5 @@ -1918,7 +1953,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert len(messages[0].contents) == 2 assert messages[0].contents[0].type == "text" @@ -1982,7 +2017,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify turn 2 received history from turn 1 + new text+file input - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] assert len(second_call_messages) >= 2 # History should include the assistant response from turn 1 @@ -2050,7 +2085,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify turn 2 received history with function call + new text+image - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] # History should contain function_call and function_result from turn 1 fc_contents = [ c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call" diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index d5fcf5dbe0..480525ea1d 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -285,8 +285,10 @@ def test_vertex_ai_requires_project_and_location_together(monkeypatch: pytest.Mo GeminiChatClient(model="gemini-2.5-flash") -async def test_missing_model_raises_on_get_response() -> None: +async def test_missing_model_raises_on_get_response(monkeypatch: pytest.MonkeyPatch) -> None: """Raises ValueError at call time when no model is set on the client or in options.""" + monkeypatch.delenv("GEMINI_MODEL", raising=False) + monkeypatch.delenv("GOOGLE_MODEL", raising=False) client, mock = _make_gemini_client(model=None) # type: ignore[arg-type] mock.aio.models.generate_content = AsyncMock() diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index b16fbd0f7f..a5fdff72b5 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -355,6 +355,7 @@ async def test_integration_web_search() -> None: @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled @_with_azure_openai_debug() +@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.") async def test_integration_client_file_search() -> None: async with AzureCliCredential() as credential: client = OpenAIChatClient(credential=credential) @@ -380,6 +381,7 @@ async def test_integration_client_file_search() -> None: @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled @_with_azure_openai_debug() +@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.") async def test_integration_client_file_search_streaming() -> None: async with AzureCliCredential() as credential: client = OpenAIChatClient(credential=credential) diff --git a/python/samples/02-agents/chat_client/built_in_chat_clients.py b/python/samples/02-agents/chat_client/built_in_chat_clients.py index 4d79cc17b4..32f3efcf57 100644 --- a/python/samples/02-agents/chat_client/built_in_chat_clients.py +++ b/python/samples/02-agents/chat_client/built_in_chat_clients.py @@ -75,11 +75,7 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: if client_name == "azure_openai_chat_completion": return OpenAIChatCompletionClient(credential=AzureCliCredential()) if client_name == "foundry_chat": - return FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) + return FoundryChatClient(credential=AzureCliCredential()) raise ValueError(f"Unsupported client name: {client_name}") @@ -93,21 +89,6 @@ async def main(client_name: ClientName = "openai_chat") -> None: print(f"Client: {client_name}") print(f"User: {message.text}") - if isinstance(client, FoundryChatClient): - async with client: - if stream: - response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) - print("Assistant: ", end="") - async for chunk in response_stream: - if chunk.text: - print(chunk.text, end="") - print("") - else: - print( - f"Assistant: {await client.get_response([message], stream=False, options={'tools': get_weather})}" - ) - return - if stream: response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) print("Assistant: ", end="") diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/README.md index 072dbea36f..3181cb5ea4 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/README.md @@ -8,4 +8,4 @@ This folder contains a list of samples that show how to host agents using the `r | [02_local_tools](./02_local_tools) | An example of hosting an agent with the `responses` API and local tools including a function tool and a local shell tool. | | [03_remote_mcp](./03_remote_mcp) | An example of hosting an agent with the `responses` API and remote MCPs, including a GitHub MCP server and a Foundry Toolbox. | | [04_workflows](./04_workflows) | An example of hosting a workflow with the `responses` API. | -| [using_deployed_agent.py](./using_deployed_agent.py) | An example of how to use the deployed agent in Agent Framework. | +| [using_deployed_agent.py](./using_deployed_agent.py) | Connect to the deployed basic Foundry agent with `FoundryAgent`, `allow_preview=True`, and version `v2`. | diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py index 1f3525775a..9d1d50b959 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py @@ -1,50 +1,146 @@ # Copyright (c) Microsoft. All rights reserved. +from __future__ import annotations + import asyncio +import os +from collections.abc import Mapping +from typing import Any, cast -from agent_framework import Agent, AgentResponse, AgentResponseUpdate, ResponseStream -from agent_framework.openai import OpenAIChatClient -from typing_extensions import Any +from agent_framework import AgentSession +from agent_framework.foundry import FoundryAgent +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import VersionRefIndicator +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() """ -This script demonstrates how to talk to a deployed agent using the OpenAIChatClient. +This sample demonstrates how to connect to the deployed basic Foundry agent with +`FoundryAgent`. + +The sample uses environment variables for configuration, which can be set in a .env file or in the environment directly: +Environment variables: + FOUNDRY_PROJECT_ENDPOINT: Azure AI Foundry project endpoint. + FOUNDRY_AGENT_NAME: Hosted agent name. + FOUNDRY_AGENT_VERSION: Hosted agent version. Optional, defaults to latest if not specified. + +After you deploy one of the agents in this directory, you can run this sample +to connect to it and have a conversation. + +Note: The `allow_preview=True` flag is required to connect to the new hosted +agents, as this is a preview feature in Foundry. -Depending on where you have deployed your agent (local or Foundry Hosting), you may -need to change the base_url when initializing the OpenAIChatClient. """ -async def print_streaming_response(streaming_response: ResponseStream[AgentResponseUpdate, AgentResponse[Any]]) -> None: - async for chunk in streaming_response: - if chunk.text: - print(chunk.text, end="", flush=True) +async def create_hosted_agent_session( + *, + agent: FoundryAgent, + project_client: AIProjectClient, + agent_name: str, + agent_version: str | None, + isolation_key: str, +) -> AgentSession: + """Create a hosted-agent service session and wrap it in an AgentSession.""" + create_session_kwargs: dict[str, Any] = { + "agent_name": agent_name, + "isolation_key": isolation_key, + } + resolved_agent_version = agent_version + if resolved_agent_version is None: + agent_details = await cast(Any, project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] + agent_name=agent_name + ) + versions = getattr(agent_details, "versions", None) + if not isinstance(versions, Mapping): + raise ValueError("Hosted agent details did not include a versions mapping.") + latest_version = getattr(cast(Any, versions.get("latest")), "version", None) + if not isinstance(latest_version, str) or not latest_version: + raise ValueError("Hosted agent details did not include a latest version string.") + resolved_agent_version = latest_version + + create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=resolved_agent_version) + service_session = await project_client.beta.agents.create_session(**create_session_kwargs) + agent_session_id = getattr(service_session, "agent_session_id", None) + if not isinstance(agent_session_id, str) or not agent_session_id: + raise ValueError("Hosted agent session creation did not return a non-empty agent_session_id.") + + return agent.get_session(agent_session_id) async def main() -> None: - agent = Agent(client=OpenAIChatClient(base_url="http://localhost:8088")) - session = agent.create_session() + credential = AzureCliCredential() + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + agent_name = os.environ["FOUNDRY_AGENT_NAME"] + agent_version = os.getenv("FOUNDRY_AGENT_VERSION") + isolation_key = "my-isolation-key" - # First turn - query = "Hi!" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - streaming_response = agent.run(query, session=session, stream=True) - await print_streaming_response(streaming_response) + project_client = AIProjectClient( + endpoint=project_endpoint, + credential=credential, + allow_preview=True, + ) + async with ( + project_client, + FoundryAgent( + project_client=project_client, + agent_name=agent_name, + agent_version=agent_version, + allow_preview=True, + ) as agent, + ): + session = await create_hosted_agent_session( + agent=agent, + project_client=project_client, + agent_name=agent_name, + agent_version=agent_version, + isolation_key=isolation_key, + ) - # Second turn - query = "Your name is Javis. What can you do?" - print(f"\nUser: {query}") - print("Agent: ", end="", flush=True) - streaming_response = agent.run(query, session=session, stream=True) - await print_streaming_response(streaming_response) + try: + # 1. Send the first turn. + query = "Hi!" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, session=session, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) - # Third turn - query = "What is your name?" - print(f"\nUser: {query}") - print("Agent: ", end="", flush=True) - streaming_response = agent.run(query, session=session, stream=True) - await print_streaming_response(streaming_response) + # 2. Continue the conversation with the same deployed agent session. + query = "Your name is Javis. What can you do?" + print(f"\nUser: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, session=session, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + + # 3. Ask a follow-up question in the same session. + query = "What is your name?" + print(f"\nUser: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, session=session, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + finally: + if session.service_session_id is not None: + await project_client.beta.agents.delete_session( + agent_name=agent_name, + session_id=session.service_session_id, + isolation_key=isolation_key, + ) if __name__ == "__main__": asyncio.run(main()) + +""" +Sample output: +User: Hi! +Agent: Hello! How can I help you today? +User: Your name is Javis. What can you do? +Agent: I can answer questions and help with tasks using the instructions configured on the deployed agent. +User: What is your name? +Agent: My name is Javis. +""" From da32e8cf80a8e90232fcd7a519b5f66997a33275 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:41:20 +0900 Subject: [PATCH 08/40] Python: (core): Add functional workflow API (#4238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add functional workflow api * cleanup * More cleanup * address copilot feedback * Address PR feedbacK * updates * PR feedback * Address review comments on functional workflow samples - Swap 05/06 get-started samples: agent workflow first (motivates why workflows exist), simple text workflow second - Rename text_pipeline → text_workflow, poem_pipeline → poem_workflow - Add @step to agent workflow sample (05) to demonstrate caching - Switch agent samples to AzureOpenAIResponsesClient with Foundry - Remove .as_agent() from agent_integration.py to focus on the key difference between inline agent calls vs @step-cached calls - Add commented-out Agent.run example in hitl_review.py - Add clarifying comment in _functional.py that event streaming is buffered (not true per-token streaming) - Add naive_group_chat.py functional sample: round-robin group chat as a plain Python loop - Update READMEs to reflect new file names and group chat sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix pyright type errors * Address PR review comments on functional workflow API 1. Allow request_info inside @step: Auto-inject RunContext into step functions that declare a RunContext parameter (by type or name 'ctx'), and expose get_run_context() for programmatic access. 2. Handle None responses: Log a warning when a response value is None, and document the behavior in request_info docstring. 3. Add executor_bypassed event type: Replace executor_invoked + executor_completed with a single executor_bypassed event when a step replays from cache, making cached vs live execution explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add regression tests for PR review comments on functional workflow API The three review comments (request_info in @step, None response handling, executor_bypassed event type) were already addressed in 7da7db4e. This commit adds cross-cutting regression tests that exercise the interactions between these features: - HITL in step with caching: preceding step bypassed on resume - Full checkpoint lifecycle with HITL step (interrupt -> resume -> restore) - None response inside step-level request_info logs warning - WorkflowInterrupted from step does not emit executor_failed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #4238 review comments on functional workflow API Comment 1 (request_info in @step): Already supported. Added comment in StepWrapper.__call__ explaining why WorkflowInterrupted (BaseException) safely bypasses the except Exception handler. Comment 2 (None response): Added docstring to _get_response clarifying the (found, value) return tuple semantics and None handling. Comment 3 (bypass event type): executor_bypassed is already a dedicated event type in WorkflowEventType. Updated comment at the bypass site to make the deliberate event type choice explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add experimental API warnings to functional workflow module Mark all public classes and decorators (workflow, step, RunContext, FunctionalWorkflow, StepWrapper, FunctionalWorkflowAgent) as experimental and subject to change or removal. * Address PR #4238 review comments from @eavanvalkenburg - RunContext docstring leads with purpose (opt-in handle for HITL, custom events, state) so readers importing it from the public surface understand its role before the mechanics (#2993513452). - Rename `06_first_functional_workflow.py` to `06_functional_workflow_basics.py`; the previous filename was confusing since it followed `05_functional_workflow_with_agents.py` (#2993531979). - Simplify `05_functional_workflow_with_agents.py` to call agents directly without a @step wrapper; the step-vs-no-step contrast lives in `03-workflows/functional/agent_integration.py`, keeping the get-started sample minimal (#2993525532). - Switch functional samples to `FoundryChatClient` for consistency with the rest of 01-get-started and 03-workflows (follow-up on #2876988570). - Use walrus in `hitl_review.py` final-state assertion (#2993572182). - Add expected-output block to `basic_streaming_pipeline.py` (#2993557609). - Clarify in `parallel_pipeline.py` that `@step` composes with `asyncio.gather` (#2993597282). - `naive_group_chat.py` threads `list[Message]` between turns instead of stringifying the transcript, preserving role/authorship (#2993583231). Drive-by: pre-commit hook sorts an unrelated import block in `samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py`. * Fix 10 functional-workflow API bugs from /ultrareview pass - bug_001: `ctx.request_info()` without an explicit `request_id` now derives a deterministic `auto::` id from the call-counter, so HITL resume works correctly on the documented default path. A uuid was regenerated on every replay, making resume impossible. - bug_002: `StepWrapper.__call__` no longer deepcopies arguments on the cache-hit replay branch. The copy is only performed on the live-execution path (for the event log) and falls back to the original mapping if deepcopy fails, so steps whose args aren't deepcopyable (locks, sockets, sessions) can still resume from checkpoint. - bug_007: `_set_responses` now prunes each resolved `request_id` from `_pending_requests`, and the cache-hit branch in `request_info` does the same. Previously, answered requests were re-serialized into every subsequent checkpoint and the final checkpoint falsely claimed pending requests even after the workflow completed. - bug_008: `_compute_signature_hash` now mixes the function's `co_code` and `co_names` into the checkpoint signature, so changes to the workflow body invalidate older checkpoints even when steps are accessed via module / class attributes (which `_discover_step_names` can't see statically). `RunContext._record_observed_step` records observed step names for diagnostics. - bug_010: `FunctionalWorkflow.run()` docstring corrected — says "at least one of message/responses/checkpoint_id" and explicitly notes `responses` may be combined with `checkpoint_id` (the validator already allowed this). - bug_013: `FunctionalWorkflowAgent` now surfaces `request_info` events as `FunctionApprovalRequestContent` items (mirroring graph `WorkflowAgent`), threads `responses=` and `checkpoint_id=` through to the underlying workflow, and exposes `pending_requests`. Previously `.as_agent()` returned empty `AgentResponse` for HITL workflows — effectively unusable. - bug_014: `FunctionalWorkflow` now clears `_last_message`, `_last_step_cache`, and `_last_pending_request_ids` on clean completion. `run()` validates that `responses=` keys intersect the currently-pending request set (or raises with a clear error) instead of silently replaying against stale singleton state from a prior run. - bug_015: `FunctionalWorkflow.as_agent` signature now matches graph `Workflow.as_agent`: accepts `name`, `description`, `context_providers`, and `**kwargs`. `FunctionalWorkflowAgent` stores the overrides. - bug_017: `RunContext.set_state` raises `ValueError` for underscore- prefixed keys (the framework's `_step_cache` / `_original_message` keys would silently clobber user state on checkpoint save and user underscore-prefixed state was dropped on restore). Docstring documents the reserved prefix. - merged_bug_003: Workflow function arity is validated at decoration time. Multiple non-ctx parameters raise `ValueError` immediately (previously every arg past the first was silently dropped at call time). Passing a non-None `message` to a ctx-only workflow raises `ValueError` instead of silently discarding the message. Test coverage: +18 regression tests covering every fix. Full workflow suite now 766 passed, 1 skipped, 2 xfailed; full core suite 2338 passed. * Deslop functional.py fix commit - Remove dead instrumentation added in the prior commit that was never consumed: `RunContext._observed_step_names`, `RunContext._record_observed_step`, `FunctionalWorkflow._runtime_step_names`, and `FunctionalWorkflowAgent._extra_kwargs`. The signature hash relies on `co_code` alone, which covers the attribute-access case without the collection-scaffolding. - Trim over-explanatory comments that restated what the code does or what it no longer does. Keep only the comments that answer "why" for the non-obvious bits (deterministic id contract, defensive deepcopy, stale replay guard). - Compress the `_compute_signature_hash` and FunctionalWorkflow `__init__` block docstrings without losing the user-facing reasoning. Net -49 lines. Regression lock preserved (766 passed, 1 skipped, 2 xfailed). * Fix functional workflow review feedback --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot --- .../packages/core/agent_framework/__init__.py | 16 + .../core/agent_framework/_feature_stage.py | 1 + .../agent_framework/_workflows/_events.py | 7 + .../agent_framework/_workflows/_functional.py | 1551 +++++++++++++++ .../agent_framework/_workflows/_workflow.py | 10 +- .../tests/workflow/test_function_executor.py | 9 +- .../workflow/test_functional_workflow.py | 1693 +++++++++++++++++ .../05_functional_workflow_with_agents.py | 49 + .../06_functional_workflow_basics.py | 57 + ...workflow.py => 07_first_graph_workflow.py} | 7 +- ...st_your_agent.py => 08_host_your_agent.py} | 0 python/samples/01-get-started/README.md | 6 +- python/samples/03-workflows/README.md | 14 + .../functional/agent_integration.py | 107 ++ .../03-workflows/functional/basic_pipeline.py | 58 + .../functional/basic_streaming_pipeline.py | 63 + .../03-workflows/functional/hitl_review.py | 84 + .../functional/naive_group_chat.py | 82 + .../functional/parallel_pipeline.py | 66 + .../functional/steps_and_checkpointing.py | 97 + python/samples/README.md | 6 +- 21 files changed, 3968 insertions(+), 15 deletions(-) create mode 100644 python/packages/core/agent_framework/_workflows/_functional.py create mode 100644 python/packages/core/tests/workflow/test_functional_workflow.py create mode 100644 python/samples/01-get-started/05_functional_workflow_with_agents.py create mode 100644 python/samples/01-get-started/06_functional_workflow_basics.py rename python/samples/01-get-started/{05_first_workflow.py => 07_first_graph_workflow.py} (87%) rename python/samples/01-get-started/{06_host_your_agent.py => 08_host_your_agent.py} (100%) create mode 100644 python/samples/03-workflows/functional/agent_integration.py create mode 100644 python/samples/03-workflows/functional/basic_pipeline.py create mode 100644 python/samples/03-workflows/functional/basic_streaming_pipeline.py create mode 100644 python/samples/03-workflows/functional/hitl_review.py create mode 100644 python/samples/03-workflows/functional/naive_group_chat.py create mode 100644 python/samples/03-workflows/functional/parallel_pipeline.py create mode 100644 python/samples/03-workflows/functional/steps_and_checkpointing.py diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 05f65873bc..364f62eae1 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -213,6 +213,15 @@ from ._workflows._executor import ( handler, ) from ._workflows._function_executor import FunctionExecutor, executor +from ._workflows._functional import ( + FunctionalWorkflow, + FunctionalWorkflowAgent, + RunContext, + StepWrapper, + get_run_context, + step, + workflow, +) from ._workflows._request_info_mixin import response_handler from ._workflows._runner import Runner from ._workflows._runner_context import ( @@ -332,6 +341,8 @@ __all__ = [ "FunctionMiddleware", "FunctionMiddlewareTypes", "FunctionTool", + "FunctionalWorkflow", + "FunctionalWorkflowAgent", "GeneratedEmbeddings", "GraphConnectivityError", "HistoryProvider", @@ -354,6 +365,7 @@ __all__ = [ "ResponseStream", "Role", "RoleLiteral", + "RunContext", "Runner", "RunnerContext", "SecretString", @@ -366,6 +378,7 @@ __all__ = [ "SkillScriptRunner", "SkillsProvider", "SlidingWindowStrategy", + "StepWrapper", "SubWorkflowRequestMessage", "SubWorkflowResponseMessage", "SummarizationStrategy", @@ -424,6 +437,7 @@ __all__ = [ "evaluator", "executor", "function_middleware", + "get_run_context", "handler", "included_messages", "included_token_count", @@ -439,6 +453,7 @@ __all__ = [ "register_state_type", "resolve_agent_id", "response_handler", + "step", "tool", "tool_call_args_match", "tool_called_check", @@ -447,4 +462,5 @@ __all__ = [ "validate_tool_mode", "validate_tools", "validate_workflow_graph", + "workflow", ] diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 761b7860a4..ef7dfd3687 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -48,6 +48,7 @@ class ExperimentalFeature(str, Enum): EVALS = "EVALS" FILE_HISTORY = "FILE_HISTORY" + FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS" SKILLS = "SKILLS" TOOLBOXES = "TOOLBOXES" diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index d26952d8e5..4b8238268c 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -120,6 +120,7 @@ WorkflowEventType = Literal[ "executor_invoked", # Executor handler was called (use .executor_id, .data) "executor_completed", # Executor handler completed (use .executor_id, .data) "executor_failed", # Executor handler raised error (use .executor_id, .details) + "executor_bypassed", # Executor skipped via cache hit during replay (use .executor_id, .data) # Orchestration event types (use .data for typed payload) "group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501 "handoff_sent", # Handoff routing events (use .data as HandoffSentEvent) @@ -148,6 +149,7 @@ class WorkflowEvent(Generic[DataT]): - `WorkflowEvent.executor_invoked(executor_id)` - executor handler called - `WorkflowEvent.executor_completed(executor_id)` - executor handler completed - `WorkflowEvent.executor_failed(executor_id, details)` - executor handler failed + - `WorkflowEvent.executor_bypassed(executor_id)` - executor skipped via cache hit The generic parameter DataT represents the type of the event's data payload: - Lifecycle events: `WorkflowEvent[None]` (data is None) @@ -318,6 +320,11 @@ class WorkflowEvent(Generic[DataT]): """Create an 'executor_failed' event when an executor handler raises an error.""" return WorkflowEvent("executor_failed", executor_id=executor_id, data=details, details=details) + @classmethod + def executor_bypassed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]: + """Create an 'executor_bypassed' event when a step is skipped via cache hit during replay.""" + return cls("executor_bypassed", executor_id=executor_id, data=data) + # ========================================================================== # Property for type-safe access # ========================================================================== diff --git a/python/packages/core/agent_framework/_workflows/_functional.py b/python/packages/core/agent_framework/_workflows/_functional.py new file mode 100644 index 0000000000..159d75e137 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_functional.py @@ -0,0 +1,1551 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Functional workflow API for writing workflows as plain async functions. + +.. warning:: Experimental + + This API is experimental and subject to change or removal + in future versions without notice. + +This module provides the ``@workflow`` and ``@step`` decorators that let users +define workflows using native Python control flow (if/else, loops, +``asyncio.gather``) instead of a graph-based topology. + +A ``@workflow``-decorated async function receives its input as the first +positional argument. If the function needs HITL (``request_info``), custom +events, or key/value state, add a :class:`RunContext` parameter — otherwise it +can be omitted. Inside the workflow, plain ``async`` calls run normally. +Optionally, ``@step``-decorated functions gain caching, per-step checkpointing, +and event emission. ``@step`` functions may also declare a ``RunContext`` +parameter to access HITL and state APIs directly. + +Key public symbols: + +* :func:`workflow` / :class:`FunctionalWorkflow` — decorator and runtime. +* :func:`step` / :class:`StepWrapper` — optional step decorator. +* :class:`RunContext` — execution context injected into workflow and step + functions. +* :func:`get_run_context` — retrieve the active ``RunContext`` from anywhere + inside a running workflow. +* :class:`FunctionalWorkflowAgent` — agent adapter returned by + :meth:`FunctionalWorkflow.as_agent`. +""" + +from __future__ import annotations + +# pyright: reportPrivateUsage=false +# Classes in this module (RunContext, StepWrapper, FunctionalWorkflow) form a +# cohesive unit and intentionally access each other's underscore-prefixed members. +import functools +import hashlib +import inspect +import logging +import typing +from collections.abc import AsyncIterable, Awaitable, Callable, Sequence +from contextvars import ContextVar +from copy import deepcopy +from typing import Any, Generic, Literal, TypeVar, overload + +from .._feature_stage import ExperimentalFeature, experimental +from .._types import AgentResponse, AgentResponseUpdate, ResponseStream +from ..observability import OtelAttr, capture_exception, create_workflow_span +from ._checkpoint import CheckpointStorage, WorkflowCheckpoint +from ._events import ( + WorkflowErrorDetails, + WorkflowEvent, + WorkflowRunState, + _framework_event_origin, # type: ignore[reportPrivateUsage] +) +from ._workflow import WorkflowRunResult + +logger = logging.getLogger(__name__) + +R = TypeVar("R") + +# ContextVar holding the active RunContext during workflow execution. +# ContextVar is per-asyncio-Task, so concurrent workflows each get their own context. +_active_run_ctx: ContextVar[RunContext | None] = ContextVar("_active_run_ctx", default=None) + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +def get_run_context() -> RunContext | None: + """Return the active :class:`RunContext`, or ``None`` if not inside a ``@workflow``. + + This is useful inside ``@step`` functions (or any code called from a + workflow) that need access to HITL, state, or event APIs without + requiring a ``RunContext`` parameter. + """ + return _active_run_ctx.get() + + +# --------------------------------------------------------------------------- +# Internal exception for HITL interruption +# --------------------------------------------------------------------------- + + +class WorkflowInterrupted(BaseException): + """Internal: raised when request_info() is called during initial execution. + + Inherits from ``BaseException`` (not ``Exception``) so that user code + with ``except Exception:`` handlers inside a ``@workflow`` function does + not accidentally intercept the HITL interruption signal. + """ + + def __init__(self, request_id: str, request_data: Any, response_type: type) -> None: + self.request_id = request_id + self.request_data = request_data + self.response_type = response_type + super().__init__(f"Workflow interrupted by request_info (request_id={request_id})") + + +# --------------------------------------------------------------------------- +# RunContext +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class RunContext: + """Opt-in handle for workflow-only features inside a ``@workflow`` function. + + Use ``RunContext`` when a workflow function needs one of the following, + otherwise omit it entirely for a cleaner signature: + + * Human-in-the-loop: :meth:`request_info` pauses the workflow until a + response is supplied, then resumes with that value. + * Custom events: :meth:`add_event` emits events into the run stream + (useful for progress reporting or tracing). + * Workflow-scoped key/value state: :meth:`get_state` / :meth:`set_state` + persist values across a run and survive checkpoints. + + The context is injected automatically. Declare it either by parameter + name (``ctx``) or by type annotation (``: RunContext``); both work. + + Args: + workflow_name: Identifier for the enclosing workflow, used when + generating events and checkpoint metadata. + streaming: Whether the current run was started with ``stream=True``. + run_kwargs: Extra keyword arguments forwarded from + :meth:`FunctionalWorkflow.run`. + + Examples: + + .. code-block:: python + + # Simple workflow: no context parameter needed. + @workflow + async def my_pipeline(data: str) -> str: + return await some_step(data) + + + # HITL workflow: request a response from a human reviewer. + @workflow + async def hitl_pipeline(data: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": data}, response_type=str) + return feedback + + + # RunContext also works inside @step functions. + @step + async def review_step(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str) + return feedback + """ + + def __init__( + self, + workflow_name: str, + *, + streaming: bool = False, + run_kwargs: dict[str, Any] | None = None, + ) -> None: + self._workflow_name = workflow_name + self._streaming = streaming + self._run_kwargs = run_kwargs or {} + + # Event accumulator + self._events: list[WorkflowEvent[Any]] = [] + + # Step result cache: (step_name, call_index) -> result + self._step_cache: dict[tuple[str, int], Any] = {} + # Cached step metadata used to keep auto-generated request_info IDs in sync on bypass. + self._step_cache_auto_request_info_counts: dict[tuple[str, int], int] = {} + # Per-step call counters for deterministic cache keys + self._step_call_counters: dict[str, int] = {} + # Deterministic call counter for auto-generated request_info IDs + self._auto_request_info_index: int = 0 + + # HITL responses (set via _set_responses before replay) + self._responses: dict[str, Any] = {} + # Pending request_info events (for checkpointing) + self._pending_requests: dict[str, WorkflowEvent[Any]] = {} + + # User state (simple dict) + self._state: dict[str, Any] = {} + + # Callback invoked after each step completes (set by FunctionalWorkflow) + self._on_step_completed: Callable[[], Awaitable[None]] | None = None + + # ------------------------------------------------------------------ + # Public API (for @workflow functions) + # ------------------------------------------------------------------ + + async def request_info( + self, + request_data: Any, + response_type: type, + *, + request_id: str | None = None, + ) -> Any: + """Request external information (human-in-the-loop). + + On first execution this suspends the workflow by raising an internal + ``WorkflowInterrupted`` signal (caught by the framework, never exposed + to user code). The caller receives a ``WorkflowRunResult`` (or a + ``ResponseStream`` when ``stream=True``) whose + :meth:`~WorkflowRunResult.get_request_info_events` contains the pending + request. When the workflow is resumed with + ``run(responses={request_id: value})``, the same function re-executes + and ``request_info`` returns the provided *value* directly. + + Args: + request_data: Arbitrary payload describing what information is + needed (e.g. a Pydantic model, dict, or string prompt). + response_type: The expected Python type of the response value. + request_id: Optional stable identifier for this request. If + omitted, a deterministic identifier is derived from the call + order (``auto::``) so that resume works without the + caller needing to echo back an explicit ID. + + Returns: + The response value supplied during replay. ``None`` is allowed + but triggers a warning — prefer a sentinel value when the + absence of data is meaningful. + + Raises: + WorkflowInterrupted: Raised internally on initial execution + (not visible to workflow authors). + """ + if request_id is None: + # Deterministic id; same determinism contract as @step caching. + rid = f"auto::{self._auto_request_info_index}" + self._auto_request_info_index += 1 + else: + rid = request_id + + found, value = self._get_response(rid) + if found: + self._pending_requests.pop(rid, None) + return value + + # No response — emit event and interrupt + event = WorkflowEvent.request_info( + request_id=rid, + source_executor_id=self._workflow_name, + request_data=request_data, + response_type=response_type, + ) + await self.add_event(event) + self._pending_requests[rid] = event + raise WorkflowInterrupted(rid, request_data, response_type) + + async def add_event(self, event: WorkflowEvent[Any]) -> None: + """Add a custom event to the workflow event stream. + + Use this to inject application-specific events alongside the + framework-generated lifecycle events. + + Args: + event: The workflow event to append. + """ + self._events.append(event) + + def get_state(self, key: str, default: Any = None) -> Any: + """Retrieve a value from the workflow's key/value state. + + State values are persisted across HITL interruptions and are included + in checkpoints when checkpoint storage is configured. + + Args: + key: The state key to look up. + default: Value returned when *key* is absent. + + Returns: + The stored value, or *default* if the key does not exist. + """ + return self._state.get(key, default) + + def set_state(self, key: str, value: Any) -> None: + """Store a value in the workflow's key/value state. + + Args: + key: The state key. Must not start with ``_`` — framework + bookkeeping (e.g. ``_step_cache``, ``_original_message``) uses + the underscore prefix and user keys in that namespace are + silently clobbered by checkpoint save and dropped on + checkpoint restore. Use names without a leading underscore + for user state. + value: The value to store. Must be JSON-serializable if + checkpoint storage is used. + + Raises: + ValueError: If *key* begins with ``_`` (reserved for framework + bookkeeping). + """ + if key.startswith("_"): + raise ValueError( + f"State key {key!r} starts with '_', which is reserved for " + f"framework bookkeeping (e.g. '_step_cache', '_original_message') " + f"and would be silently dropped on checkpoint restore. Use a " + f"non-underscore-prefixed key for user state." + ) + self._state[key] = value + + def is_streaming(self) -> bool: + """Return whether the current run was started with ``stream=True``. + + Returns: + ``True`` if the workflow is running in streaming mode. + """ + return self._streaming + + # ------------------------------------------------------------------ + # Internal API (for StepWrapper and FunctionalWorkflow) + # ------------------------------------------------------------------ + + def _get_events(self) -> list[WorkflowEvent[Any]]: + return list(self._events) + + def _get_step_cache_key(self, step_name: str) -> tuple[str, int]: + idx = self._step_call_counters.get(step_name, 0) + self._step_call_counters[step_name] = idx + 1 + return (step_name, idx) + + def _get_cached_result(self, key: tuple[str, int]) -> tuple[bool, Any]: + if key in self._step_cache: + return True, self._step_cache[key] + return False, None + + def _set_cached_result(self, key: tuple[str, int], value: Any) -> None: + self._step_cache[key] = value + + def _set_cached_step_auto_request_info_count(self, key: tuple[str, int], count: int) -> None: + self._step_cache_auto_request_info_counts[key] = count + + def _advance_auto_request_info_index_for_cached_step(self, key: tuple[str, int]) -> None: + self._auto_request_info_index += self._step_cache_auto_request_info_counts.get(key, 0) + + def _set_responses(self, responses: dict[str, Any]) -> None: + for rid, value in responses.items(): + if value is None: + logger.warning( + "Response for request_id=%r is None. If this is intentional, " + "consider using a sentinel value instead.", + rid, + ) + self._responses = dict(responses) + # Remove resolved requests from the pending set so downstream + # checkpoints don't re-serialize them as still-pending. + for rid in responses: + self._pending_requests.pop(rid, None) + + def _get_response(self, request_id: str) -> tuple[bool, Any]: + """Look up a HITL response by *request_id*. + + Returns: + A ``(found, value)`` tuple. When *found* is ``True``, *value* is + the caller-supplied response (which **may be** ``None`` — a warning + is logged by :meth:`_set_responses` in that case). When *found* is + ``False``, *value* is always ``None`` and simply means no response + has been provided yet. + """ + if request_id in self._responses: + return True, self._responses[request_id] + return False, None + + def _export_step_cache(self) -> dict[str, Any]: + """Serialize the step cache for checkpointing. + + Converts tuple keys to strings for JSON compatibility. + """ + return {f"{name}::{idx}": val for (name, idx), val in self._step_cache.items()} + + def _export_step_cache_auto_request_info_counts(self) -> dict[str, int]: + """Serialize per-step auto request_info counts for checkpointing.""" + return {f"{name}::{idx}": count for (name, idx), count in self._step_cache_auto_request_info_counts.items()} + + def _import_step_cache(self, data: dict[str, Any]) -> None: + """Restore step cache from checkpoint data.""" + self._step_cache = {} + for k, v in data.items(): + try: + name, idx_str = k.rsplit("::", 1) + self._step_cache[name, int(idx_str)] = v + except (ValueError, TypeError) as exc: + raise ValueError( + f"Corrupted step cache entry in checkpoint: key={k!r}. " + f"The checkpoint may be from an incompatible version or corrupted. " + f"Original error: {exc}" + ) from exc + + def _import_step_cache_auto_request_info_counts(self, data: dict[str, Any]) -> None: + """Restore per-step auto request_info counts from checkpoint data.""" + self._step_cache_auto_request_info_counts = {} + for k, v in data.items(): + try: + name, idx_str = k.rsplit("::", 1) + self._step_cache_auto_request_info_counts[name, int(idx_str)] = int(v) + except (ValueError, TypeError) as exc: + raise ValueError( + f"Corrupted step cache request_info metadata in checkpoint: key={k!r}, value={v!r}. " + f"The checkpoint may be from an incompatible version or corrupted. " + f"Original error: {exc}" + ) from exc + + +# --------------------------------------------------------------------------- +# StepWrapper +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class StepWrapper(Generic[R]): + """Wrapper returned by the ``@step`` decorator. + + When called inside a running ``@workflow`` function, the wrapper + intercepts execution to provide: + + * **Caching** — results are cached by ``(step_name, call_index)`` so + that HITL replay and checkpoint restore skip already-completed work. + On cache hit a single ``executor_bypassed`` event is emitted instead + of the normal ``executor_invoked`` / ``executor_completed`` pair. + * **Event emission** — ``executor_invoked`` / ``executor_completed`` / + ``executor_failed`` events are emitted for observability. + * **RunContext injection** — if the step function declares a parameter + annotated as :class:`RunContext` (or named ``ctx``), the active + context is automatically injected, giving step functions access to + HITL, state, and event APIs. + * **Per-step checkpointing** — a checkpoint is saved after each live + execution when checkpoint storage is configured. + + Outside a workflow the wrapper is transparent: it delegates directly to + the original function, making decorated functions fully testable in + isolation. + + Args: + func: The async function to wrap. + name: Optional display name. Defaults to ``func.__name__``. + + Raises: + TypeError: If *func* is not an async (coroutine) function. + """ + + def __init__(self, func: Callable[..., Awaitable[R]], *, name: str | None = None) -> None: + if not inspect.iscoroutinefunction(func): + raise TypeError( + f"@step can only decorate async functions, but '{func.__name__}' is not a coroutine function." + ) + self._func = func + self.name: str = name or func.__name__ + self._signature = inspect.signature(func) + functools.update_wrapper(self, func) + + # Detect RunContext parameter for auto-injection inside workflows + self._ctx_param_name: str | None = None + try: + hints = typing.get_type_hints(func) + except Exception: + hints = {} + for param_name, param in self._signature.parameters.items(): + if param.kind not in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + continue + resolved = hints.get(param_name, param.annotation) + if resolved is RunContext or param_name == "ctx": + self._ctx_param_name = param_name + break + + def _build_call_args_with_ctx( + self, + ctx: RunContext, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Inject RunContext without consuming a user positional argument.""" + if self._ctx_param_name is None or self._ctx_param_name in kwargs: + return args, dict(kwargs) + + call_args: list[Any] = [] + call_kwargs = dict(kwargs) + arg_index = 0 + + for param in self._signature.parameters.values(): + if param.name == self._ctx_param_name: + if param.kind == inspect.Parameter.KEYWORD_ONLY: + call_kwargs[param.name] = ctx + else: + call_args.append(ctx) + continue + + if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD): + if arg_index < len(args): + call_args.append(args[arg_index]) + arg_index += 1 + elif param.kind == inspect.Parameter.VAR_POSITIONAL: + call_args.extend(args[arg_index:]) + arg_index = len(args) + + if arg_index < len(args): + call_args.extend(args[arg_index:]) + + return tuple(call_args), call_kwargs + + async def __call__(self, *args: Any, **kwargs: Any) -> R: + ctx = _active_run_ctx.get() + if ctx is None: + # Outside a workflow — pass through directly + return await self._func(*args, **kwargs) + + cache_key = ctx._get_step_cache_key(self.name) + found, cached = ctx._get_cached_result(cache_key) + if found: + ctx._advance_auto_request_info_index_for_cached_step(cache_key) + # Dedicated bypass event so consumers can tell cache-hit replays + # apart from fresh executions. + await ctx.add_event(WorkflowEvent.executor_bypassed(self.name, cached)) + return cached # type: ignore[return-value, no-any-return] + + # Inject RunContext if the step function declares it + call_args, call_kwargs = self._build_call_args_with_ctx(ctx, args, kwargs) + + # Defensive deepcopy for the event log only; fall back to the live + # reference so non-deepcopyable args (locks, sockets) don't fail. + if args or kwargs: + try: + invocation_data: Any = deepcopy({"args": args, "kwargs": kwargs}) + except Exception: + invocation_data = {"args": args, "kwargs": kwargs} + else: + invocation_data = None + await ctx.add_event(WorkflowEvent.executor_invoked(self.name, invocation_data)) + auto_request_info_index_before = ctx._auto_request_info_index + try: + result = await self._func(*call_args, **call_kwargs) + except Exception as exc: + # NOTE: WorkflowInterrupted (from request_info inside a step) inherits + # from BaseException, NOT Exception, so it propagates past this handler + # without emitting a spurious executor_failed event. This is intentional + # — request_info is fully supported inside @step functions. + await ctx.add_event(WorkflowEvent.executor_failed(self.name, WorkflowErrorDetails.from_exception(exc))) + raise + ctx._set_cached_step_auto_request_info_count( + cache_key, + ctx._auto_request_info_index - auto_request_info_index_before, + ) + ctx._set_cached_result(cache_key, result) + await ctx.add_event(WorkflowEvent.executor_completed(self.name, result)) + if ctx._on_step_completed is not None: + await ctx._on_step_completed() + return result + + +# --------------------------------------------------------------------------- +# @step decorator +# --------------------------------------------------------------------------- + + +@overload +def step(func: Callable[..., Awaitable[R]]) -> StepWrapper[R]: ... + + +@overload +def step(*, name: str | None = None) -> Callable[[Callable[..., Awaitable[R]]], StepWrapper[R]]: ... + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +def step( + func: Callable[..., Awaitable[Any]] | None = None, + *, + name: str | None = None, +) -> StepWrapper[Any] | Callable[[Callable[..., Awaitable[Any]]], StepWrapper[Any]]: + """Decorator that marks an async function as a tracked workflow step. + + Supports both bare ``@step`` and parameterized ``@step(name="custom")`` + forms. Inside a running ``@workflow`` function, calls to a step are + intercepted for result caching, event emission, and per-step + checkpointing. If the step function declares a :class:`RunContext` + parameter (by type annotation or the name ``ctx``), the active context + is automatically injected, giving the step access to + :meth:`~RunContext.request_info`, state, and event APIs. Outside a + workflow the decorated function behaves identically to the original, + making it fully testable in isolation. + + The ``@step`` decorator is **optional**. Plain async functions work + inside ``@workflow`` without it; use ``@step`` only when you need + caching, checkpointing, or observability for a particular call. + + Args: + func: The async function to decorate (when using the bare + ``@step`` form). + name: Optional display name for the step. Defaults to the + function's ``__name__``. + + Returns: + A :class:`StepWrapper` (bare form) or a decorator that produces + one (parameterized form). + + Raises: + TypeError: If the decorated function is not async. + + Examples: + + .. code-block:: python + + @step + async def fetch_data(url: str) -> dict: + return await http_get(url) + + + @step(name="transform") + async def transform_data(raw: dict) -> str: + return json.dumps(raw) + + + # Step with HITL — RunContext is auto-injected inside a workflow: + @step + async def review(doc: str, ctx: RunContext) -> str: + return await ctx.request_info({"draft": doc}, response_type=str) + """ + if func is not None: + return StepWrapper(func, name=name) + + def _decorator(fn: Callable[..., Awaitable[Any]]) -> StepWrapper[Any]: + return StepWrapper(fn, name=name) + + return _decorator + + +# --------------------------------------------------------------------------- +# FunctionalWorkflow +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class FunctionalWorkflow: + """A workflow backed by a user-defined async function. + + Created by the :func:`workflow` decorator. Exposes the same ``run()`` + interface as graph-based :class:`Workflow` objects, returning a + :class:`WorkflowRunResult` (or a :class:`ResponseStream` in streaming + mode). + + The underlying function is executed directly — no graph compilation or + edge wiring is involved. Native Python control flow (``if``/``else``, + ``for``, ``asyncio.gather``) is used for branching and parallelism. + + Args: + func: The async function that implements the workflow logic. + name: Display name for the workflow. Defaults to ``func.__name__``. + description: Optional human-readable description. + checkpoint_storage: Default :class:`CheckpointStorage` used for + persisting step results and state between runs. Can be + overridden per-run via the *checkpoint_storage* parameter of + :meth:`run`. + + Examples: + + .. code-block:: python + + @workflow + async def my_pipeline(data: str) -> str: + return await to_upper(data) + + + result = await my_pipeline.run("hello") + print(result.get_outputs()) # ['HELLO'] + """ + + def __init__( + self, + func: Callable[..., Awaitable[Any]], + *, + name: str | None = None, + description: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + ) -> None: + self._func = func + self.name = name or func.__name__ + self.description = description + self._checkpoint_storage = checkpoint_storage + self._is_running = False + # Replay state: cleared on clean completion so later responses-only + # calls can't silently replay with stale data from a prior run. + self._last_message: Any = None + self._last_step_cache: dict[tuple[str, int], Any] = {} + self._last_step_cache_auto_request_info_counts: dict[tuple[str, int], int] = {} + self._last_pending_request_ids: set[str] = set() + + # Signature arity is validated once at decoration time. + self._non_ctx_param_names = self._classify_signature(func) + + # Discover step names referenced in the function for signature hash + self._step_names = self._discover_step_names(func) + + # Compute a stable signature hash + self.graph_signature_hash = self._compute_signature_hash() + + functools.update_wrapper(self, func) # type: ignore[arg-type] + + @staticmethod + def _classify_signature(func: Callable[..., Any]) -> list[str]: + """Return the names of non-ctx parameters, validating arity. + + A workflow function may declare at most one non-ctx parameter (which + receives the caller-supplied ``message``). Any extra non-ctx + parameters would be silently dropped by ``_execute``, so we reject + them at decoration time. + """ + try: + hints = typing.get_type_hints(func) + except Exception: + hints = {} + non_ctx: list[str] = [] + for param_name, param in inspect.signature(func).parameters.items(): + resolved = hints.get(param_name, param.annotation) + if resolved is RunContext or param_name == "ctx": + continue + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + non_ctx.append(param_name) + if len(non_ctx) > 1: + raise ValueError( + f"@workflow function '{func.__name__}' declares multiple non-RunContext " + f"parameters ({non_ctx}); at most one is supported (it receives the " + f"'message' argument passed to .run()). Combine the inputs into a " + f"single object or dict." + ) + return non_ctx + + # ------------------------------------------------------------------ + # run() — same overloaded interface as graph Workflow + # ------------------------------------------------------------------ + + @overload + def run( + self, + message: Any | None = None, + *, + stream: Literal[True], + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[WorkflowEvent[Any], WorkflowRunResult]: ... + + @overload + def run( + self, + message: Any | None = None, + *, + stream: Literal[False] = ..., + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + include_status_events: bool = False, + **kwargs: Any, + ) -> Awaitable[WorkflowRunResult]: ... + + def run( + self, + message: Any | None = None, + *, + stream: bool = False, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + include_status_events: bool = False, + **kwargs: Any, + ) -> ResponseStream[WorkflowEvent[Any], WorkflowRunResult] | Awaitable[WorkflowRunResult]: + """Run the functional workflow. + + At least one of *message*, *responses*, or *checkpoint_id* must be + provided. *message* starts a fresh run; *responses* resumes after a + HITL interruption; *checkpoint_id* restores from a previously saved + checkpoint. *responses* may be combined with *checkpoint_id* to + restore a checkpoint and inject HITL responses in a single call. + *message* is mutually exclusive with both *responses* and + *checkpoint_id*. + + Args: + message: Input data passed as the first positional argument to + the workflow function. + stream: If ``True``, return a :class:`ResponseStream` that + yields :class:`WorkflowEvent` instances as they are produced. + responses: HITL responses keyed by ``request_id``, used to + resume a workflow that was suspended by + :meth:`RunContext.request_info`. + checkpoint_id: Identifier of a checkpoint to restore from. + Requires *checkpoint_storage* to be set (here or on the + decorator). + checkpoint_storage: Override the default checkpoint storage + for this run. + include_status_events: When ``True`` (non-streaming only), + include status-change events in the result. + + Keyword Args: + **kwargs: Extra keyword arguments stored on + :attr:`RunContext._run_kwargs` and accessible to step + functions. + + Returns: + A :class:`WorkflowRunResult` (non-streaming) or a + :class:`ResponseStream` (streaming). + + Raises: + ValueError: If the combination of *message*, *responses*, and + *checkpoint_id* is invalid. + RuntimeError: If the workflow is already running (concurrent + execution is not allowed). + """ + self._validate_run_params(message, responses, checkpoint_id) + if responses and checkpoint_id is None: + # Require at least one response key to match a currently-pending + # request; prevents silent replay against stale state while still + # allowing callers to accumulate prior answers across multi-round + # HITL. + if not self._last_pending_request_ids: + raise ValueError( + f"responses={list(responses)!r} do not correspond to any pending request on " + f"workflow '{self.name}'. The workflow has no pending request_info events, " + f"so there is nothing to resume. Start a fresh run with 'message', or supply " + f"'checkpoint_id' to restore a specific checkpoint." + ) + if not (set(responses) & self._last_pending_request_ids): + raise ValueError( + f"responses={list(responses)!r} do not answer any of the currently-pending " + f"requests on workflow '{self.name}' ({sorted(self._last_pending_request_ids)!r}). " + f"Provide a response keyed by one of the pending request_ids." + ) + self._ensure_not_running() + + response_stream: ResponseStream[WorkflowEvent[Any], WorkflowRunResult] = ResponseStream( + self._run_core( + message=message, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + streaming=stream, + **kwargs, + ), + finalizer=functools.partial(self._finalize_events, include_status_events=include_status_events), + cleanup_hooks=[self._run_cleanup], + ) + + if stream: + return response_stream + return response_stream.get_final_response() + + # ------------------------------------------------------------------ + # As agent + # ------------------------------------------------------------------ + + def as_agent( + self, + name: str | None = None, + *, + description: str | None = None, + context_providers: Sequence[Any] | None = None, + **kwargs: Any, + ) -> FunctionalWorkflowAgent: + """Wrap this workflow as an agent-compatible object. + + The returned :class:`FunctionalWorkflowAgent` exposes a ``run()`` + method that delegates to the workflow, surfaces ``request_info`` + events as function approval requests, and converts outputs into an + :class:`AgentResponse`. + + Signature mirrors graph :meth:`Workflow.as_agent` so polymorphic + code works over either flavor. + + Args: + name: Display name for the agent. Defaults to the workflow name. + description: Optional description override. Defaults to the + workflow's ``description``. + context_providers: Optional context providers to associate with + the agent. Stored for caller introspection. + **kwargs: Reserved for future parity with + :meth:`Workflow.as_agent`. + + Returns: + A :class:`FunctionalWorkflowAgent` wrapping this workflow. + """ + return FunctionalWorkflowAgent( + workflow=self, + name=name, + description=description, + context_providers=context_providers, + **kwargs, + ) + + # ------------------------------------------------------------------ + # Internal execution + # ------------------------------------------------------------------ + + async def _run_core( + self, + message: Any | None = None, + *, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + streaming: bool = False, + **kwargs: Any, + ) -> AsyncIterable[WorkflowEvent[Any]]: + storage = checkpoint_storage or self._checkpoint_storage + + # Build context + ctx = RunContext(self.name, streaming=streaming, run_kwargs=kwargs if kwargs else None) + + # Restore from checkpoint if requested + prev_checkpoint_id: str | None = None + if checkpoint_id is not None: + if storage is None: + raise ValueError( + "Cannot restore from checkpoint without checkpoint_storage. " + "Provide checkpoint_storage parameter or set it on the @workflow decorator." + ) + checkpoint = await storage.load(checkpoint_id) + if checkpoint.graph_signature_hash != self.graph_signature_hash: + raise ValueError( + f"Checkpoint '{checkpoint_id}' was created by a different version of workflow " + f"'{checkpoint.workflow_name}' and is not compatible with the current version. " + f"The workflow's step structure may have changed since this checkpoint was saved." + ) + prev_checkpoint_id = checkpoint_id + # Restore step cache + step_cache_data = checkpoint.state.get("_step_cache", {}) + ctx._import_step_cache(step_cache_data) + step_cache_auto_request_info_counts = checkpoint.state.get("_step_cache_auto_request_info_counts", {}) + ctx._import_step_cache_auto_request_info_counts(step_cache_auto_request_info_counts) + # Restore user state + ctx._state = {k: v for k, v in checkpoint.state.items() if not k.startswith("_")} + # Restore pending request info events + ctx._pending_requests = dict(checkpoint.pending_request_info_events) + # Restore original message for replay + if message is None: + message = checkpoint.state.get("_original_message") + + # For response-only replay (no checkpoint), restore cached state + if checkpoint_id is None and responses: + if message is None: + message = self._last_message + ctx._step_cache = dict(self._last_step_cache) + ctx._step_cache_auto_request_info_counts = dict(self._last_step_cache_auto_request_info_counts) + + # Store message for future replays + if message is not None: + self._last_message = message + + # Set responses for replay + if responses: + ctx._set_responses(responses) + + # Wire up per-step checkpointing + # Use a mutable list so the closure can update prev_checkpoint_id + ckpt_chain: list[str | None] = [prev_checkpoint_id] + if storage is not None: + + async def _on_step_completed() -> None: + ckpt_chain[0] = await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + + ctx._on_step_completed = _on_step_completed + + # Tracing + attributes: dict[str, Any] = {OtelAttr.WORKFLOW_NAME: self.name} + if self.description: + attributes[OtelAttr.WORKFLOW_DESCRIPTION] = self.description + + with create_workflow_span(OtelAttr.WORKFLOW_RUN_SPAN, attributes) as span: + saw_request = False + try: + span.add_event(OtelAttr.WORKFLOW_STARTED) + + with _framework_event_origin(): + yield WorkflowEvent.started() + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS) + + # Execute the user function + return_value = await self._execute(ctx, message) + + # Emit the return value as the workflow output. + if return_value is not None: + await ctx.add_event(WorkflowEvent.output(self.name, return_value)) + + # Persist step cache for response-only replay + self._last_step_cache = dict(ctx._step_cache) + self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) + + # Yield collected events. + # NOTE: Events are buffered during _execute() and yielded after + # the user function completes. This is *not* true streaming — + # all events have already been produced by this point. True + # per-token streaming from inner agent calls is a future + # enhancement. + for event in ctx._get_events(): + if event.type == "request_info": + saw_request = True + yield event + if event.type == "request_info": + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) + + # Save final checkpoint if storage is available + if storage is not None: + await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + + # Final status + if saw_request: + self._last_pending_request_ids = set(ctx._pending_requests) + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) + else: + # Clean completion — drop cross-run replay state. + self._last_message = None + self._last_step_cache = {} + self._last_step_cache_auto_request_info_counts = {} + self._last_pending_request_ids = set() + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IDLE) + + span.add_event(OtelAttr.WORKFLOW_COMPLETED) + + except WorkflowInterrupted: + # Persist step cache for response-only replay + self._last_step_cache = dict(ctx._step_cache) + self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) + self._last_pending_request_ids = set(ctx._pending_requests) + + # HITL interruption — yield events collected so far + for event in ctx._get_events(): + if event.type == "request_info": + saw_request = True + yield event + if event.type == "request_info": + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) + + # Save checkpoint + if storage is not None: + await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) + + span.add_event(OtelAttr.WORKFLOW_COMPLETED) + + except Exception as exc: + # Yield any events collected before the failure + for event in ctx._get_events(): + yield event + + details = WorkflowErrorDetails.from_exception(exc) + with _framework_event_origin(): + yield WorkflowEvent.failed(details) + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.FAILED) + + span.add_event( + name=OtelAttr.WORKFLOW_ERROR, + attributes={ + "error.message": str(exc), + "error.type": type(exc).__name__, + }, + ) + capture_exception(span, exception=exc) + raise + + async def _execute(self, ctx: RunContext, message: Any) -> Any: + """Run the user's async function with the active context.""" + if message is not None and not self._non_ctx_param_names: + raise ValueError( + f"@workflow function '{self._func.__name__}' has no non-RunContext " + f"parameter to receive a message, but .run(message=...) was called " + f"with a non-None value. Either add a first parameter to the " + f"workflow function or omit 'message'." + ) + + token = _active_run_ctx.set(ctx) + try: + sig = inspect.signature(self._func) + params = list(sig.parameters.values()) + + # Resolve string annotations to actual types + try: + hints = typing.get_type_hints(self._func) + except Exception as exc: + logger.warning( + "Failed to resolve type hints for workflow function '%s': %s. " + "RunContext injection may not work if annotations are forward references.", + self._func.__name__, + exc, + ) + hints = {} + + # Build call arguments: inject RunContext and pass `message`. + # RunContext is detected by type annotation first, then by + # parameter name "ctx" — so both of these work: + # async def my_workflow(data: str, ctx: RunContext) -> str: + # async def my_workflow(data: str, ctx) -> str: + call_args: list[Any] = [] + message_injected = False + + for param in params: + resolved = hints.get(param.name, param.annotation) + if resolved is RunContext or param.name == "ctx": + call_args.append(ctx) + elif not message_injected: + # First non-ctx param gets the message + call_args.append(message) + message_injected = True + + return await self._func(*call_args) + finally: + _active_run_ctx.reset(token) + + # ------------------------------------------------------------------ + # Checkpoint helpers + # ------------------------------------------------------------------ + + async def _save_checkpoint( + self, + ctx: RunContext, + storage: CheckpointStorage, + previous_checkpoint_id: str | None = None, + ) -> str: + state = dict(ctx._state) + state["_step_cache"] = ctx._export_step_cache() + state["_step_cache_auto_request_info_counts"] = ctx._export_step_cache_auto_request_info_counts() + state["_original_message"] = self._last_message + + checkpoint = WorkflowCheckpoint( + workflow_name=self.name, + graph_signature_hash=self.graph_signature_hash, + previous_checkpoint_id=previous_checkpoint_id, + state=state, + pending_request_info_events=dict(ctx._pending_requests), + ) + return await storage.save(checkpoint) + + def _compute_signature_hash(self) -> str: + """Stable hash of the workflow's code shape. + + Mixes workflow name, statically-discovered step names, and a digest + of ``__code__.co_code`` + ``co_names``. The code digest catches + body changes that step-name discovery misses (e.g. attribute-access + step references). + """ + code = getattr(self._func, "__code__", None) + co_code_hex = hashlib.sha256(code.co_code).hexdigest() if code is not None else "" + co_names = tuple(sorted(code.co_names)) if code is not None else () + sig_data = { + "workflow": self.name, + "steps": sorted(self._step_names), + "co_code": co_code_hex, + "co_names": list(co_names), + } + import json + + canonical = json.dumps(sig_data, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + @staticmethod + def _discover_step_names(func: Callable[..., Any]) -> list[str]: + """Extract step names referenced by the workflow function. + + Inspects the function's ``__code__.co_names`` and global scope for + ``StepWrapper`` instances. Steps accessed via module or class + attributes (``my_steps.fetch``) are missed here, but + :meth:`_compute_signature_hash` still captures them through the + ``co_code`` digest. + """ + names: list[str] = [] + globs = getattr(func, "__globals__", {}) + code_names = getattr(getattr(func, "__code__", None), "co_names", ()) + for n in code_names: + obj = globs.get(n) + if isinstance(obj, StepWrapper): + names.append(obj.name) + return names + + # ------------------------------------------------------------------ + # Finalize / cleanup / validation (mirrors Workflow) + # ------------------------------------------------------------------ + + @staticmethod + def _finalize_events( + events: Sequence[WorkflowEvent[Any]], + *, + include_status_events: bool = False, + ) -> WorkflowRunResult: + filtered: list[WorkflowEvent[Any]] = [] + status_events: list[WorkflowEvent[Any]] = [] + + for ev in events: + if ev.type == "started": + continue + if ev.type == "status": + status_events.append(ev) + if include_status_events: + filtered.append(ev) + continue + filtered.append(ev) + + return WorkflowRunResult(filtered, status_events) + + @staticmethod + def _validate_run_params( + message: Any | None, + responses: dict[str, Any] | None, + checkpoint_id: str | None, + ) -> None: + if message is not None and responses is not None: + raise ValueError("Cannot provide both 'message' and 'responses'. Use one or the other.") + + if message is not None and checkpoint_id is not None: + raise ValueError("Cannot provide both 'message' and 'checkpoint_id'. Use one or the other.") + + if message is None and responses is None and checkpoint_id is None: + raise ValueError( + "Must provide at least one of: 'message' (new run), 'responses' (send responses), " + "or 'checkpoint_id' (resume from checkpoint)." + ) + + def _ensure_not_running(self) -> None: + if self._is_running: + raise RuntimeError("Workflow is already running. Concurrent executions are not allowed.") + self._is_running = True + + async def _run_cleanup(self) -> None: + self._is_running = False + + +# --------------------------------------------------------------------------- +# @workflow decorator +# --------------------------------------------------------------------------- + + +@overload +def workflow(func: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow: ... + + +@overload +def workflow( + *, + name: str | None = None, + description: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, +) -> Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]: ... + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +def workflow( + func: Callable[..., Awaitable[Any]] | None = None, + *, + name: str | None = None, + description: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, +) -> FunctionalWorkflow | Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]: + """Decorator that converts an async function into a :class:`FunctionalWorkflow`. + + Supports both bare ``@workflow`` and parameterized + ``@workflow(name="my_wf")`` forms. + + The decorated function receives its input as the first positional argument + and a :class:`RunContext` instance wherever a parameter is annotated with + that type. The resulting :class:`FunctionalWorkflow` object exposes the + same ``run()`` interface as graph-based workflows. + + Args: + func: The async function to decorate (when using the bare + ``@workflow`` form). + name: Display name for the workflow. Defaults to ``func.__name__``. + description: Optional human-readable description. + checkpoint_storage: Default :class:`CheckpointStorage` for + persisting step results and workflow state. + + Returns: + A :class:`FunctionalWorkflow` (bare form) or a decorator that + produces one (parameterized form). + + Examples: + + .. code-block:: python + + # Bare form + @workflow + async def pipeline(data: str) -> str: + return await process(data) + + + # Parameterized form + @workflow(name="my_pipeline", checkpoint_storage=storage) + async def pipeline(data: str) -> str: ... + """ + if func is not None: + return FunctionalWorkflow(func, name=name, description=description, checkpoint_storage=checkpoint_storage) + + def _decorator(fn: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow: + return FunctionalWorkflow(fn, name=name, description=description, checkpoint_storage=checkpoint_storage) + + return _decorator + + +# --------------------------------------------------------------------------- +# FunctionalWorkflowAgent +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class FunctionalWorkflowAgent: + """Agent adapter for a :class:`FunctionalWorkflow`. + + Provides a ``run()`` method with the same overloaded signature as + :class:`BaseAgent` — returning an :class:`AgentResponse` (non-streaming) + or a :class:`ResponseStream[AgentResponseUpdate, AgentResponse]` + (streaming), making functional workflows usable anywhere an + agent-compatible object is expected. + + ``request_info`` events emitted by the underlying workflow are surfaced + as :class:`FunctionApprovalRequestContent` items (mirroring the graph + :class:`WorkflowAgent`), so HITL workflows are callable via this + adapter. Callers resume via ``responses=`` / ``checkpoint_id=``. + + Args: + workflow: The :class:`FunctionalWorkflow` to wrap. + name: Display name for the agent. Defaults to the workflow name. + description: Display description. Defaults to ``workflow.description``. + context_providers: Optional context providers stored for caller + introspection. + **kwargs: Reserved for future parity with :class:`WorkflowAgent`; + currently ignored. + """ + + REQUEST_INFO_FUNCTION_NAME: str = "request_info" + + def __init__( + self, + workflow: FunctionalWorkflow, + *, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[Any] | None = None, + **kwargs: Any, + ) -> None: + # kwargs is accepted for signature parity with graph Workflow.as_agent + # but not otherwise consumed. + del kwargs + self._workflow = workflow + self.name = name or workflow.name + self.id = f"FunctionalWorkflowAgent_{self.name}" + self.description: str | None = description if description is not None else workflow.description + self.context_providers: Sequence[Any] | None = context_providers + self._pending_requests: dict[str, WorkflowEvent[Any]] = {} + + @property + def pending_requests(self) -> dict[str, WorkflowEvent[Any]]: + """Pending request_info events emitted during the last run.""" + return self._pending_requests + + @overload + def run( + self, + messages: Any | None = None, + *, + stream: Literal[True], + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... + + @overload + def run( + self, + messages: Any | None = None, + *, + stream: Literal[False] = ..., + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse]: ... + + def run( + self, + messages: Any | None = None, + *, + stream: bool = False, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse] | Awaitable[AgentResponse]: + """Run the underlying workflow and return the result as an agent response. + + Args: + messages: Input data forwarded to :meth:`FunctionalWorkflow.run`. + + Keyword Args: + stream: If ``True``, return a :class:`ResponseStream` of + :class:`AgentResponseUpdate` items. + responses: HITL responses keyed by ``request_id``, forwarded to + the underlying workflow so HITL resumes work via this agent. + checkpoint_id: Optional checkpoint to restore from. + checkpoint_storage: Override the workflow's default + :class:`CheckpointStorage` for this run. + **kwargs: Extra keyword arguments forwarded to the workflow run. + + Returns: + An :class:`AgentResponse` (non-streaming) or a + :class:`ResponseStream` (streaming). + """ + if stream: + return self._run_streaming( + messages, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + return self._run_non_streaming( + messages, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + + async def _run_non_streaming( + self, + messages: Any | None, + *, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> AgentResponse: + result = await self._workflow.run( + messages, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + return self._result_to_agent_response(result) + + def _run_streaming( + self, + messages: Any | None, + *, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + from .._types import Content + + agent_name = self.name + # Clear per-run pending state up front + self._pending_requests = {} + workflow_stream = self._workflow.run( + messages, + stream=True, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + + async def _generate_updates() -> AsyncIterable[AgentResponseUpdate]: + async for event in workflow_stream: + if event.type == "output": + data = event.data + if isinstance(data, str): + contents: list[Content] = [Content.from_text(text=data)] + elif isinstance(data, Content): + contents = [data] + else: + contents = [Content.from_text(text=str(data))] + yield AgentResponseUpdate( + contents=contents, + role="assistant", + author_name=agent_name, + ) + elif event.type == "request_info": + approval = self._request_info_to_approval_request(event) + if approval is None: + continue + yield AgentResponseUpdate( + contents=[approval], + role="assistant", + author_name=agent_name, + ) + + return ResponseStream( + _generate_updates(), + finalizer=AgentResponse.from_updates, + ) + + def _request_info_to_approval_request(self, event: WorkflowEvent[Any]) -> Any: + """Convert a `request_info` event to `FunctionApprovalRequestContent`. + + Returns ``None`` if the event is missing a request_id (defensive; + `request_info` always sets one). + """ + from .._types import Content + + request_id = event.request_id + if not request_id: + return None + self._pending_requests[request_id] = event + function_call = Content.from_function_call( + call_id=request_id, + name=self.REQUEST_INFO_FUNCTION_NAME, + arguments={"request_id": request_id, "data": event.data}, + ) + return Content.from_function_approval_request( + id=request_id, + function_call=function_call, + additional_properties={"request_id": request_id}, + ) + + def _result_to_agent_response(self, result: WorkflowRunResult) -> AgentResponse: + from .._types import Content + from .._types import Message as Msg + + # Refresh pending_requests for this run. + self._pending_requests = {} + + messages: list[Msg] = [] + for output in result.get_outputs(): + if isinstance(output, str): + contents: list[Content] = [Content.from_text(text=output)] + elif isinstance(output, Content): + contents = [output] + else: + contents = [Content.from_text(text=str(output))] + messages.append(Msg("assistant", contents)) + + # Surface pending request_info events so HITL callers see them. + approval_contents: list[Content] = [] + for event in result.get_request_info_events(): + approval = self._request_info_to_approval_request(event) + if approval is not None: + approval_contents.append(approval) + if approval_contents: + messages.append(Msg("assistant", approval_contents)) + + return AgentResponse(messages=messages) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index fc26db8953..c452f62bc2 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -340,10 +340,10 @@ class Workflow(DictConvertible): # Emit explicit start/status events to the stream with _framework_event_origin(): started = WorkflowEvent.started() - yield started + yield started # noqa: RUF070 with _framework_event_origin(): in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS) - yield in_progress + yield in_progress # noqa: RUF070 # Reset context for a new run if supported if reset_context: @@ -388,7 +388,7 @@ class Workflow(DictConvertible): emitted_in_progress_pending = True with _framework_event_origin(): pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) - yield pending_status + yield pending_status # noqa: RUF070 # Workflow runs until idle - emit final status based on whether requests are pending if saw_request: with _framework_event_origin(): @@ -409,10 +409,10 @@ class Workflow(DictConvertible): details = WorkflowErrorDetails.from_exception(exc) with _framework_event_origin(): failed_event = WorkflowEvent.failed(details) - yield failed_event + yield failed_event # noqa: RUF070 with _framework_event_origin(): failed_status = WorkflowEvent.status(WorkflowRunState.FAILED) - yield failed_status + yield failed_status # noqa: RUF070 span.add_event( name=OtelAttr.WORKFLOW_ERROR, attributes={ diff --git a/python/packages/core/tests/workflow/test_function_executor.py b/python/packages/core/tests/workflow/test_function_executor.py index 6d292cb6eb..b45a667722 100644 --- a/python/packages/core/tests/workflow/test_function_executor.py +++ b/python/packages/core/tests/workflow/test_function_executor.py @@ -529,11 +529,12 @@ class TestFunctionExecutor: assert "@handler on instance methods" in str(exc_info.value) async def test_async_staticmethod_detection_behavior(self): - """Document the behavior of asyncio.iscoroutinefunction with staticmethod descriptors. + """Document the behavior of inspect.iscoroutinefunction with staticmethod descriptors. This test explains why the unwrapping is necessary when decorators are stacked. """ import asyncio + import inspect # When @staticmethod is applied, it creates a descriptor async def my_async_func(): @@ -544,19 +545,19 @@ class TestFunctionExecutor: static_wrapped = staticmethod(my_async_func) # Direct check on descriptor object fails (this is the bug) - assert not asyncio.iscoroutinefunction(static_wrapped) # type: ignore[reportDeprecated] + assert not inspect.iscoroutinefunction(static_wrapped) assert isinstance(static_wrapped, staticmethod) # But unwrapping __func__ reveals the async function unwrapped = static_wrapped.__func__ - assert asyncio.iscoroutinefunction(unwrapped) # type: ignore[reportDeprecated] + assert inspect.iscoroutinefunction(unwrapped) # When accessed via class attribute, Python's descriptor protocol # automatically unwraps it, so it works: class C: async_static = static_wrapped - assert asyncio.iscoroutinefunction(C.async_static) # type: ignore[reportDeprecated] # Works via descriptor protocol + assert inspect.iscoroutinefunction(C.async_static) # Works via descriptor protocol class TestExecutorExplicitTypes: diff --git a/python/packages/core/tests/workflow/test_functional_workflow.py b/python/packages/core/tests/workflow/test_functional_workflow.py new file mode 100644 index 0000000000..ba465ffe0b --- /dev/null +++ b/python/packages/core/tests/workflow/test_functional_workflow.py @@ -0,0 +1,1693 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the functional workflow API (@workflow, @step, RunContext).""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + +import pytest + +from agent_framework import ( + AgentResponseUpdate, + ExperimentalFeature, + FunctionalWorkflow, + FunctionalWorkflowAgent, + InMemoryCheckpointStorage, + RunContext, + StepWrapper, + WorkflowEvent, + WorkflowRunResult, + WorkflowRunState, + get_run_context, + step, + workflow, +) +from agent_framework._workflows._functional import ( + RunContext as _RunContext, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@step +async def add_one(x: int) -> int: + return x + 1 + + +@step +async def double(x: int) -> int: + return x * 2 + + +@step +async def to_upper(s: str) -> str: + return s.upper() + + +@step(name="custom_name") +async def named_step(x: int) -> int: + return x + 10 + + +@step +async def failing_step(x: int) -> int: + raise ValueError(f"step failed with {x}") + + +# --------------------------------------------------------------------------- +# Basic execution +# --------------------------------------------------------------------------- + + +class TestBasicExecution: + async def test_simple_sequential_pipeline(self): + @workflow + async def pipeline(x: int) -> int: + a = await add_one(x) + return await double(a) + + result = await pipeline.run(5) + assert isinstance(result, WorkflowRunResult) + outputs = result.get_outputs() + assert outputs == [12] # (5+1)*2 + + async def test_workflow_with_string_data(self): + @workflow + async def upper_pipeline(text: str) -> str: + return await to_upper(text) + + result = await upper_pipeline.run("hello") + assert result.get_outputs() == ["HELLO"] + + async def test_workflow_returns_result(self): + @workflow + async def simple(x: int) -> int: + return await add_one(x) + + result = await simple.run(10) + assert result.get_outputs() == [11] + + async def test_workflow_name_defaults_to_function_name(self): + @workflow + async def my_pipeline(x: int) -> int: + return x + + assert my_pipeline.name == "my_pipeline" + + async def test_workflow_custom_name(self): + @workflow(name="custom_wf", description="A test workflow") + async def wf(x: int) -> int: + return x + + assert wf.name == "custom_wf" + assert wf.description == "A test workflow" + + +# --------------------------------------------------------------------------- +# Event emission +# --------------------------------------------------------------------------- + + +class TestEventEmission: + async def test_step_events_emitted(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + result = await pipeline.run(5) + event_types = [e.type for e in result] + assert "executor_invoked" in event_types + assert "executor_completed" in event_types + assert "output" in event_types + + async def test_step_events_carry_executor_id(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + result = await pipeline.run(5) + invoked_events = [e for e in result if e.type == "executor_invoked"] + assert len(invoked_events) == 1 + assert invoked_events[0].executor_id == "add_one" + + completed_events = [e for e in result if e.type == "executor_completed"] + assert len(completed_events) == 1 + assert completed_events[0].executor_id == "add_one" + assert completed_events[0].data == 6 + + async def test_status_events_in_timeline(self): + @workflow + async def pipeline(x: int) -> int: + return x + + result = await pipeline.run(1) + states = [e.state for e in result.status_timeline()] + assert WorkflowRunState.IN_PROGRESS in states + assert WorkflowRunState.IDLE in states + + async def test_final_state_is_idle(self): + @workflow + async def pipeline(x: int) -> int: + return x + + result = await pipeline.run(1) + assert result.get_final_state() == WorkflowRunState.IDLE + + async def test_custom_event(self): + from agent_framework import WorkflowEvent + + @workflow + async def pipeline(x: int, ctx: RunContext) -> int: + await ctx.add_event(WorkflowEvent.emit("pipeline", "custom_data")) + return x + + result = await pipeline.run(1) + data_events = [e for e in result if e.type == "data"] + assert len(data_events) == 1 + assert data_events[0].data == "custom_data" + + +# --------------------------------------------------------------------------- +# Parallel execution +# --------------------------------------------------------------------------- + + +class TestParallelExecution: + async def test_parallel_tasks_with_gather(self): + @step + async def slow_add(x: int) -> int: + await asyncio.sleep(0.01) + return x + 1 + + @step + async def slow_double(x: int) -> int: + await asyncio.sleep(0.01) + return x * 2 + + @workflow + async def parallel_wf(x: int) -> list[int]: + a, b = await asyncio.gather(slow_add(x), slow_double(x)) + return [a, b] + + result = await parallel_wf.run(5) + outputs = result.get_outputs() + assert outputs == [[6, 10]] + + async def test_parallel_events_all_emitted(self): + @step + async def task_a(x: int) -> int: + return x + 1 + + @step + async def task_b(x: int) -> int: + return x * 2 + + @workflow + async def par_wf(x: int) -> tuple[int, int]: + a, b = await asyncio.gather(task_a(x), task_b(x)) + return (a, b) + + result = await par_wf.run(3) + invoked = [e for e in result if e.type == "executor_invoked"] + completed = [e for e in result if e.type == "executor_completed"] + assert len(invoked) == 2 + assert len(completed) == 2 + + +# --------------------------------------------------------------------------- +# HITL (request_info / resume) +# --------------------------------------------------------------------------- + + +class TestHITL: + async def test_request_info_interrupts(self): + @workflow + async def review_wf(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") + return f"Final: {feedback}" + + # Phase 1: should interrupt with pending request + result = await review_wf.run("my doc") + assert result.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + request_events = result.get_request_info_events() + assert len(request_events) == 1 + assert request_events[0].request_id == "req1" + + async def test_request_info_resume(self): + @workflow + async def review_wf(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") + return f"Final: {feedback}" + + # Phase 1 + result1 = await review_wf.run("my doc") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: resume with response + result2 = await review_wf.run(responses={"req1": "Looks great!"}) + outputs = result2.get_outputs() + assert outputs == ["Final: Looks great!"] + assert result2.get_final_state() == WorkflowRunState.IDLE + + async def test_untyped_ctx_parameter(self): + """ctx is injected by parameter name even without a RunContext annotation.""" + + @workflow # pyright: ignore[reportUnknownArgumentType] + async def review_wf(doc: str, ctx) -> str: # pyright: ignore[reportUnknownParameterType,reportMissingParameterType] + feedback: str = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] + return f"Final: {feedback}" + + result1 = await review_wf.run("my doc") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + result2 = await review_wf.run(responses={"req1": "LGTM"}) + assert result2.get_outputs() == ["Final: LGTM"] + + async def test_multiple_sequential_interrupts(self): + @workflow + async def multi_hitl(data: str, ctx: RunContext) -> str: + r1 = await ctx.request_info("step1", response_type=str, request_id="r1") + r2 = await ctx.request_info("step2", response_type=str, request_id="r2") + return f"{r1}+{r2}" + + # Phase 1: first interrupt + result1 = await multi_hitl.run("start") + assert len(result1.get_request_info_events()) == 1 + assert result1.get_request_info_events()[0].request_id == "r1" + + # Phase 2: respond to first, hits second + result2 = await multi_hitl.run(responses={"r1": "A"}) + assert len(result2.get_request_info_events()) == 1 + assert result2.get_request_info_events()[0].request_id == "r2" + + # Phase 3: respond to second + result3 = await multi_hitl.run(responses={"r1": "A", "r2": "B"}) + assert result3.get_outputs() == ["A+B"] + + async def test_request_info_auto_generates_id(self): + @workflow + async def auto_id_wf(x: int, ctx: RunContext) -> None: + await ctx.request_info("need data", response_type=str) + + result = await auto_id_wf.run(1) + events = result.get_request_info_events() + assert len(events) == 1 + assert events[0].request_id # should be a non-empty uuid string + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrorHandling: + async def test_step_failure_propagates(self): + @workflow + async def failing_wf(x: int) -> None: + await failing_step(x) + + with pytest.raises(ValueError, match="step failed with 42"): + await failing_wf.run(42) + + async def test_step_failure_emits_executor_failed(self): + @workflow + async def failing_wf(x: int) -> None: + await failing_step(x) + + # Use stream to collect events before the raise + stream = failing_wf.run(42, stream=True) + events: list[WorkflowEvent[object]] = [] + with pytest.raises(ValueError): + async for event in stream: + events.append(event) + + failed_events = [e for e in events if e.type == "executor_failed"] + assert len(failed_events) == 1 + assert failed_events[0].executor_id == "failing_step" + + async def test_workflow_failure_emits_failed_status(self): + @workflow + async def bad_wf(x: int) -> None: + raise RuntimeError("workflow broke") + + stream = bad_wf.run(42, stream=True) + events: list[WorkflowEvent[object]] = [] + with pytest.raises(RuntimeError, match="workflow broke"): + async for event in stream: + events.append(event) + + failed_events = [e for e in events if e.type == "failed"] + assert len(failed_events) == 1 + status_events = [e for e in events if e.type == "status"] + assert any(e.state == WorkflowRunState.FAILED for e in status_events) + + async def test_invalid_params_message_and_responses(self): + @workflow + async def wf(x: int) -> None: + pass + + with pytest.raises(ValueError, match="Cannot provide both"): + await wf.run("hello", responses={"r1": "val"}) + + async def test_invalid_params_message_and_checkpoint(self): + @workflow + async def wf(x: int) -> None: + pass + + with pytest.raises(ValueError, match="Cannot provide both"): + await wf.run("hello", checkpoint_id="abc") + + async def test_invalid_params_nothing(self): + @workflow + async def wf(x: int) -> None: + pass + + with pytest.raises(ValueError, match="Must provide at least one"): + await wf.run() + + +# --------------------------------------------------------------------------- +# Streaming +# --------------------------------------------------------------------------- + + +class TestStreaming: + async def test_streaming_yields_events(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + stream = pipeline.run(5, stream=True) + events: list[WorkflowEvent[object]] = [] + async for event in stream: + events.append(event) + + event_types = [e.type for e in events] + assert "started" in event_types + assert "executor_invoked" in event_types + assert "executor_completed" in event_types + assert "output" in event_types + + async def test_streaming_final_response(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + stream = pipeline.run(5, stream=True) + final = await stream.get_final_response() + assert isinstance(final, WorkflowRunResult) + assert final.get_outputs() == [6] + + async def test_streaming_context_reports_streaming(self): + streaming_flag = None + + @workflow + async def wf(x: int, ctx: RunContext) -> int: + nonlocal streaming_flag + streaming_flag = ctx.is_streaming() + return x + + stream = wf.run(1, stream=True) + await stream.get_final_response() + assert streaming_flag is True + + streaming_flag = None + await wf.run(1) + assert streaming_flag is False + + +# --------------------------------------------------------------------------- +# Step passthrough outside workflow +# --------------------------------------------------------------------------- + + +class TestStepPassthrough: + async def test_step_works_outside_workflow(self): + result = await add_one(10) + assert result == 11 + + async def test_named_step_outside_workflow(self): + result = await named_step(5) + assert result == 15 + + def test_step_wrapper_name(self): + assert add_one.name == "add_one" + assert named_step.name == "custom_name" + + def test_step_wrapper_is_step_wrapper(self): + assert isinstance(add_one, StepWrapper) + assert isinstance(named_step, StepWrapper) + + +# --------------------------------------------------------------------------- +# State management +# --------------------------------------------------------------------------- + + +class TestStateManagement: + async def test_get_set_state(self): + @workflow + async def stateful_wf(x: int, ctx: RunContext) -> int: + ctx.set_state("counter", x) + return ctx.get_state("counter") + + result = await stateful_wf.run(42) + assert result.get_outputs() == [42] + + async def test_get_state_default(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + return ctx.get_state("missing", "default_val") + + result = await wf.run(1) + assert result.get_outputs() == ["default_val"] + + +# --------------------------------------------------------------------------- +# Checkpointing +# --------------------------------------------------------------------------- + + +class TestCheckpointing: + async def test_checkpoint_save_and_restore(self): + storage = InMemoryCheckpointStorage() + + @step + async def expensive(x: int) -> int: + return x * 100 + + @workflow(checkpoint_storage=storage) + async def ckpt_wf(x: int) -> int: + return await expensive(x) + + result = await ckpt_wf.run(5) + assert result.get_outputs() == [500] + + # Verify checkpoints were saved: 1 per-step + 1 final + checkpoints = await storage.list_checkpoints(workflow_name="ckpt_wf") + assert len(checkpoints) == 2 + + async def test_checkpoint_runtime_storage_override(self): + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x + 1 + + @workflow + async def wf(x: int) -> int: + return await compute(x) + + result = await wf.run(10, checkpoint_storage=storage) + assert result.get_outputs() == [11] + # 1 per-step checkpoint + 1 final checkpoint + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert len(checkpoints) == 2 + + async def test_checkpoint_restore_replays_cached_tasks(self): + storage = InMemoryCheckpointStorage() + call_count = 0 + + @step + async def counting_task(x: int) -> int: + nonlocal call_count + call_count += 1 + return x + 1 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await counting_task(x) + + # First run + result1 = await wf.run(5) + assert result1.get_outputs() == [6] + assert call_count == 1 + + # Get checkpoint ID + checkpoints = await storage.list_checkpoints(workflow_name="wf") + ckpt_id = checkpoints[0].checkpoint_id + + # Restore — step should replay from cache + result2 = await wf.run(checkpoint_id=ckpt_id) + assert result2.get_outputs() == [6] + assert call_count == 1 # not called again + + async def test_checkpoint_hitl_resume(self): + storage = InMemoryCheckpointStorage() + + @workflow(checkpoint_storage=storage) + async def hitl_wf(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") + return f"Done: {feedback}" + + # Phase 1: interrupt + result1 = await hitl_wf.run("draft text") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Get checkpoint + checkpoints = await storage.list_checkpoints(workflow_name="hitl_wf") + ckpt_id = checkpoints[0].checkpoint_id + + # Phase 2: restore and respond + result2 = await hitl_wf.run(checkpoint_id=ckpt_id, responses={"req1": "Approved!"}) + assert result2.get_outputs() == ["Done: Approved!"] + + async def test_checkpoint_without_storage_raises(self): + @workflow + async def wf(x: int) -> int: + return x + + with pytest.raises(ValueError, match="checkpoint_storage"): + await wf.run(checkpoint_id="nonexistent") + + async def test_checkpoint_preserves_state(self): + storage = InMemoryCheckpointStorage() + + @workflow(checkpoint_storage=storage) + async def stateful_wf(x: int, ctx: RunContext) -> str: + ctx.set_state("key", "value") + feedback = await ctx.request_info("need info", response_type=str, request_id="r1") + val = ctx.get_state("key") + return f"{val}:{feedback}" + + # Phase 1 + result1 = await stateful_wf.run(1) + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: restore and respond + checkpoints = await storage.list_checkpoints(workflow_name="stateful_wf") + ckpt_id = checkpoints[0].checkpoint_id + + result2 = await stateful_wf.run(checkpoint_id=ckpt_id, responses={"r1": "hello"}) + assert result2.get_outputs() == ["value:hello"] + + async def test_per_step_checkpoint_enables_crash_recovery(self): + """Simulates crash recovery: step 1 completes and is checkpointed, + then the workflow crashes in step 2. Restoring from the per-step + checkpoint should replay step 1 from cache without re-executing it.""" + storage = InMemoryCheckpointStorage() + step1_calls = 0 + step2_calls = 0 + + @step + async def slow_step1(x: int) -> int: + nonlocal step1_calls + step1_calls += 1 + return x + 10 + + @step + async def crashing_step2(x: int) -> int: + nonlocal step2_calls + step2_calls += 1 + if step2_calls == 1: + raise RuntimeError("simulated crash") + return x * 2 + + @workflow(checkpoint_storage=storage) + async def crash_wf(x: int) -> int: + a = await slow_step1(x) + return await crashing_step2(a) + + # First run: step1 succeeds and checkpoints, step2 crashes + with pytest.raises(RuntimeError, match="simulated crash"): + await crash_wf.run(5) + + assert step1_calls == 1 + assert step2_calls == 1 + + # A per-step checkpoint was saved after step1 completed + checkpoints = await storage.list_checkpoints(workflow_name="crash_wf") + assert len(checkpoints) >= 1 + ckpt_id = checkpoints[0].checkpoint_id + + # Restore from checkpoint: step1 replays from cache, step2 runs fresh + result = await crash_wf.run(checkpoint_id=ckpt_id) + assert result.get_outputs() == [30] # (5+10)*2 + assert step1_calls == 1 # NOT called again — replayed from cache + assert step2_calls == 2 # called again, succeeds this time + + async def test_per_step_checkpoint_chain(self): + """Each step creates a new checkpoint chained to the previous one.""" + storage = InMemoryCheckpointStorage() + + @step + async def s1(x: int) -> int: + return x + 1 + + @step + async def s2(x: int) -> int: + return x + 2 + + @step + async def s3(x: int) -> int: + return x + 3 + + @workflow(checkpoint_storage=storage) + async def multi_step_wf(x: int) -> int: + a = await s1(x) + b = await s2(a) + return await s3(b) + + result = await multi_step_wf.run(0) + assert result.get_outputs() == [6] # 0+1+2+3 + + # 3 per-step checkpoints + 1 final = 4 + checkpoints = await storage.list_checkpoints(workflow_name="multi_step_wf") + assert len(checkpoints) == 4 + + async def test_no_checkpoint_on_cache_hit(self): + """During replay, cached steps should NOT create additional checkpoints.""" + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x + 1 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await compute(x) + + # First run: 1 per-step + 1 final = 2 checkpoints + await wf.run(5) + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert len(checkpoints) == 2 + ckpt_id = checkpoints[0].checkpoint_id + + # Restore: step replays from cache (no new per-step checkpoint), + # but final checkpoint still saved = 1 new checkpoint + await wf.run(checkpoint_id=ckpt_id) + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert len(checkpoints) == 3 # 2 from first run + 1 final from restore + + +# --------------------------------------------------------------------------- +# Branching / control flow +# --------------------------------------------------------------------------- + + +class TestControlFlow: + async def test_if_else_branching(self): + @dataclass + class Classification: + is_spam: bool + + @step + async def classify(text: str) -> Classification: + return Classification(is_spam="spam" in text.lower()) + + @step + async def process_normal(text: str) -> str: + return f"processed: {text}" + + @step + async def quarantine(text: str) -> str: + return f"quarantined: {text}" + + @workflow + async def email_pipeline(email: str) -> str: + cl = await classify(email) + if cl.is_spam: + result = await quarantine(email) + else: + result = await process_normal(email) + return result + + result_spam = await email_pipeline.run("Buy spam now!") + assert result_spam.get_outputs() == ["quarantined: Buy spam now!"] + + result_normal = await email_pipeline.run("Hello friend") + assert result_normal.get_outputs() == ["processed: Hello friend"] + + +# --------------------------------------------------------------------------- +# Nested workflow calls +# --------------------------------------------------------------------------- + + +class TestNestedWorkflows: + async def test_nested_workflow_as_task(self): + @step + async def step_a(x: int) -> int: + return x + 1 + + @workflow + async def inner_wf(x: int) -> int: + return await step_a(x) + + @step + async def call_inner(x: int) -> int: + result = await inner_wf.run(x) + return result.get_outputs()[0] + + @workflow + async def outer_wf(x: int) -> int: + return await call_inner(x) + + result = await outer_wf.run(5) + assert result.get_outputs() == [6] + + +# --------------------------------------------------------------------------- +# as_agent() +# --------------------------------------------------------------------------- + + +class TestAsAgent: + async def test_as_agent_returns_agent(self): + @workflow + async def wf(x: int) -> str: + return f"result: {x}" + + agent = wf.as_agent() + assert agent.name == "wf" + + async def test_as_agent_custom_name(self): + @workflow + async def wf(x: int) -> int: + return x + + agent = wf.as_agent(name="my_agent") + assert agent.name == "my_agent" + + async def test_as_agent_run(self): + @workflow + async def wf(x: int) -> int: + return await add_one(x) + + agent = wf.as_agent() + response = await agent.run(10) + assert response.text == "11" + + async def test_as_agent_run_streaming(self): + @workflow + async def wf(x: int) -> str: + return f"result: {x}" + + agent = wf.as_agent() + stream = agent.run(10, stream=True) + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + assert len(updates) == 1 + assert updates[0].text == "result: 10" + + response = await stream.get_final_response() + assert len(response.messages) >= 1 + + async def test_as_agent_has_id_and_description(self): + @workflow(description="A test workflow") + async def wf(x: int) -> int: + return x + + agent = wf.as_agent(name="my_agent") + assert agent.id == "FunctionalWorkflowAgent_my_agent" + assert agent.description == "A test workflow" + + +# --------------------------------------------------------------------------- +# Concurrent execution guard +# --------------------------------------------------------------------------- + + +class TestConcurrencyGuard: + async def test_concurrent_run_raises(self): + @workflow + async def slow_wf(x: int) -> int: + await asyncio.sleep(0.1) + return x + + # Start first run + stream = slow_wf.run(1, stream=True) + + # Try to start second run while first is active + with pytest.raises(RuntimeError, match="already running"): + slow_wf.run(2, stream=True) + + # Consume the stream to clean up + await stream.get_final_response() + + async def test_run_after_completion(self): + @workflow + async def wf(x: int) -> int: + return x + + result1 = await wf.run(1) + assert result1.get_outputs() == [1] + + # Should be able to run again after first completes + result2 = await wf.run(2) + assert result2.get_outputs() == [2] + + +# --------------------------------------------------------------------------- +# Decorator forms +# --------------------------------------------------------------------------- + + +class TestDecoratorForms: + def test_step_bare_decorator(self): + @step + async def my_step(x: int) -> int: + return x + + assert isinstance(my_step, StepWrapper) + assert my_step.name == "my_step" + + def test_step_with_name(self): + @step(name="renamed") + async def my_step(x: int) -> int: + return x + + assert isinstance(my_step, StepWrapper) + assert my_step.name == "renamed" + + def test_workflow_bare_decorator(self): + @workflow + async def my_wf(x: int) -> None: + pass + + assert isinstance(my_wf, FunctionalWorkflow) + assert my_wf.name == "my_wf" + + def test_workflow_with_params(self): + @workflow(name="custom", description="desc") + async def my_wf(x: int) -> None: + pass + + assert isinstance(my_wf, FunctionalWorkflow) + assert my_wf.name == "custom" + assert my_wf.description == "desc" + + +# --------------------------------------------------------------------------- +# include_status_events +# --------------------------------------------------------------------------- + + +class TestIncludeStatusEvents: + async def test_status_events_excluded_by_default(self): + @workflow + async def wf(x: int) -> int: + return x + + result = await wf.run(1) + status_in_list = [e for e in result if e.type == "status"] + assert len(status_in_list) == 0 + + async def test_status_events_included_when_requested(self): + @workflow + async def wf(x: int) -> int: + return x + + result = await wf.run(1, include_status_events=True) + status_in_list = [e for e in result if e.type == "status"] + assert len(status_in_list) > 0 + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + async def test_workflow_with_no_tasks(self): + @workflow + async def no_tasks(x: int) -> int: + return x * 2 + + result = await no_tasks.run(5) + assert result.get_outputs() == [10] + + async def test_workflow_with_no_output(self): + @workflow + async def silent_wf(x: int) -> None: + pass # returns None — no output emitted + + result = await silent_wf.run(5) + assert result.get_outputs() == [] + + async def test_return_value_auto_yields_output(self): + """Returning a non-None value automatically emits it as an output.""" + + @workflow + async def wf(x: int) -> int: + return x * 3 + + result = await wf.run(5) + assert result.get_outputs() == [15] + + async def test_step_called_multiple_times(self): + @workflow + async def wf(x: int) -> int: + a = await add_one(x) + b = await add_one(a) + return await add_one(b) + + result = await wf.run(0) + assert result.get_outputs() == [3] # 0+1+1+1 + + # Should have 3 invoked and 3 completed events for add_one + invoked = [e for e in result if e.type == "executor_invoked"] + completed = [e for e in result if e.type == "executor_completed"] + assert len(invoked) == 3 + assert len(completed) == 3 + + +# --------------------------------------------------------------------------- +# Recovery after errors +# --------------------------------------------------------------------------- + + +class TestRecoveryAfterErrors: + async def test_run_after_failure_is_allowed(self): + @workflow + async def wf(x: int) -> int: + if x == 1: + raise RuntimeError("boom") + return x + + with pytest.raises(RuntimeError, match="boom"): + await wf.run(1) + + # Must be able to run again after the failure + result = await wf.run(2) + assert result.get_outputs() == [2] + + async def test_step_sync_function_raises(self): + with pytest.raises(TypeError, match="async functions"): + + @step # pyright: ignore[reportArgumentType] + def not_async(x: int) -> int: # pyright: ignore[reportUnusedFunction] + return x + + +# --------------------------------------------------------------------------- +# WorkflowInterrupted is BaseException +# --------------------------------------------------------------------------- + + +class TestWorkflowInterruptedIsBaseException: + async def test_except_exception_does_not_catch_interrupt(self): + """User code with ``except Exception`` should not catch WorkflowInterrupted.""" + caught = False + + @workflow + async def wf(x: int, ctx: RunContext) -> str: + nonlocal caught + try: + return await ctx.request_info("need review", response_type=str, request_id="r1") + except Exception: + # This should NOT catch WorkflowInterrupted + caught = True + return "caught!" + + result = await wf.run("data") + # Should have a pending request, NOT "caught!" + assert result.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + assert result.get_outputs() == [] + assert caught is False + + +# --------------------------------------------------------------------------- +# Checkpoint validation +# --------------------------------------------------------------------------- + + +class TestCheckpointValidation: + async def test_checkpoint_signature_mismatch_raises(self): + from agent_framework import WorkflowCheckpoint + + storage = InMemoryCheckpointStorage() + + @workflow(name="my_wf", checkpoint_storage=storage) + async def wf(x: int) -> int: + return x + + # Manually create a checkpoint with a different signature hash + bad_checkpoint = WorkflowCheckpoint( + workflow_name="my_wf", + graph_signature_hash="totally_different_hash", + state={"_step_cache": {}, "_original_message": 1}, + ) + ckpt_id = await storage.save(bad_checkpoint) + + # Should fail due to hash mismatch + with pytest.raises(ValueError, match="not compatible"): + await wf.run(checkpoint_id=ckpt_id) + + async def test_import_step_cache_malformed_key(self): + ctx = _RunContext("test") + with pytest.raises(ValueError, match="Corrupted step cache"): + ctx._import_step_cache({"invalid_key_no_separator": 42}) # pyright: ignore[reportPrivateUsage] + + async def test_import_step_cache_non_integer_index(self): + ctx = _RunContext("test") + with pytest.raises(ValueError, match="Corrupted step cache"): + ctx._import_step_cache({"step_name::abc": 42}) # pyright: ignore[reportPrivateUsage] + + +# --------------------------------------------------------------------------- +# executor_bypassed event on replay (review comment #3) +# --------------------------------------------------------------------------- + + +class TestExecutorBypassed: + async def test_cached_step_emits_bypassed_event(self): + """When a step replays from cache, it should emit executor_bypassed.""" + storage = InMemoryCheckpointStorage() + call_count = 0 + + @step + async def tracked(x: int) -> int: + nonlocal call_count + call_count += 1 + return x + 1 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await tracked(x) + + # First run — live execution + result1 = await wf.run(5) + assert result1.get_outputs() == [6] + assert call_count == 1 + + event_types1 = [e.type for e in result1] + assert "executor_invoked" in event_types1 + assert "executor_completed" in event_types1 + assert "executor_bypassed" not in event_types1 + + # Restore from checkpoint — cached replay + ckpt_id = (await storage.list_checkpoints(workflow_name="wf"))[-1].checkpoint_id + result2 = await wf.run(checkpoint_id=ckpt_id) + assert result2.get_outputs() == [6] + assert call_count == 1 # not called again + + event_types2 = [e.type for e in result2] + assert "executor_bypassed" in event_types2 + # Should NOT have the live-execution pair + assert "executor_invoked" not in event_types2 + assert "executor_completed" not in event_types2 + + async def test_bypassed_event_carries_cached_data(self): + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x * 10 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await compute(x) + + await wf.run(3) + ckpt_id = (await storage.list_checkpoints(workflow_name="wf"))[-1].checkpoint_id + + result = await wf.run(checkpoint_id=ckpt_id) + bypassed = [e for e in result if e.type == "executor_bypassed"] + assert len(bypassed) == 1 + assert bypassed[0].executor_id == "compute" + assert bypassed[0].data == 30 + + +# --------------------------------------------------------------------------- +# request_info inside @step (review comment #1) +# --------------------------------------------------------------------------- + + +class TestRequestInfoInStep: + async def test_step_with_run_context_injection(self): + """A @step function with a RunContext parameter gets it auto-injected.""" + + @step + async def review_step(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="s1") + return f"reviewed: {feedback}" + + @workflow + async def wf(doc: str) -> str: + return await review_step(doc) + + # Phase 1: should interrupt + result1 = await wf.run("my doc") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + assert len(result1.get_request_info_events()) == 1 + assert result1.get_request_info_events()[0].request_id == "s1" + + # Phase 2: resume + result2 = await wf.run(responses={"s1": "LGTM"}) + assert result2.get_outputs() == ["reviewed: LGTM"] + + async def test_step_works_outside_workflow_with_explicit_ctx(self): + """Outside a workflow, the step is transparent — caller provides ctx.""" + + @step + async def needs_ctx(data: str, ctx: RunContext) -> str: + val = ctx.get_state("key", "default") + return f"{data}:{val}" + + # Outside a workflow, pass through directly — caller supplies ctx + ctx = RunContext("test") + ctx.set_state("key", "hello") + result = await needs_ctx("data", ctx) + assert result == "data:hello" + + async def test_step_injects_ctx_before_user_positional_parameters(self): + """RunContext injection should not conflict when ctx is the first step parameter.""" + + @step + async def needs_ctx_first(ctx: RunContext, data: str) -> str: + ctx.set_state("seen", data) + return f"{data}:{ctx.get_state('seen')}" + + @workflow + async def wf(data: str) -> str: + return await needs_ctx_first(data) + + result = await wf.run("draft") + + assert result.get_outputs() == ["draft:draft"] + + async def test_get_run_context_inside_workflow(self): + """get_run_context() returns the active RunContext inside a workflow.""" + from agent_framework import get_run_context + + captured_ctx = None + + @step + async def capture_ctx(x: int) -> int: + nonlocal captured_ctx + captured_ctx = get_run_context() + return x + + @workflow + async def wf(x: int) -> int: + return await capture_ctx(x) + + await wf.run(1) + assert captured_ctx is not None + assert isinstance(captured_ctx, RunContext) + + async def test_get_run_context_outside_workflow(self): + """get_run_context() returns None outside a workflow.""" + from agent_framework import get_run_context + + assert get_run_context() is None + + +# --------------------------------------------------------------------------- +# None response handling (review comment #2) +# --------------------------------------------------------------------------- + + +class TestNoneResponseHandling: + async def test_none_response_logs_warning(self): + """Providing None as a response value should log a warning.""" + + @workflow + async def wf(doc: str, ctx: RunContext) -> str: + val = await ctx.request_info("need input", response_type=str, request_id="r1") + return f"got: {val}" + + # Phase 1 + await wf.run("start") + + # Phase 2: resume with None response — should warn but still work + with caplog_context(logging.getLogger("agent_framework._workflows._functional")) as logs: + result = await wf.run(responses={"r1": None}) + + assert result.get_outputs() == ["got: None"] + assert any("None" in msg and "r1" in msg for msg in logs) + + async def test_none_response_is_returned(self): + """None is a valid (if discouraged) response value.""" + + @workflow + async def wf(x: int, ctx: RunContext) -> str: + val = await ctx.request_info("need data", response_type=str, request_id="r1") + return f"value={val}" + + await wf.run(1) + result = await wf.run(responses={"r1": None}) + assert result.get_outputs() == ["value=None"] + + +# Helper for capturing log messages + + +@contextmanager +def caplog_context(target_logger: logging.Logger) -> Iterator[list[str]]: + """Capture log messages from a specific logger.""" + messages: list[str] = [] + + class _Handler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + messages.append(self.format(record)) + + handler = _Handler() + handler.setLevel(logging.WARNING) + target_logger.addHandler(handler) + try: + yield messages + finally: + target_logger.removeHandler(handler) + + +# --------------------------------------------------------------------------- +# Combined regression tests (cross-cutting review comments #1, #2, #3) +# --------------------------------------------------------------------------- + + +class TestHITLInStepWithCaching: + """Regression tests: request_info inside @step combined with caching and bypass.""" + + async def test_preceding_step_bypassed_on_hitl_resume(self): + """When a step after a completed step calls request_info and interrupts, + resuming should bypass the first step (cached) and re-execute the HITL step.""" + call_count_a = 0 + + @step + async def step_a(x: int) -> int: + nonlocal call_count_a + call_count_a += 1 + return x + 1 + + @step + async def step_b(val: int, ctx: RunContext) -> str: + feedback = await ctx.request_info({"val": val}, response_type=str, request_id="r1") + return f"{val}:{feedback}" + + @workflow + async def wf(x: int) -> str: + a = await step_a(x) + return await step_b(a) + + # Phase 1: step_a completes, step_b interrupts + result1 = await wf.run(5) + assert call_count_a == 1 + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: resume — step_a should be bypassed, step_b re-executes + result2 = await wf.run(responses={"r1": "ok"}) + assert call_count_a == 1 # step_a not called again + assert result2.get_outputs() == ["6:ok"] + + event_types = [e.type for e in result2] + assert "executor_bypassed" in event_types + + async def test_hitl_step_with_checkpoint_full_lifecycle(self): + """Full lifecycle: run -> interrupt -> resume -> checkpoint restore -> all bypassed.""" + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x * 10 + + @step + async def review(val: int, ctx: RunContext) -> str: + feedback = await ctx.request_info({"val": val}, response_type=str, request_id="rev") + return f"reviewed({val}):{feedback}" + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> str: + v = await compute(x) + return await review(v) + + # Phase 1: interrupt + result1 = await wf.run(3) + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: resume + result2 = await wf.run(responses={"rev": "LGTM"}) + assert result2.get_outputs() == ["reviewed(30):LGTM"] + + # Phase 3: restore from latest checkpoint -- both steps should be bypassed + ckpt_id = (await storage.list_checkpoints(workflow_name="wf"))[-1].checkpoint_id + result3 = await wf.run(checkpoint_id=ckpt_id) + assert result3.get_outputs() == ["reviewed(30):LGTM"] + + event_types3 = [e.type for e in result3] + bypassed = [e for e in result3 if e.type == "executor_bypassed"] + assert len(bypassed) == 2 + assert "executor_invoked" not in event_types3 + + async def test_none_response_in_step_request_info(self): + """None response inside a @step request_info should warn and return None.""" + + @step + async def needs_feedback(doc: str, ctx: RunContext) -> str: + val = await ctx.request_info({"doc": doc}, response_type=str, request_id="r1") + return f"got:{val}" + + @workflow + async def wf(doc: str) -> str: + return await needs_feedback(doc) + + await wf.run("draft") + + with caplog_context(logging.getLogger("agent_framework._workflows._functional")) as logs: + result = await wf.run(responses={"r1": None}) + + assert result.get_outputs() == ["got:None"] + assert any("None" in msg and "r1" in msg for msg in logs) + + async def test_step_hitl_does_not_emit_executor_failed(self): + """WorkflowInterrupted from request_info inside a step should NOT emit executor_failed.""" + + @step + async def hitl_step(x: int, ctx: RunContext) -> str: + return await ctx.request_info("need data", response_type=str, request_id="r1") + + @workflow + async def wf(x: int) -> str: + return await hitl_step(x) + + result = await wf.run(1) + event_types = [e.type for e in result] + assert "executor_failed" not in event_types + assert result.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + +# --------------------------------------------------------------------------- +# Regression tests for ultrareview findings +# --------------------------------------------------------------------------- + + +class TestDeterministicAutoRequestId: + """Regression for bug_001: auto-generated request_info ids must be stable across replay.""" + + async def test_auto_request_id_roundtrips_on_resume(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + # No request_id — framework must generate a deterministic one + val = await ctx.request_info("need data", response_type=str) + return f"got:{val}" + + result1 = await wf.run(1) + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + requests = result1.get_request_info_events() + assert len(requests) == 1 + rid = requests[0].request_id + assert rid # non-empty + + # Resume with the id the caller just received. + result2 = await wf.run(responses={rid: "hello"}) + assert result2.get_final_state() == WorkflowRunState.IDLE + assert result2.get_outputs() == ["got:hello"] + + async def test_multiple_auto_ids_are_distinct_and_stable(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + a = await ctx.request_info("first", response_type=str) + b = await ctx.request_info("second", response_type=str) + return f"{a}/{b}" + + r1 = await wf.run(1) + rid1 = r1.get_request_info_events()[0].request_id + r2 = await wf.run(responses={rid1: "A"}) + rid2 = r2.get_request_info_events()[0].request_id + assert rid1 != rid2 + r3 = await wf.run(responses={rid1: "A", rid2: "B"}) + assert r3.get_outputs() == ["A/B"] + + async def test_cached_step_advances_auto_request_id_counter(self): + call_count = 0 + + @step + async def first_review(value: int, ctx: RunContext) -> str: + nonlocal call_count + call_count += 1 + return await ctx.request_info({"step": "first", "value": value}, response_type=str) + + @step + async def second_review(value: int, ctx: RunContext) -> str: + return await ctx.request_info({"step": "second", "value": value}, response_type=str) + + @workflow + async def wf(value: int) -> str: + first = await first_review(value) + second = await second_review(value) + return f"{first}/{second}" + + first_run = await wf.run(1) + first_request_id = first_run.get_request_info_events()[0].request_id + assert first_request_id == "auto::0" + + second_run = await wf.run(responses={first_request_id: "A"}) + second_request_id = second_run.get_request_info_events()[0].request_id + assert second_request_id == "auto::1" + completed_call_count = call_count + + final_run = await wf.run(responses={first_request_id: "A", second_request_id: "B"}) + + assert call_count == completed_call_count + assert final_run.get_outputs() == ["A/B"] + + +class TestPendingRequestsPruned: + """Regression for bug_007: resolved requests must be pruned from _pending_requests.""" + + async def test_final_checkpoint_no_longer_claims_resolved_requests_pending(self): + storage = InMemoryCheckpointStorage() + + @workflow(checkpoint_storage=storage) + async def wf(x: int, ctx: RunContext) -> str: + a = await ctx.request_info("q1", response_type=str, request_id="r1") + b = await ctx.request_info("q2", response_type=str, request_id="r2") + return f"{a}/{b}" + + await wf.run(1) + await wf.run(responses={"r1": "A"}) + result = await wf.run(responses={"r1": "A", "r2": "B"}) + assert result.get_final_state() == WorkflowRunState.IDLE + # Latest checkpoint must show no pending requests. + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert checkpoints, "expected at least one checkpoint to have been saved" + final = checkpoints[-1] + assert final.pending_request_info_events == {} + + +class TestArityValidation: + """Regression for merged_bug_003: validate workflow signature arity.""" + + def test_multi_non_ctx_param_rejected_at_decoration(self): + with pytest.raises(ValueError, match="multiple non-RunContext parameters"): + + @workflow + async def wf(a: str, b: str, ctx: RunContext) -> str: + return f"{a}+{b}" + + async def test_ctx_only_workflow_with_message_raises_clear_error(self): + @workflow + async def wf(ctx: RunContext) -> str: + return "no message used" + + with pytest.raises(ValueError, match="no non-RunContext parameter"): + await wf.run("important input") + + def test_ctx_only_workflow_decoration_succeeds(self): + # Decoration must not raise even though the workflow has no + # message-receiving parameter. (Running it without a message still + # requires providing responses or a checkpoint_id — that's + # _validate_run_params's job, not ours.) + @workflow + async def wf(ctx: RunContext) -> str: + return "ok" + + assert wf is not None + + +class TestStaleResponsesRejected: + """Regression for bug_014: stale responses after clean completion must be rejected.""" + + async def test_responses_after_clean_completion_raise(self): + @workflow + async def wf(x: int) -> int: + return x * 2 + + await wf.run(5) # clean completion, no pending requests + with pytest.raises(ValueError, match="no pending request_info"): + await wf.run(responses={"stale": "x"}) + + async def test_responses_mismatched_key_raises(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + return await ctx.request_info("q", response_type=str, request_id="r1") + + await wf.run(1) # interrupts with r1 pending + with pytest.raises(ValueError, match="do not answer"): + await wf.run(responses={"definitely_not_r1": "x"}) + + +class TestReservedStateKeys: + """Regression for bug_017: set_state must reject underscore-prefixed keys.""" + + async def test_underscore_key_rejected(self): + @workflow + async def wf(x: int, ctx: RunContext) -> int: + ctx.set_state("_private", "user value") + return x + + with pytest.raises(ValueError, match="reserved for framework"): + await wf.run(1) + + async def test_normal_key_still_works(self): + @workflow + async def wf(x: int, ctx: RunContext) -> int: + ctx.set_state("normal_key", "v") + assert ctx.get_state("normal_key") == "v" + return x + + r = await wf.run(1) + assert r.get_outputs() == [1] + + +class TestDeepcopyOnCacheHit: + """Regression for bug_002: cache hits must not deepcopy args.""" + + async def test_step_with_non_deepcopyable_arg_replays(self): + import threading + + @step + async def takes_lock(lock: threading.Lock, n: int) -> int: + return n + 1 + + @workflow + async def wf(x: int) -> int: + lock = threading.Lock() + return await takes_lock(lock, x) + + # First run — must succeed despite threading.Lock not being deepcopyable + # (deepcopy now wrapped in try/except, falls back to live reference for + # the invocation_data event only). + r1 = await wf.run(5) + assert r1.get_outputs() == [6] + + +class TestStepDiscoveryAttributeAccess: + """Regression for bug_008: checkpoint hash must differ when function body changes.""" + + async def test_signature_hash_changes_when_function_body_changes(self): + @workflow + async def wf_a(x: int) -> int: + return x + 1 + + @workflow(name="wf_b") + async def wf_b(x: int) -> int: + return x * 100 + + # Two different function bodies -> different hashes even though the + # static step-name scan would produce the same empty list. + assert wf_a.graph_signature_hash != wf_b.graph_signature_hash + + +class TestAsAgentSignatureParity: + """Regression for bug_015: as_agent signature must accept description/context_providers.""" + + async def test_as_agent_accepts_description_override(self): + @workflow(description="workflow level") + async def wf(x: str) -> str: + return x.upper() + + agent = wf.as_agent(name="a", description="agent level") + assert agent.description == "agent level" + + async def test_as_agent_accepts_context_providers_kwarg(self): + @workflow + async def wf(x: str) -> str: + return x + + providers = [object()] # opaque placeholder; must be stored without error + agent = wf.as_agent(context_providers=providers) + assert list(agent.context_providers or []) == providers + + async def test_as_agent_description_defaults_to_workflow_description(self): + @workflow(description="from workflow") + async def wf(x: str) -> str: + return x + + agent = wf.as_agent() + assert agent.description == "from workflow" + + +class TestFunctionalWorkflowAgentHITL: + """Regression for bug_013: .as_agent() must surface request_info events.""" + + async def test_request_info_surfaces_as_function_approval_request(self): + @workflow + async def wf(x: str, ctx: RunContext) -> str: + answer = await ctx.request_info({"need": x}, response_type=str, request_id="rid-1") + return f"got:{answer}" + + agent = wf.as_agent() + response = await agent.run("topic") + + # Agent must expose the pending request_id. + assert "rid-1" in agent.pending_requests + + # Response must contain at least one content item whose type is + # function_approval_request (or equivalent). + approval_found = False + for message in response.messages: + for content in message.contents: + if getattr(content, "type", None) == "function_approval_request": + approval_found = True + break + assert approval_found, "expected FunctionApprovalRequestContent in agent response" + + async def test_resume_via_agent_responses_kwarg(self): + @workflow + async def wf(x: str, ctx: RunContext) -> str: + answer = await ctx.request_info(x, response_type=str, request_id="rid-1") + return f"got:{answer}" + + agent = wf.as_agent() + # First phase: suspend + await agent.run("topic") + # Second phase: resume via the agent surface + response = await agent.run(responses={"rid-1": "answered"}) + # Agent's final response should contain the workflow's text output. + text_blobs: list[str] = [] + for message in response.messages: + for content in message.contents: + text = getattr(content, "text", None) + if text: + text_blobs.append(text) + assert any("got:answered" in t for t in text_blobs) + + +class TestRunDocstringAllowsResponsesAndCheckpoint: + """Regression for bug_010: docstring must permit responses+checkpoint_id combo.""" + + def test_docstring_says_at_least_one(self): + doc = FunctionalWorkflow.run.__doc__ or "" + assert "At least one" in doc or "at least one" in doc + assert "Exactly one" not in doc + + +class TestFunctionalWorkflowExperimentalStage: + """Tests for the experimental stage annotations applied to functional workflow APIs.""" + + def test_public_symbols_are_marked_experimental(self) -> None: + symbols = [ + get_run_context, + RunContext, + StepWrapper, + step, + FunctionalWorkflow, + workflow, + FunctionalWorkflowAgent, + ] + + for symbol in symbols: + assert symbol.__feature_stage__ == "experimental" + assert symbol.__feature_id__ == ExperimentalFeature.FUNCTIONAL_WORKFLOWS.value + assert symbol.__doc__ is not None + assert ".. warning:: Experimental" in symbol.__doc__ diff --git a/python/samples/01-get-started/05_functional_workflow_with_agents.py b/python/samples/01-get-started/05_functional_workflow_with_agents.py new file mode 100644 index 0000000000..05e5f2637f --- /dev/null +++ b/python/samples/01-get-started/05_functional_workflow_with_agents.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Functional Workflow with Agents — Call agents inside @workflow + +This sample shows how to call agents inside a functional workflow. +Agent calls are just regular async function calls — no special wrappers needed. +""" + +import asyncio + +from agent_framework import Agent, workflow +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +# +client = FoundryChatClient(credential=AzureCliCredential()) + +writer = Agent( + name="WriterAgent", + instructions="Write a short poem (4 lines max) about the given topic.", + client=client, +) + +reviewer = Agent( + name="ReviewerAgent", + instructions="Review the given poem in one sentence. Is it good?", + client=client, +) +# + + +# +@workflow +async def poem_workflow(topic: str) -> str: + """Write a poem, then review it.""" + poem = (await writer.run(f"Write a poem about: {topic}")).text + review = (await reviewer.run(f"Review this poem: {poem}")).text + return f"Poem:\n{poem}\n\nReview: {review}" +# + + +async def main() -> None: + result = await poem_workflow.run("a cat learning to code") + print(result.get_outputs()[0]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/06_functional_workflow_basics.py b/python/samples/01-get-started/06_functional_workflow_basics.py new file mode 100644 index 0000000000..8033a7ac4e --- /dev/null +++ b/python/samples/01-get-started/06_functional_workflow_basics.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Functional Workflow Basics — Orchestrate async functions with @workflow + +The functional API lets you write workflows as plain Python async functions. +No graph concepts, no edges, no executor classes — just call functions +and use native control flow (if/else, loops, asyncio.gather). + +This sample builds a minimal pipeline with two steps: +1. Convert text to uppercase +2. Reverse the text + +No external services are required. +""" + +import asyncio + +from agent_framework import workflow + + +# Plain async functions — no decorators needed +async def to_upper_case(text: str) -> str: + """Convert input to uppercase.""" + return text.upper() + + +async def reverse_text(text: str) -> str: + """Reverse the string.""" + return text[::-1] + + +# +@workflow +async def text_workflow(text: str) -> str: + """Uppercase the text, then reverse it.""" + upper = await to_upper_case(text) + return await reverse_text(upper) +# + + +async def main() -> None: + # + result = await text_workflow.run("hello world") + print(f"Output: {result.get_outputs()}") + print(f"Final state: {result.get_final_state()}") + # + + """ + Expected output: + Output: ['DLROW OLLEH'] + Final state: WorkflowRunState.IDLE + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/05_first_workflow.py b/python/samples/01-get-started/07_first_graph_workflow.py similarity index 87% rename from python/samples/01-get-started/05_first_workflow.py rename to python/samples/01-get-started/07_first_graph_workflow.py index 74720e529f..2a84fabfab 100644 --- a/python/samples/01-get-started/05_first_workflow.py +++ b/python/samples/01-get-started/07_first_graph_workflow.py @@ -12,9 +12,12 @@ from agent_framework import ( from typing_extensions import Never """ -First Workflow — Chain executors with edges +First Graph Workflow — Chain executors with edges -This sample builds a minimal workflow with two steps: +The graph API gives you full control over execution topology: edges, +fan-out/fan-in, switch/case, and superstep-based checkpointing. + +This sample builds a minimal graph workflow with two steps: 1. Convert text to uppercase (class-based executor) 2. Reverse the text (function-based executor) diff --git a/python/samples/01-get-started/06_host_your_agent.py b/python/samples/01-get-started/08_host_your_agent.py similarity index 100% rename from python/samples/01-get-started/06_host_your_agent.py rename to python/samples/01-get-started/08_host_your_agent.py diff --git a/python/samples/01-get-started/README.md b/python/samples/01-get-started/README.md index 9ecfdf08c9..61aefc6592 100644 --- a/python/samples/01-get-started/README.md +++ b/python/samples/01-get-started/README.md @@ -24,8 +24,10 @@ export FOUNDRY_MODEL="gpt-4o" # optional, defaults to gpt-4o | 2 | [02_add_tools.py](02_add_tools.py) | Define a function tool with `@tool` and attach it to an agent. | | 3 | [03_multi_turn.py](03_multi_turn.py) | Keep conversation history across turns with `AgentSession`. | | 4 | [04_memory.py](04_memory.py) | Add dynamic context with a custom `ContextProvider`. | -| 5 | [05_first_workflow.py](05_first_workflow.py) | Chain executors into a workflow with edges. | -| 6 | [06_host_your_agent.py](06_host_your_agent.py) | Host a single agent with Azure Functions. | +| 5 | [05_functional_workflow_with_agents.py](05_functional_workflow_with_agents.py) | Call agents inside a functional workflow. | +| 6 | [06_functional_workflow_basics.py](06_functional_workflow_basics.py) | Write a workflow as a plain async function. | +| 7 | [07_first_graph_workflow.py](07_first_graph_workflow.py) | Chain executors into a graph workflow with edges. | +| 8 | [08_host_your_agent.py](08_host_your_agent.py) | Host a single agent with Azure Functions. | Run any sample with: diff --git a/python/samples/03-workflows/README.md b/python/samples/03-workflows/README.md index ae3292a07a..15d224563f 100644 --- a/python/samples/03-workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -30,6 +30,20 @@ Once comfortable with these, explore the rest of the samples below. ## Samples Overview (by directory) +### functional + +Write workflows as plain Python async functions — no graph concepts, no executor classes, no edges. Use native control flow (`if`/`else`, loops, `asyncio.gather`) for branching and parallelism. + +| Sample | File | Concepts | +|---|---|---| +| Basic Pipeline | [functional/basic_pipeline.py](./functional/basic_pipeline.py) | Sequential steps as plain async functions | +| Basic Streaming Pipeline | [functional/basic_streaming_pipeline.py](./functional/basic_streaming_pipeline.py) | Stream workflow events in real time with `run(stream=True)` | +| Parallel Pipeline | [functional/parallel_pipeline.py](./functional/parallel_pipeline.py) | Fan-out/fan-in with `asyncio.gather` | +| Steps and Checkpointing | [functional/steps_and_checkpointing.py](./functional/steps_and_checkpointing.py) | `@step` decorator for per-step checkpointing and observability | +| Human-in-the-Loop Review | [functional/hitl_review.py](./functional/hitl_review.py) | HITL with `ctx.request_info()` and replay | +| Agent Integration | [functional/agent_integration.py](./functional/agent_integration.py) | Calling agents inside workflow steps | +| Naive Group Chat | [functional/naive_group_chat.py](./functional/naive_group_chat.py) | Simple round-robin group chat as a plain loop | + ### agents | Sample | File | Concepts | diff --git a/python/samples/03-workflows/functional/agent_integration.py b/python/samples/03-workflows/functional/agent_integration.py new file mode 100644 index 0000000000..b5911cb690 --- /dev/null +++ b/python/samples/03-workflows/functional/agent_integration.py @@ -0,0 +1,107 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Calling agents inside functional workflows. + +Agent calls work inside @workflow as plain function calls — no decorator needed. +Just call the agent and use the result. + +If you want per-step caching (so agent calls don't re-execute on HITL resume +or crash recovery), add @step. Since each agent call hits an LLM API (time + +money), @step is often worth it. But it's always opt-in. + +This sample shows both approaches side-by-side so you can see the difference. +""" + +import asyncio + +from agent_framework import Agent, step, workflow +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +# --------------------------------------------------------------------------- +# Create agents +# --------------------------------------------------------------------------- + +client = FoundryChatClient(credential=AzureCliCredential()) + +classifier_agent = Agent( + name="ClassifierAgent", + instructions=( + "Classify documents into one category: Technical, Legal, Marketing, or Scientific. " + "Reply with only the category name." + ), + client=client, +) + +writer_agent = Agent( + name="WriterAgent", + instructions="Summarize the given content in one sentence.", + client=client, +) + +reviewer_agent = Agent( + name="ReviewerAgent", + instructions="Review the given summary in one sentence. Is it accurate and complete?", + client=client, +) + +# --------------------------------------------------------------------------- +# Simplest approach: call agents directly inside the workflow. +# No @step, no wrappers — just plain function calls. +# --------------------------------------------------------------------------- + + +@workflow +async def simple_pipeline(document: str) -> str: + """Process a document — agents called inline, no @step.""" + classification = (await classifier_agent.run(f"Classify this document: {document}")).text + summary = (await writer_agent.run(f"Summarize: {document}")).text + review = (await reviewer_agent.run(f"Review this summary: {summary}")).text + + return f"Classification: {classification}\nSummary: {summary}\nReview: {review}" + + +# --------------------------------------------------------------------------- +# With @step: agent results are cached. On HITL resume or checkpoint +# recovery, completed steps return their saved result instead of calling +# the LLM again. Worth it for expensive operations. +# --------------------------------------------------------------------------- + + +@step +async def classify_document(doc: str) -> str: + return (await classifier_agent.run(f"Classify this document: {doc}")).text + + +@step +async def generate_summary(doc: str) -> str: + return (await writer_agent.run(f"Summarize: {doc}")).text + + +@step +async def review_summary(summary: str) -> str: + return (await reviewer_agent.run(f"Review this summary: {summary}")).text + + +@workflow +async def cached_pipeline(document: str) -> str: + """Same pipeline, but @step caches each agent call.""" + classification = await classify_document(document) + summary = await generate_summary(document) + review = await review_summary(summary) + + return f"Classification: {classification}\nSummary: {summary}\nReview: {review}" + + +async def main(): + # Simple version — agents called inline + result = await simple_pipeline.run("This is a technical document about machine learning...") + print(result.get_outputs()[0]) + + # Cached version — same result, but steps won't re-execute on resume + result = await cached_pipeline.run("This is a technical document about machine learning...") + print(f"\nCached: {result.get_outputs()[0]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/basic_pipeline.py b/python/samples/03-workflows/functional/basic_pipeline.py new file mode 100644 index 0000000000..81514da53a --- /dev/null +++ b/python/samples/03-workflows/functional/basic_pipeline.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Basic sequential pipeline using the functional workflow API. + +The simplest possible workflow: plain async functions orchestrated by @workflow. +No @step decorator needed — just write Python. +""" + +import asyncio + +from agent_framework import workflow + + +# These are plain async functions — no decorators needed. +# They run normally inside the workflow, just like any other Python function. +async def fetch_data(url: str) -> dict[str, str | int]: + """Simulate fetching data from a URL.""" + return {"url": url, "content": f"Data from {url}", "status": 200} + + +async def transform_data(data: dict[str, str | int]) -> str: + """Transform raw data into a summary string.""" + return f"[{data['status']}] {data['content']}" + + +# @workflow turns this async function into a FunctionalWorkflow object. +# Without it, this is just a normal async function. With it, you get: +# - .run() that returns a WorkflowRunResult with events and outputs +# - .run(stream=True) for streaming events in real time +# - .as_agent() to use this workflow anywhere an agent is expected +# +# The function's first parameter receives the input from .run("..."). +# Add a `ctx: RunContext` parameter only if you need HITL, state, or custom events. +@workflow +async def data_pipeline(url: str) -> str: + """A simple sequential data pipeline.""" + raw = await fetch_data(url) + summary = await transform_data(raw) + + # This is just a function — plain Python works between calls. + # No need to wrap every operation in a separate async function. + is_valid = len(summary) > 0 and "[200]" in summary + tag = "VALID" if is_valid else "INVALID" + + # Returning a value automatically emits it as an output. + # Callers retrieve it via result.get_outputs(). + return f"[{tag}] {summary}" + + +async def main(): + # .run() is provided by @workflow — a plain async function wouldn't have it + result = await data_pipeline.run("https://example.com/api/data") + print("Output:", result.get_outputs()[0]) + print("State:", result.get_final_state()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/basic_streaming_pipeline.py b/python/samples/03-workflows/functional/basic_streaming_pipeline.py new file mode 100644 index 0000000000..4ee61da60f --- /dev/null +++ b/python/samples/03-workflows/functional/basic_streaming_pipeline.py @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Basic streaming pipeline using the functional workflow API. + +Stream workflow events in real time with run(stream=True). +""" + +import asyncio + +from agent_framework import workflow + + +# Plain async functions — no decorators needed for simple helpers. +async def fetch_data(url: str) -> dict[str, str | int]: + """Simulate fetching data from a URL.""" + return {"url": url, "content": f"Data from {url}", "status": 200} + + +async def transform_data(data: dict[str, str | int]) -> str: + """Transform raw data into a summary string.""" + return f"[{data['status']}] {data['content']}" + + +async def validate_result(summary: str) -> bool: + """Validate the transformed result.""" + return len(summary) > 0 and "[200]" in summary + + +# @workflow enables .run(stream=True), which returns a ResponseStream +# you can iterate over with `async for`. Without @workflow, you'd just +# have a normal async function with no streaming capability. +@workflow +async def data_pipeline(url: str) -> str: + """A simple sequential data pipeline.""" + raw = await fetch_data(url) + summary = await transform_data(raw) + is_valid = await validate_result(summary) + + return f"{summary} (valid={is_valid})" + + +async def main(): + # run(stream=True) returns a ResponseStream that yields events as they + # are produced. The raw stream includes lifecycle events (started, status) + # alongside application events — filter by event.type to find what you need. + stream = data_pipeline.run("https://example.com/api/data", stream=True) + async for event in stream: + if event.type == "output": + print(f"Output: {event.data}") + + # After iteration, get_final_response() returns the WorkflowRunResult + result = await stream.get_final_response() + print(f"Final state: {result.get_final_state()}") + + """ + Expected output: + Output: [200] Data from https://example.com/api/data (valid=True) + Final state: WorkflowRunState.IDLE + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/hitl_review.py b/python/samples/03-workflows/functional/hitl_review.py new file mode 100644 index 0000000000..39f2dae885 --- /dev/null +++ b/python/samples/03-workflows/functional/hitl_review.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Human-in-the-loop review pipeline using functional workflows. + +Demonstrates ctx.request_info() for pausing the workflow to wait for +external input and resuming with run(responses={...}). + +HITL works with or without @step. The difference is what happens on resume: +- Without @step: every function re-executes from the top (fine for cheap calls). +- With @step: completed functions return their saved result instantly. + +This sample uses @step on write_draft() because it simulates an expensive +operation that shouldn't re-run just because the workflow was paused. +""" + +import asyncio + +from agent_framework import RunContext, WorkflowRunState, step, workflow + + +# @step saves the result. When the workflow resumes after the HITL pause, +# this returns its saved result instead of running the expensive operation again. +# +# In a real workflow you might call an agent here instead: +# @step +# async def write_draft(topic: str) -> str: +# return (await writer_agent.run(f"Write a draft about: {topic}")).text +@step +async def write_draft(topic: str) -> str: + """Simulate writing a draft — expensive, shouldn't re-run on resume.""" + print(f" write_draft executing for '{topic}'") + return f"Draft document about '{topic}': Lorem ipsum dolor sit amet..." + + +@step +async def revise_draft(draft: str, feedback: str) -> str: + """Revise the draft based on feedback.""" + return f"Revised: {draft[:50]}... [Applied feedback: {feedback}]" + + +@workflow +async def review_pipeline(topic: str, ctx: RunContext) -> str: + """Write a draft, get human review, then revise.""" + draft = await write_draft(topic) + + # ctx.request_info() suspends the workflow here. The caller gets back + # a WorkflowRunResult with state IDLE_WITH_PENDING_REQUESTS and can + # inspect the pending request via result.get_request_info_events(). + feedback = await ctx.request_info( + {"draft": draft, "instructions": "Please review this draft"}, + response_type=str, + request_id="review_request", + ) + + # This only executes after the caller resumes with run(responses={...}). + # write_draft above returns its saved result (thanks to @step), + # request_info returns the provided response, and we continue here. + return await revise_draft(draft, feedback) + + +async def main(): + # Phase 1: Run until the workflow pauses for human input + print("=== Phase 1: Initial run ===") + result1 = await review_pipeline.run("AI Safety") + + # If request_info() was reached, the state is IDLE_WITH_PENDING_REQUESTS. + # If the workflow completed without hitting request_info(), it would be IDLE. + print(f"State: {(final_state := result1.get_final_state())}") + assert final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + requests = result1.get_request_info_events() + print(f"Pending request: {requests[0].request_id}") + + # Phase 2: Resume with the human's response + print("\n=== Phase 2: Resume with feedback ===") + print("(write_draft should NOT execute again — saved by @step)") + result2 = await review_pipeline.run(responses={"review_request": "Add more details about alignment research"}) + + print(f"State: {result2.get_final_state()}") + print(f"Output: {result2.get_outputs()[0]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/naive_group_chat.py b/python/samples/03-workflows/functional/naive_group_chat.py new file mode 100644 index 0000000000..45a3266049 --- /dev/null +++ b/python/samples/03-workflows/functional/naive_group_chat.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Naive group chat using the functional workflow API. + +A simple round-robin group chat where agents take turns responding. +Because it's just a function, you control the loop, the turn order, +and the termination condition with plain Python — no framework abstractions. + +Compare this with the graph-based GroupChat orchestration to see how the +functional API lets you start simple and add complexity only when needed. +""" + +import asyncio + +from agent_framework import Agent, Message, workflow +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +# --------------------------------------------------------------------------- +# Create agents +# --------------------------------------------------------------------------- + +client = FoundryChatClient(credential=AzureCliCredential()) + +expert = Agent( + name="PythonExpert", + instructions=( + "You are a Python expert in a group discussion. " + "Answer questions about Python and refine your answer based on feedback. " + "Keep responses concise (2-3 sentences)." + ), + client=client, +) + +critic = Agent( + name="Critic", + instructions=( + "You are a constructive critic in a group discussion. " + "Point out edge cases, gotchas, or missing nuances in the previous answer. " + "If the answer is solid, say so briefly." + ), + client=client, +) + +summarizer = Agent( + name="Summarizer", + instructions=( + "You are a summarizer in a group discussion. " + "After the discussion, provide a final concise summary that incorporates " + "the expert's answer and the critic's feedback. Keep it to 2-3 sentences." + ), + client=client, +) + +# --------------------------------------------------------------------------- +# A naive group chat is just a loop — no special framework needed +# --------------------------------------------------------------------------- + + +@workflow +async def group_chat(question: str) -> str: + """Round-robin group chat: expert answers, critic reviews, summarizer wraps up.""" + participants = [expert, critic, summarizer] + # Passing list[Message] keeps roles/authorship intact between turns, + # instead of stringifying everything into a single prompt. + conversation: list[Message] = [Message("user", [question])] + + # Simple round-robin: each agent sees the full conversation so far + for agent in participants: + response = await agent.run(conversation) + conversation.extend(response.messages) + + return "\n\n".join(f"{m.author_name or m.role}: {m.text}" for m in conversation) + + +async def main(): + result = await group_chat.run("What's the difference between a list and a tuple in Python?") + print(result.get_outputs()[0]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/parallel_pipeline.py b/python/samples/03-workflows/functional/parallel_pipeline.py new file mode 100644 index 0000000000..21fd8dceac --- /dev/null +++ b/python/samples/03-workflows/functional/parallel_pipeline.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Parallel pipeline using asyncio.gather with functional workflows. + +Fan-out/fan-in uses native Python concurrency via asyncio.gather. +No @step needed — still just plain async functions. +""" + +import asyncio + +from agent_framework import workflow + + +# Plain async functions — asyncio.gather handles the concurrency, +# no framework primitives needed for parallelism. +async def research_web(topic: str) -> str: + """Simulate web research.""" + await asyncio.sleep(0.05) + return f"Web results for '{topic}': 10 articles found" + + +async def research_papers(topic: str) -> str: + """Simulate academic paper search.""" + await asyncio.sleep(0.05) + return f"Papers on '{topic}': 3 relevant papers" + + +async def research_news(topic: str) -> str: + """Simulate news search.""" + await asyncio.sleep(0.05) + return f"News about '{topic}': 5 recent articles" + + +async def synthesize(sources: list[str]) -> str: + """Combine research results into a summary.""" + return "Research Summary:\n" + "\n".join(f" - {s}" for s in sources) + + +# @workflow wraps the orchestration logic so you get .run(), streaming, +# and events. The functions it calls are plain Python — no decorators +# needed just because they're inside a workflow. +@workflow +async def research_pipeline(topic: str) -> str: + """Fan-out to three research tasks, then synthesize results.""" + # asyncio.gather runs all three concurrently — this is standard Python, + # not a framework concept. Use it the same way you would anywhere else. + # + # Tip: if any of these were wrapped with @step (e.g. an expensive agent call), + # the pattern is identical — @step composes with asyncio.gather, so each + # branch is independently cached on HITL resume or checkpoint restore. + web, papers, news = await asyncio.gather( + research_web(topic), + research_papers(topic), + research_news(topic), + ) + + return await synthesize([web, papers, news]) + + +async def main(): + result = await research_pipeline.run("AI agents") + print(result.get_outputs()[0]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/steps_and_checkpointing.py b/python/samples/03-workflows/functional/steps_and_checkpointing.py new file mode 100644 index 0000000000..93a9423df0 --- /dev/null +++ b/python/samples/03-workflows/functional/steps_and_checkpointing.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Introducing @step: per-step checkpointing and observability. + +The previous samples used plain functions — and that works. Workflows support +HITL (ctx.request_info) and checkpointing regardless of whether you use @step. + +The difference: without @step, a resumed workflow re-executes every function +call from the top. That's fine for cheap functions. But for expensive operations +(API calls, agent runs, etc.) you don't want to pay that cost again. + +@step saves each function's result so it skips re-execution on resume: +- On HITL resume, completed steps return their saved result instantly. +- On crash recovery from a checkpoint, earlier step results are restored. +- Each step emits executor_invoked/executor_completed events for observability. + +@step is opt-in. Plain functions still work alongside @step in the same workflow. +""" + +import asyncio + +from agent_framework import InMemoryCheckpointStorage, step, workflow + +# Track call counts to show which functions actually execute on resume +fetch_calls = 0 +transform_calls = 0 + + +# @step saves this function's result. On resume, it returns the saved +# result instead of re-executing — useful because this is expensive. +@step +async def fetch_data(url: str) -> dict[str, str | int]: + """Expensive operation — @step prevents re-execution on resume.""" + global fetch_calls + fetch_calls += 1 + print(f" fetch_data called (call #{fetch_calls})") + return {"url": url, "content": f"Data from {url}", "status": 200} + + +@step +async def transform_data(data: dict[str, str | int]) -> str: + """Another expensive operation — @step saves the result.""" + global transform_calls + transform_calls += 1 + print(f" transform_data called (call #{transform_calls})") + return f"[{data['status']}] {data['content']}" + + +# No @step — this is cheap, so it just re-runs on resume. That's fine. +async def validate_result(summary: str) -> bool: + """Cheap validation — no @step needed.""" + return len(summary) > 0 and "[200]" in summary + + +storage = InMemoryCheckpointStorage() + + +# checkpoint_storage tells @workflow where to persist step results. +# Each @step saves a checkpoint after it completes. +@workflow(checkpoint_storage=storage) +async def data_pipeline(url: str) -> str: + """Mix of @step functions and plain functions.""" + raw = await fetch_data(url) + summary = await transform_data(raw) + is_valid = await validate_result(summary) + + return f"{summary} (valid={is_valid})" + + +async def main(): + # --- Run 1: Everything executes normally --- + print("=== Run 1: Fresh execution ===") + result = await data_pipeline.run("https://example.com/api/data") + print(f"Output: {result.get_outputs()[0]}") + print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}") + + # @step functions emit executor events; plain functions don't. + print("\nEvents:") + for event in result: + if event.type in ("executor_invoked", "executor_completed"): + print(f" {event.type}: {event.executor_id}") + + # --- Run 2: Restore from checkpoint --- + # The workflow re-executes, but @step functions return saved results. + # Only validate_result() (no @step) actually runs again. + print("\n=== Run 2: Restored from checkpoint ===") + latest = await storage.get_latest(workflow_name="data_pipeline") + assert latest is not None + + result2 = await data_pipeline.run(checkpoint_id=latest.checkpoint_id) + print(f"Output: {result2.get_outputs()[0]}") + print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}") + print("(call counts unchanged — @step results were restored from checkpoint)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/README.md b/python/samples/README.md index 953d9ff9bb..0ff8563933 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -20,8 +20,10 @@ Start with `01-get-started/` and work through the numbered files: 2. **[02_add_tools.py](./01-get-started/02_add_tools.py)** — Add function tools with `@tool` 3. **[03_multi_turn.py](./01-get-started/03_multi_turn.py)** — Multi-turn conversations with `AgentSession` 4. **[04_memory.py](./01-get-started/04_memory.py)** — Agent memory with `ContextProvider` -5. **[05_first_workflow.py](./01-get-started/05_first_workflow.py)** — Build a workflow with executors and edges -6. **[06_host_your_agent.py](./01-get-started/06_host_your_agent.py)** — Host your agent via Azure Functions +5. **[05_functional_workflow_with_agents.py](./01-get-started/05_functional_workflow_with_agents.py)** — Call agents inside a functional workflow +6. **[06_functional_workflow_basics.py](./01-get-started/06_functional_workflow_basics.py)** — Write a workflow as a plain async function +7. **[07_first_graph_workflow.py](./01-get-started/07_first_graph_workflow.py)** — Build a workflow with executors and edges +8. **[08_host_your_agent.py](./01-get-started/08_host_your_agent.py)** — Host your agent via Azure Functions ## Prerequisites From 7b70f80036bcc9a4138bb321249bd4d7983f803d Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:59:14 -0700 Subject: [PATCH 09/40] Python: Surface `oauth_consent_request` events from Responses API in Foundry clients (#5070) * Fix Foundry clients not surfacing oauth_consent_request events (#5054) Override _parse_chunk_from_openai in both RawFoundryChatClient and RawFoundryAgentChatClient to intercept response.output_item.added events with item.type == 'oauth_consent_request'. The consent link is validated (HTTPS required) and converted to Content.from_oauth_consent_request, which the AG-UI layer already knows how to emit as a CUSTOM event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #5054 OAuth consent parsing - Extract shared helper (try_parse_oauth_consent_event) to avoid duplicated logic between RawFoundryChatClient and RawFoundryAgentChatClient - Use urllib.parse.urlparse() for HTTPS validation instead of case-sensitive startswith check - Sanitize log messages to avoid leaking consent_link tokens; log only item id - Add model=self.model to ChatResponseUpdate to match parent behavior - Add assertions on role, raw_representation, and model in happy-path tests - Add test for empty-string consent_link - Add test verifying non-oauth events delegate to super() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle response.oauth_consent_requested top-level event (#5054) Add support for the top-level response.oauth_consent_requested stream event in addition to the response.output_item.added variant. The service may emit either form; handle both so the consent link is reliably surfaced. Extract _validate_consent_link helper within _oauth_helpers.py to reduce nesting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * Address review feedback: defensive getattr and dedicated helper tests (#5054) - Use getattr(event, 'type', None) in try_parse_oauth_consent_event for defensive access against malformed events without a type attribute - Add test_oauth_helpers.py with unit tests for _validate_consent_link and try_parse_oauth_consent_event covering edge cases: - HTTPS URL with empty netloc (https:///path) - Warning log messages for rejected consent links - Event objects missing 'type' attribute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * Fix mypy: match _parse_chunk_from_openai signature with superclass Add seen_reasoning_delta_item_ids parameter to _parse_chunk_from_openai overrides in both RawFoundryChatClient and RawFoundryAgentChatClient to match the updated superclass signature on main. Update super() calls and test assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson --- .../foundry/agent_framework_foundry/_agent.py | 24 ++- .../agent_framework_foundry/_chat_client.py | 17 ++ .../agent_framework_foundry/_oauth_helpers.py | 75 +++++++ .../tests/foundry/test_foundry_agent.py | 192 +++++++++++++++++- .../tests/foundry/test_foundry_chat_client.py | 163 +++++++++++++++ .../tests/foundry/test_oauth_helpers.py | 164 +++++++++++++++ 6 files changed, 625 insertions(+), 10 deletions(-) create mode 100644 python/packages/foundry/agent_framework_foundry/_oauth_helpers.py create mode 100644 python/packages/foundry/tests/foundry/test_oauth_helpers.py diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index da64b65cd9..fbf165ab21 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -19,6 +19,7 @@ from agent_framework import ( AgentSession, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ChatResponseUpdate, ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -35,6 +36,8 @@ from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event + from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): @@ -373,16 +376,19 @@ class RawFoundryAgentChatClient( # type: ignore[misc] options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], seen_reasoning_delta_item_ids: set[str] | None = None, - ) -> Any: - parsed_chunk = super()._parse_chunk_from_openai( - event, - options, - function_call_ids, - seen_reasoning_delta_item_ids, - ) + ) -> ChatResponseUpdate: + """Parse streaming events while preserving hosted-agent session state.""" + update = try_parse_oauth_consent_event(event, self.model) + if update is None: + update = super()._parse_chunk_from_openai( + event, + options, + function_call_ids, + seen_reasoning_delta_item_ids, + ) if _uses_foundry_agent_session(options.get("conversation_id")): - parsed_chunk.conversation_id = None - return parsed_chunk + update.conversation_id = None + return update @override def _check_model_presence(self, options: dict[str, Any]) -> None: diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 57522fb886..fc2b29e1e4 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from agent_framework import ( ChatMiddlewareLayer, + ChatResponseUpdate, Content, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -33,6 +34,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event + from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): @@ -241,6 +244,20 @@ class RawFoundryChatClient( # type: ignore[misc] response_tools = super()._prepare_tools_for_openai(tools) return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + @override + def _parse_chunk_from_openai( + self, + event: Any, + options: dict[str, Any], + function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, + ) -> ChatResponseUpdate: + """Parse streaming event, intercepting oauth_consent_request items.""" + update = try_parse_oauth_consent_event(event, self.model) + if update is not None: + return update + return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py new file mode 100644 index 0000000000..873d42c3d9 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from agent_framework import ChatResponseUpdate, Content + +logger = logging.getLogger(__name__) + + +def _validate_consent_link(consent_link: str, item_id: str) -> str: + """Validate a consent link is HTTPS with a valid netloc. + + Returns the link unchanged if valid, or an empty string if not. + """ + parsed = urlparse(consent_link) + if parsed.scheme.lower() != "https" or not parsed.netloc: + logger.warning( + "Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)", + item_id, + ) + return "" + return consent_link + + +def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None: + """Parse an oauth_consent_request from a streaming event, if present. + + Returns a ``ChatResponseUpdate`` when *event* is a + ``response.output_item.added`` carrying an ``oauth_consent_request`` item + or a top-level ``response.oauth_consent_requested`` event, + or ``None`` so the caller can fall through to the base implementation. + """ + consent_link: str = "" + raw_item: Any = None + + event_type = getattr(event, "type", None) + + if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": + raw_item = event.item + consent_link = getattr(raw_item, "consent_link", None) or "" + elif event_type == "response.oauth_consent_requested": + raw_item = event + consent_link = getattr(event, "consent_link", None) or "" + else: + return None + + item_id = getattr(raw_item, "id", "") + + if consent_link: + consent_link = _validate_consent_link(consent_link, item_id) + + contents: list[Content] = [] + if consent_link: + contents.append( + Content.from_oauth_consent_request( + consent_link=consent_link, + raw_representation=raw_item, + ) + ) + else: + logger.warning( + "Received oauth_consent_request output without valid consent_link (item id=%s)", + item_id, + ) + + return ChatResponseUpdate( + contents=contents, + role="assistant", + model=model, + raw_representation=event, + ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 73670d0bbc..e110e540fe 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -10,7 +10,17 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentResponse, AgentSession, ChatContext, ChatMiddleware, ChatResponse, Message, tool +from agent_framework import ( + AgentResponse, + AgentSession, + ChatContext, + ChatMiddleware, + ChatResponse, + ChatResponseUpdate, + Message, + tool, +) +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential @@ -287,6 +297,31 @@ def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id assert result.conversation_id is None +def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_for_agent_sessions() -> None: + """Test that agent-session stream updates do not overwrite session.service_session_id.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + parsed = ChatResponseUpdate(conversation_id="resp_123") + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._parse_chunk_from_openai", + return_value=parsed, + ): + result = client._parse_chunk_from_openai( + event=MagicMock(type="response.output_text.delta"), + options={"conversation_id": "agent-session-123"}, + function_call_ids={}, + ) + + assert result.conversation_id is None + + def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: """Test that _check_model_presence does nothing (model is on service).""" @@ -622,3 +657,158 @@ async def test_foundry_agent_custom_client_run() -> None: assert isinstance(response, AgentResponse) assert response.text is not None assert "response test" in response.text.lower() + + +def test_parse_chunk_surfaces_oauth_consent_request() -> None: + """An oauth_consent_request output item surfaces as Content with consent_link.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "https://consent-host.example.com/login?data=abc123" + mock_item.id = "oauth-item-1" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + assert update.role == "assistant" + assert update.raw_representation is mock_event + + +def test_parse_chunk_skips_non_https_oauth_consent() -> None: + """An oauth_consent_request with a non-HTTPS link is rejected.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "http://insecure.example.com/login" + mock_item.id = "oauth-item-2" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_missing_consent_link() -> None: + """An oauth_consent_request without a consent_link produces no content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = None + mock_item.id = "oauth-item-3" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_empty_string_consent_link() -> None: + """An oauth_consent_request with empty-string consent_link produces no content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "" + mock_item.id = "oauth-item-4" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: + """Non-oauth events are delegated to super()._parse_chunk_from_openai().""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_text.delta" + + with patch.object( + RawOpenAIChatClient, + "_parse_chunk_from_openai", + return_value=MagicMock(), + ) as mock_super: + client._parse_chunk_from_openai(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}, None) + + +def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: + """A top-level response.oauth_consent_requested event surfaces as Content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.oauth_consent_requested" + mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz" + mock_event.id = "consent-event-1" + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz" + assert update.role == "assistant" + assert update.raw_representation is mock_event diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index a7b57029ba..2ef5ca04ee 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException from agent_framework_openai import OpenAIContentFilterException +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential @@ -993,3 +994,165 @@ def test_get_mcp_tool_with_connection_id() -> None: description="GitHub MCP via Foundry", ) assert tool_obj is not None + + +def test_parse_chunk_surfaces_oauth_consent_request() -> None: + """An oauth_consent_request output item surfaces as Content with consent_link.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "https://consent-host.example.com/login?data=abc123" + mock_item.id = "oauth-item-1" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + assert update.role == "assistant" + assert update.raw_representation is mock_event + assert update.model == "test-model" + + +def test_parse_chunk_skips_non_https_oauth_consent() -> None: + """An oauth_consent_request with a non-HTTPS link is rejected.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "http://insecure.example.com/login" + mock_item.id = "oauth-item-2" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_missing_consent_link() -> None: + """An oauth_consent_request without a consent_link produces no content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = None + mock_item.id = "oauth-item-3" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_empty_string_consent_link() -> None: + """An oauth_consent_request with empty-string consent_link produces no content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "" + mock_item.id = "oauth-item-4" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: + """Non-oauth events are delegated to super()._parse_chunk_from_openai().""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_text.delta" + + with patch.object( + RawOpenAIChatClient, + "_parse_chunk_from_openai", + return_value=MagicMock(), + ) as mock_super: + client._parse_chunk_from_openai(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}, None) + + +def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: + """A top-level response.oauth_consent_requested event surfaces as Content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.oauth_consent_requested" + mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz" + mock_event.id = "consent-event-1" + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz" + assert update.role == "assistant" + assert update.raw_representation is mock_event diff --git a/python/packages/foundry/tests/foundry/test_oauth_helpers.py b/python/packages/foundry/tests/foundry/test_oauth_helpers.py new file mode 100644 index 0000000000..2ab209e141 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_oauth_helpers.py @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event + +# region _validate_consent_link tests + + +def test_validate_consent_link_accepts_valid_https() -> None: + """A valid HTTPS URL with a netloc passes validation.""" + link = "https://consent.example.com/auth?code=123" + assert _validate_consent_link(link, "item-1") == link + + +def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None: + """An HTTP link is rejected and a warning is logged.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("http://insecure.example.com/login", "item-2") + assert result == "" + assert "non-HTTPS" in caplog.text + assert "item-2" in caplog.text + + +def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None: + """An HTTPS URL with an empty netloc (e.g. https:///path) is rejected.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("https:///path", "item-3") + assert result == "" + assert "non-HTTPS" in caplog.text + assert "item-3" in caplog.text + + +def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None: + """A non-URL string is rejected.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("not-a-url", "item-4") + assert result == "" + + +# endregion + +# region try_parse_oauth_consent_event tests + + +def _make_output_item_event( + *, + item_type: str = "oauth_consent_request", + consent_link: Any = "https://consent.example.com/auth", + item_id: str = "oauth-item-1", +) -> MagicMock: + """Create a mock ``response.output_item.added`` event.""" + event = MagicMock() + event.type = "response.output_item.added" + item = MagicMock() + item.type = item_type + item.consent_link = consent_link + item.id = item_id + event.item = item + return event + + +def _make_top_level_event( + *, + consent_link: Any = "https://consent.example.com/authorize", + event_id: str = "consent-event-1", +) -> MagicMock: + """Create a mock ``response.oauth_consent_requested`` event.""" + event = MagicMock() + event.type = "response.oauth_consent_requested" + event.consent_link = consent_link + event.id = event_id + return event + + +def test_returns_none_for_unrelated_event() -> None: + """An event with a non-oauth type returns None.""" + event = MagicMock() + event.type = "response.output_text.delta" + assert try_parse_oauth_consent_event(event, "model-x") is None + + +def test_returns_none_for_event_without_type() -> None: + """An event object missing a 'type' attribute returns None.""" + event = object() # no type attribute + assert try_parse_oauth_consent_event(event, "model-x") is None + + +def test_parses_output_item_added_with_valid_link() -> None: + """A response.output_item.added event with a valid HTTPS link produces Content.""" + event = _make_output_item_event() + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert update.role == "assistant" + assert update.model == "test-model" + assert update.raw_representation is event + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent) == 1 + assert consent[0].consent_link == "https://consent.example.com/auth" + + +def test_parses_top_level_consent_requested_event() -> None: + """A response.oauth_consent_requested event produces Content.""" + event = _make_top_level_event() + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent) == 1 + assert consent[0].consent_link == "https://consent.example.com/authorize" + + +def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None: + """A non-HTTPS consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "non-HTTPS" in caplog.text + + +def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None: + """A None consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link=None, item_id="item-none") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "without valid consent_link" in caplog.text + + +def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None: + """An empty-string consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link="", item_id="item-empty") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "without valid consent_link" in caplog.text + + +def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None: + """An HTTPS URL with empty netloc (https:///path) is rejected.""" + event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "non-HTTPS" in caplog.text + + +# endregion From 0b69d7fd151e2a176ad182648cb419a56584b8f8 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:54:59 +0900 Subject: [PATCH 10/40] Python: Bump Python package versions for 1.2.0 release (#5468) * Bump Python package versions for 1.2.0 release Released tier bumps 1.1.1 -> 1.2.0 (core, openai, foundry, root) to reflect additive public APIs landed since 1.1.0: functional workflow API (#4238) and FunctionTool SKIP_PARSING sentinel (#5424). All beta packages stamped 1.0.0b260424, alpha packages 1.0.0a260424. All 26 non-core agent-framework-core floors raised to >=1.2.0,<2. CHANGELOG consolidates the never-tagged 1.1.1 entries with the post-merge additions into [1.2.0]. * Update CHANGELOG footer links for 1.2.0 Advance [Unreleased] comparison base from python-1.1.0 to python-1.2.0 and add a [1.2.0] reference link comparing python-1.1.0...python-1.2.0 so the heading links resolve correctly. * Fix CHANGELOG: restore [1.1.1] section and add proper [1.2.0] Previous commit incorrectly renamed the [1.1.1] header to [1.2.0], which wiped the historical 1.1.1 entries and wrongly attributed them to 1.2.0. This restores [1.1.1] to its origin/main content and adds a new [1.2.0] section above containing only the commits in python-1.1.1..HEAD: - #4238 functional workflow API - #5142 GitHub Copilot OpenTelemetry - #2403 A2A bridge support - #5070 oauth_consent_request events in Foundry clients - #5447 FoundryAgent hosted agent sessions - #5459 hosting server dependency upgrade + types - #5389 AG-UI reasoning/multimodal parsing fix - #5440 stop [TOOLBOXES] warning spam - #5455 user agent prefix fix Also corrects the [1.2.0] compare base to python-1.1.1 (not 1.1.0) and adds the missing [1.1.1] reference link. --- python/CHANGELOG.md | 24 +++++++-- python/packages/a2a/pyproject.toml | 4 +- python/packages/ag-ui/pyproject.toml | 4 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- python/packages/azure-cosmos/pyproject.toml | 4 +- python/packages/azurefunctions/pyproject.toml | 4 +- python/packages/bedrock/pyproject.toml | 4 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- python/packages/core/pyproject.toml | 2 +- python/packages/declarative/pyproject.toml | 4 +- python/packages/devui/pyproject.toml | 4 +- python/packages/durabletask/pyproject.toml | 4 +- python/packages/foundry/pyproject.toml | 4 +- .../packages/foundry_hosting/pyproject.toml | 2 +- python/packages/foundry_local/pyproject.toml | 4 +- python/packages/gemini/pyproject.toml | 4 +- python/packages/github_copilot/pyproject.toml | 4 +- .../tests/test_github_copilot_agent.py | 4 +- python/packages/hyperlight/pyproject.toml | 4 +- python/packages/lab/pyproject.toml | 4 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/openai/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 4 +- python/packages/purview/pyproject.toml | 4 +- python/packages/redis/pyproject.toml | 4 +- python/pyproject.toml | 4 +- python/uv.lock | 54 +++++++++---------- 31 files changed, 103 insertions(+), 87 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 64439fcbbf..c3291f7604 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-04-24 + +### Added +- **agent-framework-core**: Add functional workflow API ([#4238](https://github.com/microsoft/agent-framework/pull/4238)) +- **agent-framework-core**, **agent-framework-github-copilot**: Add OpenTelemetry integration for `GitHubCopilotAgent` ([#5142](https://github.com/microsoft/agent-framework/pull/5142)) +- **agent-framework-a2a**: Add Agent Framework to A2A bridge support ([#2403](https://github.com/microsoft/agent-framework/pull/2403)) +- **agent-framework-foundry**: Surface `oauth_consent_request` events from Responses API in Foundry clients ([#5070](https://github.com/microsoft/agent-framework/pull/5070)) + +### Changed +- **agent-framework-core**, **agent-framework-foundry**: Update `FoundryAgent` for hosted agent sessions ([#5447](https://github.com/microsoft/agent-framework/pull/5447)) +- **agent-framework-foundry-hosting**: Upgrade hosting server dependency and add more type support ([#5459](https://github.com/microsoft/agent-framework/pull/5459)) + +### Fixed +- **agent-framework-ag-ui**: Fix reasoning role and multimodal media parsing to follow specification ([#5389](https://github.com/microsoft/agent-framework/pull/5389)) +- **agent-framework-foundry**: Stop emitting `[TOOLBOXES]` warning for every `FoundryChatClient` call ([#5440](https://github.com/microsoft/agent-framework/pull/5440)) +- **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**: Fix user agent prefix ([#5455](https://github.com/microsoft/agent-framework/pull/5455)) + ## [1.1.1] - 2026-04-23 ### Added @@ -26,8 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125)) - **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414)) - **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384)) -- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads - ([#5424](https://github.com/microsoft/agent-framework/pull/5424)) +- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads ([#5424](https://github.com/microsoft/agent-framework/pull/5424)) ## [1.1.0] - 2026-04-21 @@ -961,7 +977,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai** For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...HEAD +[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0 +[1.1.1]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...python-1.1.1 [1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0 [1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1 [1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index cfe1c33da4..83d8ecf42f 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 5c79328794..5b6e40c95e 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260423" +version = "1.0.0b260424" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "ag-ui-protocol>=0.1.16,<0.2", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index c82ce7f040..9292ba7fc2 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index d50cf2048a..5f605943b6 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 76a0c50ae4..ddbe5bf06b 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index eb2239563e..49f557a835 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index acea0c2749..c20c248708 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 0662ab25c1..9a936ea8b6 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 6a9069432d..35f9a7f3ff 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 396f3b32e3..d2d1712f94 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index d406965cdd..3dedc1ecba 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.1.1" +version = "1.2.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 6050f080ec..155b7e3edc 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index fa308dbf55..31fdf39234 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "openai>=1.99.0,<3", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.133.1", diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index b55955d09d..7d73511188 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index 58e50d9ed3..68d938241e 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.1.1" +version = "1.2.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "agent-framework-openai>=1.1.0,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.1.0,<3.0", diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index a9d0393a1d..9746cc6605 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-ai-agentserver-core==2.0.0b3", "azure-ai-agentserver-responses==1.0.0b5", "azure-ai-agentserver-invocations==1.0.0b3", diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 5dbdcfb15c..d7c0416ffb 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "agent-framework-openai>=1.1.0,<2", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 2a2d03fe0e..384163fbee 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260423" +version = "1.0.0a260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2.0", + "agent-framework-core>=1.2.0,<2.0", "google-genai>=1.0.0,<2.0.0", ] diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 78364f08a0..eca6a3ae43 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 4d953d9c36..4820ceed22 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -207,9 +207,7 @@ class TestGitHubCopilotAgentInit: def test_default_options_returns_independent_copy(self) -> None: """Test that mutating the returned dict does not affect internal state.""" - agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( - default_options={"model": "gpt-5.1-mini"} - ) + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(default_options={"model": "gpt-5.1-mini"}) opts = agent.default_options opts["model"] = "mutated" assert agent._settings.get("model") == "gpt-5.1-mini" diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index 1b5e650935..f9f5abd41f 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260423" +version = "1.0.0a260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "hyperlight-sandbox>=0.3.0,<0.4", "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'", "hyperlight-sandbox-python-guest>=0.3.0,<0.4", diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index ed4329ff80..ca5f4837ad 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework" authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 52dc028ce0..87042bdffb 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index 0b0368ba89..3cd643e7ef 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index f53258eb98..26227d2cf2 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.1.1" +version = "1.2.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index 58ea3ce9c5..c946440b80 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", ] [tool.uv] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index baa72d89a3..a828479709 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index b9a0c50498..662cdfe13c 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260423" +version = "1.0.0b260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/pyproject.toml b/python/pyproject.toml index 6b482161c0..67853a9ec1 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.1.1" +version = "1.2.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.1.1", + "agent-framework-core[all]==1.2.0", ] [dependency-groups] diff --git a/python/uv.lock b/python/uv.lock index 6f1b6b0bbe..bb422b4478 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -96,7 +96,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.1.1" +version = "1.2.0" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -151,7 +151,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -166,7 +166,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -194,7 +194,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -209,7 +209,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -224,7 +224,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-cosmos" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/azure-cosmos" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -239,7 +239,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -261,7 +261,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -278,7 +278,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -293,7 +293,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -308,7 +308,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -323,7 +323,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.1.1" +version = "1.2.0" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -395,7 +395,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -420,7 +420,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -458,7 +458,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -485,7 +485,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }] [[package]] name = "agent-framework-foundry" -version = "1.1.1" +version = "1.2.0" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -523,7 +523,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -540,7 +540,7 @@ requires-dist = [ [[package]] name = "agent-framework-gemini" -version = "1.0.0a260423" +version = "1.0.0a260424" source = { editable = "packages/gemini" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -555,7 +555,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -570,7 +570,7 @@ requires-dist = [ [[package]] name = "agent-framework-hyperlight" -version = "1.0.0a260423" +version = "1.0.0a260424" source = { editable = "packages/hyperlight" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -589,7 +589,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -670,7 +670,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -685,7 +685,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -700,7 +700,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.1.1" +version = "1.2.0" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -715,7 +715,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -726,7 +726,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -743,7 +743,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, From 56c3f8d82582f8d848dbc96e487a036075ba5696 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:56:30 +0100 Subject: [PATCH 11/40] .NET: Bump OpenTelemetry packages to 1.15.3 (#5478) * Bump OpenTelemetry packages to 1.15.3 to fix known vulnerabilities Update OpenTelemetry packages from 1.15.0 to 1.15.3 in Directory.Packages.props to resolve NU1902 warnings-as-errors for CVEs GHSA-g94r-2vxg-569j, GHSA-mr8r-92fq-pj8p, and GHSA-q834-8qmm-v933. Add explicit PackageReference for OpenTelemetry.Exporter.OpenTelemetryProtocol in Foundry.Hosting and OpenTelemetry.Api + OpenTelemetry.Exporter.OpenTelemetryProtocol in Hosted-Invocations-EchoAgent to override transitive 1.15.0 resolution in projects with CentralPackageTransitivePinningEnabled=false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump OpenTelemetry Extensions and Instrumentation packages to 1.15.x Align the full OpenTelemetry package set to the 1.15.x family: - OpenTelemetry.Extensions.Hosting: 1.14.0 -> 1.15.3 - OpenTelemetry.Instrumentation.AspNetCore: 1.14.0 -> 1.15.2 - OpenTelemetry.Instrumentation.Http: 1.14.0 -> 1.15.1 - OpenTelemetry.Instrumentation.Runtime: 1.14.0 -> 1.15.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 18 +++++++++--------- .../Hosted-Invocations-EchoAgent.csproj | 2 ++ .../Microsoft.Agents.AI.Foundry.Hosting.csproj | 1 + 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index fff7b6a7d4..c57af55ef9 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -56,15 +56,15 @@ - - - - - - - - - + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj index d925172007..a0b9e2e0d8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj @@ -13,6 +13,8 @@ + + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index 99ecde93ca..af02c44aad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -34,6 +34,7 @@ + From 56fb634f0e921f1e0227aa843ce2551d26eaabd6 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 24 Apr 2026 17:55:28 -0700 Subject: [PATCH 12/40] .NET: Support returning durable workflow results from HTTP trigger endpoint (#5321) * Adding support for "wait for response" when invoking workflow http endpoint. * update changelog. * PR comment fixes. * Address PR review feedback. - Return 404 Not Found when no orchestration with the given ID exists - Return 200 OK for failed workflows (the HTTP operation succeeded; the workflow outcome is conveyed via the response body) - Rename 'status' to 'workflowStatus' in WorkflowRunResponse to avoid inconsistency with AgentRunSuccessResponse which uses integer status - Add optional 'error' field (omitted from JSON when null) to WorkflowRunResponse for failed workflow details --- .../01_SequentialWorkflow/README.md | 47 +++++ .../01_SequentialWorkflow/demo.http | 22 +++ .../BuiltInFunctions.cs | 165 +++++++++++++++--- .../CHANGELOG.md | 1 + .../WorkflowSamplesValidation.cs | 40 +++++ 5 files changed, 254 insertions(+), 21 deletions(-) diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md index 384fd358a7..4f455b3dec 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md @@ -65,6 +65,53 @@ Workflow orchestration started for CancelOrder. Orchestration runId: abc123def45 > > If not provided, a unique run ID is auto-generated. +### Wait for the Workflow Result + +By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header: + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ + -H "Content-Type: text/plain" \ + -H "x-ms-wait-for-response: true" \ + -d "12345" +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/CancelOrder/run ` + -ContentType text/plain ` + -Headers @{ "x-ms-wait-for-response" = "true" } ` + -Body "12345" +``` + +The response will contain the workflow result as plain text (200 OK): + +```text +Cancellation email sent for order 12345 to jerry@example.com. +``` + +To get the result as JSON, also include the `Accept: application/json` header: + +```bash +curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ + -H "Content-Type: text/plain" \ + -H "x-ms-wait-for-response: true" \ + -H "Accept: application/json" \ + -d "12345" +``` + +```json +{ + "runId": "abc123def456", + "workflowStatus": "Completed", + "result": "Cancellation email sent for order 12345 to jerry@example.com." +} +``` + In the function app logs, you will see the sequential execution of each executor: ```text diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http index 8366216a6c..fb9793f449 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http @@ -7,6 +7,21 @@ Content-Type: text/plain 12345 +### Cancel an order and wait for the result +POST {{authority}}/api/workflows/CancelOrder/run +Content-Type: text/plain +x-ms-wait-for-response: true + +12345 + +### Cancel an order and wait for the result (JSON response) +POST {{authority}}/api/workflows/CancelOrder/run +Content-Type: text/plain +Accept: application/json +x-ms-wait-for-response: true + +12345 + ### Cancel an order with a custom run ID POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123 Content-Type: text/plain @@ -19,6 +34,13 @@ Content-Type: text/plain 12345 +### Get order status and wait for the result +POST {{authority}}/api/workflows/OrderStatus/run +Content-Type: text/plain +x-ms-wait-for-response: true + +12345 + ### Batch cancel orders with a complex JSON input POST {{authority}}/api/workflows/BatchCancelOrders/run Content-Type: application/json diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index e6c94347a1..376f2fa2ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -21,6 +21,8 @@ internal static class BuiltInFunctions internal const string HttpPrefix = "http-"; internal const string McpToolPrefix = "mcptool-"; + private const string WaitForResponseHeaderName = "x-ms-wait-for-response"; + internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}"; internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}"; internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}"; @@ -62,6 +64,11 @@ internal static class BuiltInFunctions StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null; string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options); + if (ShouldWaitForResponse(req, defaultValue: false)) + { + return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId); + } + HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}"); return response; @@ -304,15 +311,7 @@ internal static class BuiltInFunctions } // Check if we should wait for response (default is true) - bool waitForResponse = true; - if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable? waitForResponseValues)) - { - string? waitForResponseValue = waitForResponseValues.FirstOrDefault(); - if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue)) - { - waitForResponse = parsedValue; - } - } + bool waitForResponse = ShouldWaitForResponse(req, defaultValue: true); AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName); @@ -428,6 +427,95 @@ internal static class BuiltInFunctions return metadata.ReadOutputAs()?.Result; } + /// + /// Waits for a workflow orchestration to complete and returns an appropriate HTTP response. + /// + private static async Task WaitForWorkflowCompletionAsync( + HttpRequestData req, + DurableTaskClient client, + FunctionContext context, + string instanceId) + { + bool acceptsJson = AcceptsJson(req); + + OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true, + cancellation: context.CancellationToken); + + if (metadata is null) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, + $"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson); + } + + if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed) + { + string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error"; + HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK); + + if (acceptsJson) + { + await failedResponse.WriteAsJsonAsync( + new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage), + context.CancellationToken); + } + else + { + failedResponse.Headers.Add("Content-Type", "text/plain"); + await failedResponse.WriteStringAsync(errorMessage, context.CancellationToken); + } + + return failedResponse; + } + + if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError, + $"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson); + } + + string? result = metadata.ReadOutputAs()?.Result; + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + + if (acceptsJson) + { + JsonElement? resultElement = null; + if (!string.IsNullOrEmpty(result)) + { + try + { + using JsonDocument doc = JsonDocument.Parse(result); + resultElement = doc.RootElement.Clone(); + } + catch (JsonException) + { + // Result is a plain string (not valid JSON) — serialize it as a JSON string element. + var buffer = new System.Buffers.ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStringValue(result); + } + + using JsonDocument fallbackDoc = JsonDocument.Parse(buffer.WrittenMemory); + resultElement = fallbackDoc.RootElement.Clone(); + } + } + + await response.WriteAsJsonAsync( + new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), resultElement), + context.CancellationToken); + } + else + { + response.Headers.Add("Content-Type", "text/plain"); + await response.WriteStringAsync(result ?? string.Empty, context.CancellationToken); + } + + return response; + } + /// /// Creates an error response with the specified status code and error message. /// @@ -435,18 +523,18 @@ internal static class BuiltInFunctions /// The function context. /// The HTTP status code. /// The error message. + /// Optional pre-computed value indicating whether the client accepts JSON. When , the value is determined from the request's Accept header. /// The HTTP response data containing the error. private static async Task CreateErrorResponseAsync( HttpRequestData req, FunctionContext context, HttpStatusCode statusCode, - string errorMessage) + string errorMessage, + bool? acceptsJson = null) { HttpResponseData response = req.CreateResponse(statusCode); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && - acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); - if (acceptsJson) + if (acceptsJson ?? AcceptsJson(req)) { ErrorResponse errorResponse = new((int)statusCode, errorMessage); await response.WriteAsJsonAsync(errorResponse, context.CancellationToken); @@ -479,10 +567,7 @@ internal static class BuiltInFunctions HttpResponseData response = req.CreateResponse(statusCode); response.Headers.Add("x-ms-thread-id", sessionId); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && - acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); - - if (acceptsJson) + if (AcceptsJson(req)) { AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, agentResponse); await response.WriteAsJsonAsync(successResponse, context.CancellationToken); @@ -511,10 +596,7 @@ internal static class BuiltInFunctions HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); response.Headers.Add("x-ms-thread-id", sessionId); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && - acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); - - if (acceptsJson) + if (AcceptsJson(req)) { AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId); await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken); @@ -528,6 +610,34 @@ internal static class BuiltInFunctions return response; } + /// + /// Returns when the caller has requested waiting for the workflow/agent to complete, + /// as indicated by the x-ms-wait-for-response header. Falls back to + /// when the header is absent or not a valid boolean. + /// + private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue) + { + if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable? values) && + bool.TryParse(values.FirstOrDefault(), out bool parsed)) + { + return parsed; + } + + return defaultValue; + } + + /// + /// Returns when the request accepts the application/json media type. + /// + private static bool AcceptsJson(HttpRequestData req) + { + return req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && + acceptValues + .SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(v => v.Split(';', 2)[0].Trim()) + .Contains("application/json", StringComparer.OrdinalIgnoreCase); + } + private static string GetAgentName(FunctionContext context) { // Check if the function name starts with the HttpPrefix @@ -591,6 +701,19 @@ internal static class BuiltInFunctions [property: JsonPropertyName("eventName")] string? EventName, [property: JsonPropertyName("response")] JsonElement Response); + /// + /// Represents a workflow run response when waiting for completion. + /// + /// The orchestration run ID. + /// The orchestration runtime status (e.g., "Completed", "Failed"). + /// The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings. + /// An optional error message when the workflow has failed. + private sealed record WorkflowRunResponse( + [property: JsonPropertyName("runId")] string RunId, + [property: JsonPropertyName("workflowStatus")] string WorkflowStatus, + [property: JsonPropertyName("result")] JsonElement? Result, + [property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null); + /// /// A service provider that combines the original service provider with an additional DurableTaskClient instance. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 2c188757d5..f8f59c89d1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321)) - Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768)) - Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index a7f2f51156..2eba009c67 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Reflection; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; @@ -125,6 +126,45 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }, message: "OrderStatus workflow completed", timeout: s_orchestrationTimeout); + + // Test the CancelOrder workflow with x-ms-wait-for-response header + this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response: true..."); + + using HttpRequestMessage waitRequest = new(HttpMethod.Post, cancelOrderUri); + waitRequest.Content = new StringContent("55555", Encoding.UTF8, "text/plain"); + waitRequest.Headers.Add("x-ms-wait-for-response", "true"); + using HttpResponseMessage waitResponse = await s_sharedHttpClient.SendAsync(waitRequest); + + Assert.True(waitResponse.IsSuccessStatusCode, $"CancelOrder wait-for-response request failed with status: {waitResponse.StatusCode}"); + string waitResponseText = await waitResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"CancelOrder wait-for-response result: {waitResponseText}"); + + // The response should contain the workflow result (not just "started for CancelOrder") + Assert.DoesNotContain("Workflow orchestration started", waitResponseText); + Assert.Contains("55555", waitResponseText); + + // Test the wait-for-response with Accept: application/json header + this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response and Accept: application/json..."); + + using HttpRequestMessage jsonWaitRequest = new(HttpMethod.Post, cancelOrderUri); + jsonWaitRequest.Content = new StringContent("77777", Encoding.UTF8, "text/plain"); + jsonWaitRequest.Headers.Add("x-ms-wait-for-response", "true"); + jsonWaitRequest.Headers.Add("Accept", "application/json"); + + using CancellationTokenSource jsonWaitCts = new(s_orchestrationTimeout); + using HttpResponseMessage jsonWaitResponse = await s_sharedHttpClient.SendAsync(jsonWaitRequest, jsonWaitCts.Token); + + Assert.True(jsonWaitResponse.IsSuccessStatusCode, $"CancelOrder JSON wait-for-response request failed with status: {jsonWaitResponse.StatusCode}"); + string jsonWaitResponseText = await jsonWaitResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"CancelOrder JSON wait-for-response result: {jsonWaitResponseText}"); + + using JsonDocument jsonDoc = JsonDocument.Parse(jsonWaitResponseText); + JsonElement root = jsonDoc.RootElement; + Assert.True(root.TryGetProperty("runId", out _), "JSON response missing 'runId' property"); + Assert.True(root.TryGetProperty("workflowStatus", out JsonElement statusEl), "JSON response missing 'workflowStatus' property"); + Assert.Equal("Completed", statusEl.GetString()); + Assert.True(root.TryGetProperty("result", out JsonElement resultEl), "JSON response missing 'result' property"); + Assert.Contains("77777", resultEl.GetString()); }); } From dad3652f46d38539b4c588b9732e9eb1b3c1e6f8 Mon Sep 17 00:00:00 2001 From: bahtyar <34988899+Bahtya@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:53:06 +0800 Subject: [PATCH 13/40] Python: fix: prevent inner_exception from being lost in AgentFrameworkException (#5167) * fix: prevent inner_exception from being lost in AgentFrameworkException The __init__ method unconditionally called super().__init__() after the conditional call with inner_exception, effectively overwriting the exception args and losing the inner_exception reference. Add else branch so super().__init__() is only called once with the correct arguments. Fixes #5155 Signed-off-by: bahtya * test: add explicit tests for AgentFrameworkException inner_exception handling - test_exception_with_inner_exception: verifies args include inner exception - test_exception_without_inner_exception: verifies args only contain message - test_exception_inner_exception_none_explicit: verifies explicit None Covers both branches of the if/else in __init__. * fix: export AgentFrameworkException from package Bahtya --------- Signed-off-by: bahtya --- .../packages/core/agent_framework/__init__.py | 2 ++ .../core/agent_framework/exceptions.py | 3 +- .../core/tests/core/test_exceptions.py | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 python/packages/core/tests/core/test_exceptions.py diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 364f62eae1..a098159a50 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -247,6 +247,7 @@ from ._workflows._workflow_executor import ( WorkflowExecutor, ) from .exceptions import ( + AgentFrameworkException, MiddlewareException, UserInputRequiredException, WorkflowCheckpointException, @@ -276,6 +277,7 @@ __all__ = [ "USER_AGENT_TELEMETRY_DISABLED_ENV_VAR", "Agent", "AgentContext", + "AgentFrameworkException", "AgentEvalConverter", "AgentExecutor", "AgentExecutorRequest", diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index 4f56c34b5c..d5a54c2102 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -34,7 +34,8 @@ class AgentFrameworkException(Exception): logger.log(log_level, message, exc_info=inner_exception) if inner_exception: super().__init__(message, inner_exception, *args) # type: ignore - super().__init__(message, *args) # type: ignore + else: + super().__init__(message, *args) # type: ignore # region Agent Exceptions diff --git a/python/packages/core/tests/core/test_exceptions.py b/python/packages/core/tests/core/test_exceptions.py new file mode 100644 index 0000000000..47b3a53197 --- /dev/null +++ b/python/packages/core/tests/core/test_exceptions.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for AgentFrameworkException inner_exception handling.""" + +import pytest + +from agent_framework import AgentFrameworkException + + +def test_exception_with_inner_exception(): + """When inner_exception is provided, it should be set as the second arg.""" + inner = ValueError("inner error") + exc = AgentFrameworkException("test message", inner_exception=inner) + assert exc.args[0] == "test message" + assert exc.args[1] is inner + + +def test_exception_without_inner_exception(): + """When inner_exception is None, args should only contain the message.""" + exc = AgentFrameworkException("test message") + assert exc.args == ("test message",) + assert len(exc.args) == 1 + + +def test_exception_inner_exception_none_explicit(): + """When inner_exception is explicitly None, args should only contain the message.""" + exc = AgentFrameworkException("test message", inner_exception=None) + assert exc.args == ("test message",) + assert len(exc.args) == 1 From 2eb0705ee0062cf3918c7fca304c1506b8b78911 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:37:10 +0100 Subject: [PATCH 14/40] .NET: [Breaking] Support string[] arguments for file-based skill scripts (#5475) * support arguments of string[] shape for file-based skill scripts * suppress breaking changes errors * address feedback * remove unnecessary usung directive --- .../AgentSkills/SubprocessScriptRunner.cs | 44 ++-- .../CompatibilitySuppressions.xml | 140 +++++++++++++ .../Microsoft.Agents.AI/Skills/AgentSkill.cs | 3 +- .../Skills/AgentSkillScript.cs | 7 +- .../Skills/AgentSkillsProvider.cs | 7 +- .../Skills/File/AgentFileSkill.cs | 17 +- .../Skills/File/AgentFileSkillScript.cs | 24 ++- .../Skills/File/AgentFileSkillScriptRunner.cs | 11 +- .../AgentInlineSkillContentBuilder.cs | 74 ++++--- .../Programmatic/AgentInlineSkillScript.cs | 39 +++- .../AgentSkills/AgentClassSkillTests.cs | 29 +-- .../AgentSkills/AgentFileSkillScriptTests.cs | 191 +++++++++++++++++- .../AgentFileSkillsSourceScriptTests.cs | 28 ++- .../AgentInlineSkillScriptTests.cs | 84 ++++++-- .../AgentSkills/AgentInlineSkillTests.cs | 10 +- .../AgentSkills/AgentSkillsProviderTests.cs | 61 +++++- .../AgentSkills/FileAgentSkillLoaderTests.cs | 2 +- 17 files changed, 650 insertions(+), 121 deletions(-) diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs index e95bde61df..b2068c4c0b 100644 --- a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -5,16 +5,16 @@ // This is provided for demonstration purposes only. using System.Diagnostics; +using System.Text.Json; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; /// /// Executes file-based skill scripts as local subprocesses. /// /// -/// This runner uses the script's absolute path, converts the arguments -/// to CLI flags, and returns captured output. It is intended for -/// demonstration purposes only. +/// This runner uses the script's absolute path and converts the arguments +/// to CLI arguments. When the LLM sends a JSON array, each element is used +/// as a positional argument. It is intended for demonstration purposes only. /// internal static class SubprocessScriptRunner { @@ -24,7 +24,8 @@ internal static class SubprocessScriptRunner public static async Task RunAsync( AgentFileSkill skill, AgentFileSkillScript script, - AIFunctionArguments arguments, + JsonElement? arguments, + IServiceProvider? serviceProvider, CancellationToken cancellationToken) { if (!File.Exists(script.FullPath)) @@ -61,24 +62,27 @@ internal static class SubprocessScriptRunner startInfo.FileName = script.FullPath; } - if (arguments is not null) + if (arguments is { ValueKind: JsonValueKind.Array } json) { - foreach (var (key, value) in arguments) + // Positional CLI arguments + foreach (var element in json.EnumerateArray()) { - if (value is bool boolValue) + if (element.ValueKind != JsonValueKind.String) { - if (boolValue) - { - startInfo.ArgumentList.Add(NormalizeKey(key)); - } - } - else if (value is not null) - { - startInfo.ArgumentList.Add(NormalizeKey(key)); - startInfo.ArgumentList.Add(value.ToString()!); + throw new InvalidOperationException( + $"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " + + "All array elements must be JSON strings."); } + + startInfo.ArgumentList.Add(element.GetString()!); } } + else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined) + { + throw new InvalidOperationException( + $"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " + + "File-based skill scripts expect positional arguments as a JSON array of strings."); + } Process? process = null; try @@ -128,10 +132,4 @@ internal static class SubprocessScriptRunner process?.Dispose(); } } - - /// - /// Normalizes a parameter key to a consistent --flag format. - /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). - /// - private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); } diff --git a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml index a8268863bd..6a2c790f22 100644 --- a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml @@ -1,6 +1,20 @@  + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -29,6 +43,13 @@ lib/net10.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -43,6 +64,20 @@ lib/net10.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -71,6 +106,13 @@ lib/net472/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -85,6 +127,20 @@ lib/net472/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -113,6 +169,13 @@ lib/net8.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -127,6 +190,20 @@ lib/net8.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -155,6 +232,13 @@ lib/net9.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -169,6 +253,20 @@ lib/net9.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -197,6 +295,13 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -211,4 +316,39 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs index 4c5ca8dbc3..6f549301d0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -35,7 +35,8 @@ public abstract class AgentSkill /// Gets the full skill content. /// /// - /// For file-based skills this is the raw SKILL.md file content. + /// For file-based skills this is the raw SKILL.md file content, optionally + /// augmented with a synthesized scripts block when scripts are present. /// For code-defined skills this is a synthesized XML document /// containing name, description, and body (instructions, resources, scripts). /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs index 1ac44bfac8..bbfbcb8616 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -46,8 +46,9 @@ public abstract class AgentSkillScript /// Runs the script with the given arguments. /// /// The skill that owns this script. - /// Arguments for script execution. + /// Raw JSON arguments for script execution, preserving the original format (object or array) sent by the caller. + /// Optional service provider for dependency injection. /// Cancellation token. /// The script execution result. - public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index b5598e19d3..af1225c9df 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Security; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -243,7 +244,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider } AIFunction scriptFunction = AIFunctionFactory.Create( - (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + (string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), name: "run_skill_script", description: "Runs a script associated with a skill."); @@ -340,7 +341,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider } } - private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(skillName)) { @@ -366,7 +367,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider try { - return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs index 4bb62e99a8..3e10557968 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -15,6 +15,8 @@ public sealed class AgentFileSkill : AgentSkill { private readonly IReadOnlyList _resources; private readonly IReadOnlyList _scripts; + private readonly string _originalContent; + private string? _content; /// /// Initializes a new instance of the class. @@ -32,7 +34,7 @@ public sealed class AgentFileSkill : AgentSkill IReadOnlyList? scripts = null) { this.Frontmatter = Throw.IfNull(frontmatter); - this.Content = Throw.IfNull(content); + this._originalContent = Throw.IfNull(content); this.Path = Throw.IfNullOrWhitespace(path); this._resources = resources ?? []; this._scripts = scripts ?? []; @@ -42,7 +44,18 @@ public sealed class AgentFileSkill : AgentSkill public override AgentSkillFrontmatter Frontmatter { get; } /// - public override string Content { get; } + /// + /// Returns the raw SKILL.md content. When the skill has scripts, a + /// <scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts> + /// block is appended with a per-script entry describing the expected argument format. + /// The result is cached after the first access. + /// + public override string Content + { + get => this._content ??= this._scripts is { Count: > 0 } + ? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts) + : this._originalContent; + } /// /// Gets the directory path where the skill was discovered. diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs index 116847126f..74c0cd2f01 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -2,9 +2,9 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -16,6 +16,11 @@ namespace Microsoft.Agents.AI; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentFileSkillScript : AgentSkillScript { + /// + /// Cached JSON schema element describing the expected argument format: a string array of CLI arguments. + /// + private static readonly JsonElement s_defaultSchema = CreateDefaultSchema(); + private readonly AgentFileSkillScriptRunner? _runner; /// @@ -37,7 +42,14 @@ public sealed class AgentFileSkillScript : AgentSkillScript public string FullPath { get; } /// - public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + /// + /// Returns a fixed schema describing a string array of CLI arguments: + /// {"type":"array","items":{"type":"string"}}. + /// + public override JsonElement? ParametersSchema => s_defaultSchema; + + /// + public override async Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) { if (skill is not AgentFileSkill fileSkill) { @@ -51,6 +63,12 @@ public sealed class AgentFileSkillScript : AgentSkillScript $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); } - return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + return await this._runner(fileSkill, this, arguments, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + private static JsonElement CreateDefaultSchema() + { + using JsonDocument document = JsonDocument.Parse("""{"type":"array","items":{"type":"string"}}"""); + return document.RootElement.Clone(); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs index c19d19e056..1746150ca2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -13,15 +14,19 @@ namespace Microsoft.Agents.AI; /// /// /// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// The parameter preserves the raw JSON sent by the caller, in the shape +/// described by . /// /// The skill that owns the script. /// The file-based script to run. -/// Optional arguments for the script, provided by the agent/LLM. +/// Raw JSON arguments for the script, in the shape described by . +/// Optional service provider for dependency injection. /// Cancellation token. /// The script execution result. [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public delegate Task AgentFileSkillScriptRunner( AgentFileSkill skill, AgentFileSkillScript script, - AIFunctionArguments arguments, + JsonElement? arguments, + IServiceProvider? serviceProvider, CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs index f90d67dd1d..dabf75fa1a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs @@ -59,36 +59,60 @@ internal static class AgentInlineSkillContentBuilder if (scripts is { Count: > 0 }) { - sb.Append("\n\n\n"); - foreach (var script in scripts) - { - var parametersSchema = script.ParametersSchema; - - if (script.Description is null && parametersSchema is null) - { - sb.Append($" \n"); - } - } - - sb.Append(""); + sb.Append('\n'); + sb.Append(BuildScriptsBlock(scripts)); } return sb.ToString(); } + /// + /// Builds a <scripts>...</scripts> XML block for the given scripts. + /// Each script is emitted as a <script name="..."> element with optional + /// description attribute and <parameters_schema> child element. + /// + /// The scripts to include in the block. + /// An XML string starting with \n<scripts>, or an empty string if the list is empty. + public static string BuildScriptsBlock(IReadOnlyList scripts) + { + _ = Throw.IfNull(scripts); + + if (scripts.Count == 0) + { + return string.Empty; + } + + var sb = new StringBuilder(); + sb.Append("\n\n"); + + foreach (var script in scripts) + { + var parametersSchema = script.ParametersSchema; + + if (script.Description is null && parametersSchema is null) + { + sb.Append($" \n"); + } + } + + sb.Append(""); + + return sb.ToString(); + } + /// /// Escapes XML special characters: always escapes &, <, >, /// ", and '. When is , diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index 1e3041aafc..c0abc73252 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; @@ -67,8 +68,42 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript public override JsonElement? ParametersSchema => this._function.JsonSchema; /// - public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + public override async Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) { - return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + var funcArgs = ConvertToFunctionArguments(arguments); + funcArgs.Services = serviceProvider; + + return await this._function.InvokeAsync(funcArgs, cancellationToken).ConfigureAwait(false); + } + + /// + /// Converts a raw to for delegate invocation. + /// + /// + /// Thrown when is provided but is not a JSON object. + /// Inline skill scripts expect arguments as a JSON object whose properties map to the delegate's parameters. + /// + private static AIFunctionArguments ConvertToFunctionArguments(JsonElement? arguments) + { + if (arguments is null || + arguments.Value.ValueKind == JsonValueKind.Null || + arguments.Value.ValueKind == JsonValueKind.Undefined) + { + return []; + } + + if (arguments.Value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Inline skill scripts expect arguments as a JSON object but received a JSON element of kind '{arguments.Value.ValueKind}'."); + } + + var dict = new Dictionary(); + foreach (var property in arguments.Value.EnumerateObject()) + { + dict[property.Name] = property.Value; + } + + return new AIFunctionArguments(dict); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 1dc7d5b3f9..dc83fad119 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -8,7 +8,6 @@ using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -128,8 +127,9 @@ public sealed class AgentClassSkillTests // Act — script with custom type deserialization var script = skill.Scripts![0]; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(scriptResult); @@ -173,12 +173,14 @@ public sealed class AgentClassSkillTests // Act & Assert — static method var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work"); - var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None); + using var doWorkDoc = JsonDocument.Parse("""{"input":"hello"}"""); + var doWorkResult = await doWorkScript.RunAsync(skill, doWorkDoc.RootElement, null, CancellationToken.None); Assert.Equal("HELLO", doWorkResult?.ToString()); // Act & Assert — instance method var appendScript = skill.Scripts!.First(s => s.Name == "append"); - var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None); + using var appendDoc = JsonDocument.Parse("""{"input":"test"}"""); + var appendResult = await appendScript.RunAsync(skill, appendDoc.RootElement, null, CancellationToken.None); Assert.Equal("test-suffix", appendResult?.ToString()); } @@ -367,7 +369,7 @@ public sealed class AgentClassSkillTests // Act & Assert — all scripts produce values foreach (var script in skill.Scripts!) { - var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None); + var result = await script.RunAsync(skill, null, null, CancellationToken.None); Assert.NotNull(result); } } @@ -382,8 +384,9 @@ public sealed class AgentClassSkillTests // Act & Assert — script with custom JSO var script = skill.Scripts![0]; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None); Assert.NotNull(scriptResult); Assert.Contains("test", scriptResult!.ToString()!); Assert.Contains("3", scriptResult!.ToString()!); @@ -497,8 +500,9 @@ public sealed class AgentClassSkillTests var script = skill.Scripts!.First(s => s.Name == "Lookup"); var jso = SkillTestJsonContext.Default.Options; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var result = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(result); @@ -531,8 +535,9 @@ public sealed class AgentClassSkillTests var script = skill.Scripts!.First(s => s.Name == "Lookup"); var jso = SkillTestJsonContext.Default.Options; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var result = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs index eb4f706f30..e638380019 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -16,13 +16,13 @@ public sealed class AgentFileSkillScriptTests public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() { // Arrange - static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult("result"); var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); // Act & Assert await Assert.ThrowsAsync( - () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + () => script.RunAsync(nonFileSkill, null, null, CancellationToken.None)); } [Fact] @@ -30,7 +30,7 @@ public sealed class AgentFileSkillScriptTests { // Arrange var runnerCalled = false; - Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct) { runnerCalled = true; return Task.FromResult("executed"); @@ -42,7 +42,7 @@ public sealed class AgentFileSkillScriptTests "/skills/my-skill"); // Act - var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + var result = await script.RunAsync(fileSkill, null, null, CancellationToken.None); // Assert Assert.True(runnerCalled); @@ -55,7 +55,7 @@ public sealed class AgentFileSkillScriptTests // Arrange AgentFileSkill? capturedSkill = null; AgentFileSkillScript? capturedScript = null; - Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct) { capturedSkill = skill; capturedScript = scriptArg; @@ -68,7 +68,7 @@ public sealed class AgentFileSkillScriptTests "/skills/owner-skill"); // Act - await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + await script.RunAsync(fileSkill, null, null, CancellationToken.None); // Assert Assert.Same(fileSkill, capturedSkill); @@ -79,7 +79,7 @@ public sealed class AgentFileSkillScriptTests public void Script_HasCorrectNameAndPath() { // Arrange & Act - static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); // Assert @@ -87,10 +87,173 @@ public sealed class AgentFileSkillScriptTests Assert.Equal("/path/to/my-script.py", script.FullPath); } + [Fact] + public void ParametersSchema_ReturnsExpectedArraySchema() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/script.py", RunnerAsync); + + // Act + var schema = script.ParametersSchema; + + // Assert + Assert.NotNull(schema); + var raw = schema!.Value.GetRawText(); + Assert.Contains("\"type\":\"array\"", raw); + Assert.Contains("\"items\":{\"type\":\"string\"}", raw); + } + + [Fact] + public void Content_WithScripts_AppendsPerScriptEntries() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); + var script1 = CreateScript("build", "/scripts/build.sh", RunnerAsync); + var script2 = CreateScript("deploy", "/scripts/deploy.sh", RunnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A skill"), + "Original content", + "/skills/my-skill", + scripts: [script1, script2]); + + // Act + var content = fileSkill.Content; + + // Assert — content starts with original and appends per-script entries + Assert.StartsWith("Original content", content); + Assert.Contains("", content); + Assert.Contains("