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