diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index 765d6c89..219b7dc8 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from azure.durable_functions.models.RetryOptions import RetryOptions -from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger,\ +from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional from azure.durable_functions.entity import Entity diff --git a/azure/durable_functions/openai_agents/__init__.py b/azure/durable_functions/openai_agents/__init__.py index 916af484..fb2aa87a 100644 --- a/azure/durable_functions/openai_agents/__init__.py +++ b/azure/durable_functions/openai_agents/__init__.py @@ -1,7 +1,9 @@ -"""OpenAI Agents integration for Azure Durable Functions. +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""OpenAI Agents integration for Durable Functions. This module provides decorators and utilities to integrate OpenAI Agents -with Azure Durable Functions orchestration patterns. +with Durable Functions orchestration patterns. """ from .context import DurableAIAgentContext diff --git a/azure/durable_functions/openai_agents/context.py b/azure/durable_functions/openai_agents/context.py index 6b8b3516..58a396b5 100644 --- a/azure/durable_functions/openai_agents/context.py +++ b/azure/durable_functions/openai_agents/context.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import json from typing import Any, Callable, Optional, TYPE_CHECKING, Union diff --git a/azure/durable_functions/openai_agents/event_loop.py b/azure/durable_functions/openai_agents/event_loop.py index ec127c38..6b85a976 100644 --- a/azure/durable_functions/openai_agents/event_loop.py +++ b/azure/durable_functions/openai_agents/event_loop.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import asyncio diff --git a/azure/durable_functions/openai_agents/exceptions.py b/azure/durable_functions/openai_agents/exceptions.py index d2e4f724..38834a52 100644 --- a/azure/durable_functions/openai_agents/exceptions.py +++ b/azure/durable_functions/openai_agents/exceptions.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from azure.durable_functions.models.Task import TaskBase diff --git a/azure/durable_functions/openai_agents/handoffs.py b/azure/durable_functions/openai_agents/handoffs.py new file mode 100644 index 00000000..e5140646 --- /dev/null +++ b/azure/durable_functions/openai_agents/handoffs.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Handoff conversion utilities for Azure Durable Functions OpenAI agent operations.""" + +from typing import Any + +from agents import Handoff +from pydantic import BaseModel + + +class DurableHandoff(BaseModel): + """Serializable representation of a Handoff. + + Contains only the data needed by the model execution to + determine what to handoff to, not the actual handoff invocation. + """ + + tool_name: str + tool_description: str + input_json_schema: dict[str, Any] + agent_name: str + strict_json_schema: bool = True + + @classmethod + def from_handoff(cls, handoff: Handoff) -> "DurableHandoff": + """Create a DurableHandoff from an OpenAI agent Handoff. + + This method converts OpenAI agent Handoff instances into serializable + DurableHandoff objects for use within Azure Durable Functions. + + Parameters + ---------- + handoff : Handoff + The OpenAI agent Handoff to convert + + Returns + ------- + DurableHandoff + A serializable handoff representation + """ + return cls( + tool_name=handoff.tool_name, + tool_description=handoff.tool_description, + input_json_schema=handoff.input_json_schema, + agent_name=handoff.agent_name, + strict_json_schema=handoff.strict_json_schema, + ) + + def to_handoff(self) -> Handoff[Any, Any]: + """Create an OpenAI agent Handoff instance from this DurableHandoff. + + This method converts the serializable DurableHandoff back into an + OpenAI agent Handoff instance for execution. + + Returns + ------- + Handoff + OpenAI agent Handoff instance + """ + return Handoff( + tool_name=self.tool_name, + tool_description=self.tool_description, + input_json_schema=self.input_json_schema, + agent_name=self.agent_name, + strict_json_schema=self.strict_json_schema, + on_invoke_handoff=lambda ctx, input: None, + ) diff --git a/azure/durable_functions/openai_agents/model_invocation_activity.py b/azure/durable_functions/openai_agents/model_invocation_activity.py index 607cee49..2d4d6f1d 100644 --- a/azure/durable_functions/openai_agents/model_invocation_activity.py +++ b/azure/durable_functions/openai_agents/model_invocation_activity.py @@ -1,6 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import enum import json -import logging from typing import Any, AsyncIterator, Optional, Union, cast from azure.durable_functions.models.RetryOptions import RetryOptions @@ -8,134 +9,81 @@ from agents import ( AgentOutputSchema, AgentOutputSchemaBase, - CodeInterpreterTool, - FileSearchTool, - FunctionTool, Handoff, - HostedMCPTool, - ImageGenerationTool, Model, ModelProvider, ModelResponse, ModelSettings, ModelTracing, OpenAIProvider, - RunContextWrapper, Tool, TResponseInputItem, UserError, - WebSearchTool, ) from agents.items import TResponseStreamEvent -from openai.types.responses.tool_param import Mcp from openai.types.responses.response_prompt_param import ResponsePromptParam from .task_tracker import TaskTracker - -try: - from azure.durable_functions import ApplicationError -except ImportError: - # Fallback if ApplicationError is not available - class ApplicationError(Exception): - """Custom application error for handling retryable and non-retryable errors.""" - - def __init__(self, message: str, non_retryable: bool = False, next_retry_delay=None): - super().__init__(message) - self.non_retryable = non_retryable - self.next_retry_delay = next_retry_delay - -logger = logging.getLogger(__name__) - - -class HandoffInput(BaseModel): - """Data conversion friendly representation of a Handoff. - - Contains only the fields which are needed by the model execution to - determine what to handoff to, not the actual handoff invocation, - which remains in the workflow context. - """ - - tool_name: str - tool_description: str - input_json_schema: dict[str, Any] - agent_name: str - strict_json_schema: bool = True - - -class FunctionToolInput(BaseModel): - """Data conversion friendly representation of a FunctionTool. - - Contains only the fields which are needed by the model execution to - determine what tool to call, not the actual tool invocation, - which remains in the workflow context. - """ - - name: str - description: str - params_json_schema: dict[str, Any] - strict_json_schema: bool = True - - -class HostedMCPToolInput(BaseModel): - """Data conversion friendly representation of a HostedMCPTool. - - Contains only the fields which are needed by the model execution to - determine what tool to call, not the actual tool invocation, - which remains in the workflow context. - """ - - tool_config: Mcp - - -ToolInput = Union[ - FunctionToolInput, - FileSearchTool, - WebSearchTool, - ImageGenerationTool, - CodeInterpreterTool, - HostedMCPToolInput, -] +from .tools import ( + DurableTool, + create_tool_from_durable_tool, + convert_tool_to_durable_tool, +) +from .handoffs import DurableHandoff -class AgentOutputSchemaInput(AgentOutputSchemaBase, BaseModel): - """Data conversion friendly representation of AgentOutputSchema.""" +class DurableAgentOutputSchema(AgentOutputSchemaBase, BaseModel): + """Serializable representation of agent output schema.""" output_type_name: Optional[str] = None - is_wrapped: bool output_schema: Optional[dict[str, Any]] = None strict_json_schema: bool def is_plain_text(self) -> bool: """Whether the output type is plain text (versus a JSON object).""" - return self.output_type_name is None or self.output_type_name == "str" + return self.output_type_name in (None, "str") - def is_strict_json_schema(self) -> bool: - """Whether the JSON schema is in strict mode.""" - return self.strict_json_schema + def name(self) -> str: + """Get the name of the output type.""" + if self.output_type_name is None: + raise ValueError("Output type name has not been specified") + return self.output_type_name def json_schema(self) -> dict[str, Any]: - """Get the JSON schema of the output type.""" + """Return the JSON schema of the output. + + Will only be called if the output type is not plain text. + """ if self.is_plain_text(): - raise UserError("Output type is plain text, so no JSON schema is available") + raise UserError("Cannot provide JSON schema for plain text output types") if self.output_schema is None: - raise UserError("Output schema is not defined") + raise UserError("Output schema definition is missing") return self.output_schema + def is_strict_json_schema(self) -> bool: + """Check if the JSON schema is in strict mode. + + Strict mode constrains the JSON schema features, but guarantees valid JSON. + See here for details: + https://platform.openai.com/docs/guides/structured-outputs#supported-schemas + """ + return self.strict_json_schema + def validate_json(self, json_str: str) -> Any: - """Validate the JSON string against the schema.""" - raise NotImplementedError() + """Validate a JSON string against the output type. - def name(self) -> str: - """Get the name of the output type.""" - if self.output_type_name is None: - raise ValueError("output_type_name is None") - return self.output_type_name + You must return the validated object, or raise a `ModelBehaviorError` if + the JSON is invalid. + """ + raise NotImplementedError() -class ModelTracingInput(enum.IntEnum): - """Conversion friendly representation of ModelTracing. +class ModelTracingLevel(enum.IntEnum): + """Serializable IntEnum representation of ModelTracing for Azure Durable Functions. - Needed as ModelTracing is enum.Enum instead of IntEnum + Values must match ModelTracing from the OpenAI SDK. This separate enum is required + because ModelTracing is a standard Enum while Pydantic serialization requires IntEnum + for proper JSON serialization in activity inputs. """ DISABLED = 0 @@ -143,22 +91,22 @@ class ModelTracingInput(enum.IntEnum): ENABLED_WITHOUT_DATA = 2 -class ActivityModelInput(BaseModel): - """Input for the invoke_model_activity activity.""" +class DurableModelActivityInput(BaseModel): + """Serializable input for the durable model invocation activity.""" input: Union[str, list[TResponseInputItem]] model_settings: ModelSettings - tracing: ModelTracingInput + tracing: ModelTracingLevel model_name: Optional[str] = None system_instructions: Optional[str] = None - tools: list[ToolInput] = Field(default_factory=list) - output_schema: Optional[AgentOutputSchemaInput] = None - handoffs: list[HandoffInput] = Field(default_factory=list) + tools: list[DurableTool] = Field(default_factory=list) + output_schema: Optional[DurableAgentOutputSchema] = None + handoffs: list[DurableHandoff] = Field(default_factory=list) previous_response_id: Optional[str] = None prompt: Optional[Any] = None def to_json(self) -> str: - """Convert the ActivityModelInput to a JSON string.""" + """Convert to a JSON string.""" try: return self.model_dump_json(warnings=False) except Exception: @@ -167,12 +115,12 @@ def to_json(self) -> str: return json.dumps(self.model_dump(warnings=False), default=str) except Exception as fallback_error: raise ValueError( - f"Unable to serialize ActivityModelInput: {fallback_error}" + f"Unable to serialize DurableModelActivityInput: {fallback_error}" ) from fallback_error @classmethod - def from_json(cls, json_str: str) -> 'ActivityModelInput': - """Create an ActivityModelInput instance from a JSON string.""" + def from_json(cls, json_str: str) -> 'DurableModelActivityInput': + """Create from a JSON string.""" return cls.model_validate_json(json_str) @@ -183,65 +131,28 @@ def __init__(self, model_provider: Optional[ModelProvider] = None): """Initialize the activity with a model provider.""" self._model_provider = model_provider or OpenAIProvider() - async def invoke_model_activity(self, input: ActivityModelInput) -> ModelResponse: + async def invoke_model_activity(self, input: DurableModelActivityInput) -> ModelResponse: """Activity that invokes a model with the given input.""" model = self._model_provider.get_model(input.model_name) - async def empty_on_invoke_tool(ctx: RunContextWrapper[Any], input: str) -> str: - return "" - - async def empty_on_invoke_handoff( - ctx: RunContextWrapper[Any], input: str - ) -> Any: - return None - - # workaround for https://github.com/pydantic/pydantic/issues/9541 - # ValidatorIterator returned - input_json = json.dumps(input.input, default=str) - input_input = json.loads(input_json) - - def make_tool(tool: ToolInput) -> Tool: - if isinstance( - tool, - ( - FileSearchTool, - WebSearchTool, - ImageGenerationTool, - CodeInterpreterTool, - ), - ): - return tool - elif isinstance(tool, HostedMCPToolInput): - return HostedMCPTool( - tool_config=tool.tool_config, - ) - elif isinstance(tool, FunctionToolInput): - return FunctionTool( - name=tool.name, - description=tool.description, - params_json_schema=tool.params_json_schema, - on_invoke_tool=empty_on_invoke_tool, - strict_json_schema=tool.strict_json_schema, - ) - else: - raise UserError(f"Unknown tool type: {tool.name}") - - tools = [make_tool(x) for x in input.tools] - handoffs: list[Handoff[Any, Any]] = [ - Handoff( - tool_name=x.tool_name, - tool_description=x.tool_description, - input_json_schema=x.input_json_schema, - agent_name=x.agent_name, - strict_json_schema=x.strict_json_schema, - on_invoke_handoff=empty_on_invoke_handoff, - ) - for x in input.handoffs + # Avoid https://github.com/pydantic/pydantic/issues/9541 + normalized_input = json.loads(json.dumps(input.input, default=str)) + + # Convert durable tools to agent tools + tools = [ + create_tool_from_durable_tool(durable_tool) + for durable_tool in input.tools + ] + + # Convert handoff descriptors to agent handoffs + handoffs = [ + durable_handoff.to_handoff() + for durable_handoff in input.handoffs ] return await model.get_response( system_instructions=input.system_instructions, - input=input_input, + input=normalized_input, model_settings=input.model_settings, tools=tools, output_schema=input.output_schema, @@ -282,40 +193,11 @@ async def get_response( conversation_id: Optional[str] = None, ) -> ModelResponse: """Get a response from the model.""" - def make_tool_info(tool: Tool) -> ToolInput: - if isinstance( - tool, - ( - FileSearchTool, - WebSearchTool, - ImageGenerationTool, - CodeInterpreterTool, - ), - ): - return tool - elif isinstance(tool, HostedMCPTool): - return HostedMCPToolInput(tool_config=tool.tool_config) - elif isinstance(tool, FunctionTool): - return FunctionToolInput( - name=tool.name, - description=tool.description, - params_json_schema=tool.params_json_schema, - strict_json_schema=tool.strict_json_schema, - ) - else: - raise ValueError(f"Unsupported tool type: {tool.name}") - - tool_infos = [make_tool_info(x) for x in tools] - handoff_infos = [ - HandoffInput( - tool_name=x.tool_name, - tool_description=x.tool_description, - input_json_schema=x.input_json_schema, - agent_name=x.agent_name, - strict_json_schema=x.strict_json_schema, - ) - for x in handoffs - ] + # Convert agent tools to Durable tools + durable_tools = [convert_tool_to_durable_tool(tool) for tool in tools] + + # Convert agent handoffs to Durable handoff descriptors + durable_handoffs = [DurableHandoff.from_handoff(handoff) for handoff in handoffs] if output_schema is not None and not isinstance( output_schema, AgentOutputSchema ): @@ -323,29 +205,30 @@ def make_tool_info(tool: Tool) -> ToolInput: f"Only AgentOutputSchema is supported by Durable Model, " f"got {type(output_schema).__name__}" ) - agent_output_schema = output_schema + output_schema_input = ( None - if agent_output_schema is None - else AgentOutputSchemaInput( - output_type_name=agent_output_schema.name(), - is_wrapped=agent_output_schema._is_wrapped, - output_schema=agent_output_schema.json_schema() - if not agent_output_schema.is_plain_text() - else None, - strict_json_schema=agent_output_schema.is_strict_json_schema(), + if output_schema is None + else DurableAgentOutputSchema( + output_type_name=output_schema.name(), + output_schema=( + output_schema.json_schema() + if not output_schema.is_plain_text() + else None + ), + strict_json_schema=output_schema.is_strict_json_schema(), ) ) - activity_input = ActivityModelInput( + activity_input = DurableModelActivityInput( model_name=self.model_name, system_instructions=system_instructions, input=cast(Union[str, list[TResponseInputItem]], input), model_settings=model_settings, - tools=tool_infos, + tools=durable_tools, output_schema=output_schema_input, - handoffs=handoff_infos, - tracing=ModelTracingInput.DISABLED, # ModelTracingInput(tracing.value), + handoffs=durable_handoffs, + tracing=ModelTracingLevel.DISABLED, # ModelTracingLevel(tracing.value), previous_response_id=previous_response_id, prompt=prompt, ) @@ -360,7 +243,8 @@ def make_tool_info(tool: Tool) -> ToolInput: ) else: response = self.task_tracker.get_activity_call_result( - self.activity_name, activity_input_json + self.activity_name, + activity_input_json ) json_response = json.loads(response) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index 8e5a1314..6cc163c7 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -1,10 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from functools import partial from typing import Optional from agents import ModelProvider, ModelResponse from agents.run import set_default_agent_runner from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext from azure.durable_functions.models.RetryOptions import RetryOptions -from .model_invocation_activity import ActivityModelInput, ModelInvoker +from .model_invocation_activity import DurableModelActivityInput, ModelInvoker from .task_tracker import TaskTracker from .runner import DurableOpenAIRunner from .context import DurableAIAgentContext @@ -14,7 +16,7 @@ async def durable_openai_agent_activity(input: str, model_provider: ModelProvider) -> str: """Activity logic that handles OpenAI model invocations.""" - activity_input = ActivityModelInput.from_json(input) + activity_input = DurableModelActivityInput.from_json(input) model_invoker = ModelInvoker(model_provider=model_provider) result = await model_invoker.invoke_model_activity(activity_input) diff --git a/azure/durable_functions/openai_agents/runner.py b/azure/durable_functions/openai_agents/runner.py index af8fb28d..43981607 100644 --- a/azure/durable_functions/openai_agents/runner.py +++ b/azure/durable_functions/openai_agents/runner.py @@ -1,5 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import json -import logging from dataclasses import replace from typing import Any, Union @@ -17,65 +18,70 @@ from .context import DurableAIAgentContext from .model_invocation_activity import DurableActivityModel -logger = logging.getLogger(__name__) - class DurableOpenAIRunner: """Runner for OpenAI agents using Durable Functions orchestration.""" def __init__(self, context: DurableAIAgentContext, activity_name: str) -> None: self._runner = DEFAULT_AGENT_RUNNER or AgentRunner() - self.context = context - self.activity_name = activity_name + self._context = context + self._activity_name = activity_name - def run_sync( + def _prepare_run_config( self, starting_agent: Agent[TContext], input: Union[str, list[TResponseInputItem]], **kwargs: Any, - ) -> RunResult: - """Run an agent synchronously with the given input and configuration.""" - # workaround for https://github.com/pydantic/pydantic/issues/9541 - # ValidatorIterator returned - input_json = to_json(input) - input = json.loads(input_json) + ) -> tuple[Union[str, list[TResponseInputItem]], RunConfig, dict[str, Any]]: + """Prepare and validate the run configuration and parameters for agent execution.""" + # Avoid https://github.com/pydantic/pydantic/issues/9541 + normalized_input = json.loads(to_json(input)) - context = kwargs.get("context") - max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS) - hooks = kwargs.get("hooks") - run_config = kwargs.get("run_config") - previous_response_id = kwargs.get("previous_response_id") - session = kwargs.get("session") - - if run_config is None: - run_config = RunConfig() + run_config = kwargs.get("run_config") or RunConfig() model_name = run_config.model or starting_agent.model - if model_name is not None and not isinstance(model_name, str): + if model_name and not isinstance(model_name, str): raise ValueError( - "Durable Functions require a model name to be a string in the " - "run config and/or agent." + "For agent execution in Durable Functions, model name in run_config or " + "starting_agent must be a string." ) updated_run_config = replace( run_config, model=DurableActivityModel( model_name=model_name, - task_tracker=self.context._task_tracker, - retry_options=self.context._model_retry_options, - activity_name=self.activity_name, + task_tracker=self._context._task_tracker, + retry_options=self._context._model_retry_options, + activity_name=self._activity_name, ), ) + run_params = { + "context": kwargs.get("context"), + "max_turns": kwargs.get("max_turns", DEFAULT_MAX_TURNS), + "hooks": kwargs.get("hooks"), + "previous_response_id": kwargs.get("previous_response_id"), + "session": kwargs.get("session"), + } + + return normalized_input, updated_run_config, run_params + + def run_sync( + self, + starting_agent: Agent[TContext], + input: Union[str, list[TResponseInputItem]], + **kwargs: Any, + ) -> RunResult: + """Run an agent synchronously with the given input and configuration.""" + normalized_input, updated_run_config, run_params = self._prepare_run_config( + starting_agent, input, **kwargs + ) + return self._runner.run_sync( starting_agent=starting_agent, - input=input, - context=context, - max_turns=max_turns, - hooks=hooks, + input=normalized_input, run_config=updated_run_config, - previous_response_id=previous_response_id, - session=session, + **run_params, ) def run( diff --git a/azure/durable_functions/openai_agents/task_tracker.py b/azure/durable_functions/openai_agents/task_tracker.py index f4bcdb65..2c68cb13 100644 --- a/azure/durable_functions/openai_agents/task_tracker.py +++ b/azure/durable_functions/openai_agents/task_tracker.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import json import inspect from typing import Any diff --git a/azure/durable_functions/openai_agents/tools.py b/azure/durable_functions/openai_agents/tools.py new file mode 100644 index 00000000..1ff6b543 --- /dev/null +++ b/azure/durable_functions/openai_agents/tools.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Tool conversion utilities for Azure Durable Functions OpenAI agent operations.""" + +from typing import Any, Union + +from agents import ( + CodeInterpreterTool, + FileSearchTool, + FunctionTool, + HostedMCPTool, + ImageGenerationTool, + Tool, + UserError, + WebSearchTool, +) +from openai.types.responses.tool_param import Mcp +from pydantic import BaseModel + + +# Built-in tool types that can be serialized directly without conversion +BUILT_IN_TOOL_TYPES = ( + FileSearchTool, + WebSearchTool, + ImageGenerationTool, + CodeInterpreterTool, +) + + +class DurableFunctionTool(BaseModel): + """Serializable representation of a FunctionTool. + + Contains only the data needed by the model execution to + determine what tool to call, not the actual tool invocation. + """ + + name: str + description: str + params_json_schema: dict[str, Any] + strict_json_schema: bool = True + + +class DurableMCPToolConfig(BaseModel): + """Serializable representation of a HostedMCPTool. + + Contains only the data needed by the model execution to + determine what tool to call, not the actual tool invocation. + """ + + tool_config: Mcp + + +DurableTool = Union[ + DurableFunctionTool, + FileSearchTool, + WebSearchTool, + ImageGenerationTool, + CodeInterpreterTool, + DurableMCPToolConfig, +] + + +def create_tool_from_durable_tool( + durable_tool: DurableTool, +) -> Tool: + """Convert a DurableTool to an OpenAI agent Tool for execution. + + This function transforms Durable Functions tool definitions into actual + OpenAI agent Tool instances that can be used during model execution. + + Parameters + ---------- + durable_tool : DurableTool + The Durable tool definition to convert + + Returns + ------- + Tool + An OpenAI agent Tool instance ready for execution + + Raises + ------ + UserError + If the tool type is not supported + """ + # Built-in tools that don't need conversion + if isinstance(durable_tool, BUILT_IN_TOOL_TYPES): + return durable_tool + + # Convert Durable MCP tool configuration to HostedMCPTool + if isinstance(durable_tool, DurableMCPToolConfig): + return HostedMCPTool( + tool_config=durable_tool.tool_config, + ) + + # Convert Durable function tool to FunctionTool + if isinstance(durable_tool, DurableFunctionTool): + return FunctionTool( + name=durable_tool.name, + description=durable_tool.description, + params_json_schema=durable_tool.params_json_schema, + on_invoke_tool=lambda ctx, input: "", + strict_json_schema=durable_tool.strict_json_schema, + ) + + raise UserError(f"Unsupported tool type: {durable_tool}") + + +def convert_tool_to_durable_tool(tool: Tool) -> DurableTool: + """Convert an OpenAI agent Tool to a DurableTool for serialization. + + This function transforms OpenAI agent Tool instances into Durable Functions + tool definitions that can be serialized and passed to activities. + + Parameters + ---------- + tool : Tool + The OpenAI agent Tool to convert + + Returns + ------- + DurableTool + A serializable tool definition + + Raises + ------ + ValueError + If the tool type is not supported for conversion + """ + # Built-in tools that can be serialized directly + if isinstance(tool, BUILT_IN_TOOL_TYPES): + return tool + + # Convert HostedMCPTool to Durable MCP configuration + elif isinstance(tool, HostedMCPTool): + return DurableMCPToolConfig(tool_config=tool.tool_config) + + # Convert FunctionTool to Durable function tool + elif isinstance(tool, FunctionTool): + return DurableFunctionTool( + name=tool.name, + description=tool.description, + params_json_schema=tool.params_json_schema, + strict_json_schema=tool.strict_json_schema, + ) + + else: + raise ValueError(f"Unsupported tool type for Durable Functions: {type(tool).__name__}") diff --git a/samples-v2/openai_agents/basic/agent_lifecycle_example.py b/samples-v2/openai_agents/basic/agent_lifecycle_example.py index c49d5362..84fa09c9 100644 --- a/samples-v2/openai_agents/basic/agent_lifecycle_example.py +++ b/samples-v2/openai_agents/basic/agent_lifecycle_example.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import random from typing import Any diff --git a/samples-v2/openai_agents/basic/dynamic_system_prompt.py b/samples-v2/openai_agents/basic/dynamic_system_prompt.py index f2cb536d..4a1e8e9b 100644 --- a/samples-v2/openai_agents/basic/dynamic_system_prompt.py +++ b/samples-v2/openai_agents/basic/dynamic_system_prompt.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import random from typing import Literal diff --git a/samples-v2/openai_agents/basic/hello_world.py b/samples-v2/openai_agents/basic/hello_world.py index 5df37d01..57c4ca98 100644 --- a/samples-v2/openai_agents/basic/hello_world.py +++ b/samples-v2/openai_agents/basic/hello_world.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from agents import Agent, Runner diff --git a/samples-v2/openai_agents/basic/lifecycle_example.py b/samples-v2/openai_agents/basic/lifecycle_example.py index b6f73d98..0e9d4973 100644 --- a/samples-v2/openai_agents/basic/lifecycle_example.py +++ b/samples-v2/openai_agents/basic/lifecycle_example.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import random from typing import Any, Optional diff --git a/samples-v2/openai_agents/basic/local_image.py b/samples-v2/openai_agents/basic/local_image.py index 8be48571..53d29380 100644 --- a/samples-v2/openai_agents/basic/local_image.py +++ b/samples-v2/openai_agents/basic/local_image.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from agents import Agent, Runner diff --git a/samples-v2/openai_agents/basic/non_strict_output_type.py b/samples-v2/openai_agents/basic/non_strict_output_type.py index 700d2798..e8271a40 100644 --- a/samples-v2/openai_agents/basic/non_strict_output_type.py +++ b/samples-v2/openai_agents/basic/non_strict_output_type.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from pydantic import BaseModel from typing import Optional diff --git a/samples-v2/openai_agents/basic/previous_response_id.py b/samples-v2/openai_agents/basic/previous_response_id.py index 0df5245c..b525234d 100644 --- a/samples-v2/openai_agents/basic/previous_response_id.py +++ b/samples-v2/openai_agents/basic/previous_response_id.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from agents import Agent, Runner diff --git a/samples-v2/openai_agents/basic/remote_image.py b/samples-v2/openai_agents/basic/remote_image.py index 8112409a..bbb3eec2 100644 --- a/samples-v2/openai_agents/basic/remote_image.py +++ b/samples-v2/openai_agents/basic/remote_image.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from agents import Agent, Runner diff --git a/samples-v2/openai_agents/basic/tools.py b/samples-v2/openai_agents/basic/tools.py index 39cc8b8c..c8937589 100644 --- a/samples-v2/openai_agents/basic/tools.py +++ b/samples-v2/openai_agents/basic/tools.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from pydantic import BaseModel from agents import Agent, Runner, function_tool diff --git a/samples-v2/openai_agents/function_app.py b/samples-v2/openai_agents/function_app.py index c83ce7d2..68805de7 100644 --- a/samples-v2/openai_agents/function_app.py +++ b/samples-v2/openai_agents/function_app.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import os import random diff --git a/samples-v2/openai_agents/handoffs/message_filter.py b/samples-v2/openai_agents/handoffs/message_filter.py index 21871ca2..53bada31 100644 --- a/samples-v2/openai_agents/handoffs/message_filter.py +++ b/samples-v2/openai_agents/handoffs/message_filter.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from __future__ import annotations import json diff --git a/samples-v2/openai_agents/test_orchestrators.py b/samples-v2/openai_agents/test_orchestrators.py index 2b363d09..63bdf223 100755 --- a/samples-v2/openai_agents/test_orchestrators.py +++ b/samples-v2/openai_agents/test_orchestrators.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. """ Test script for OpenAI Agents with Durable Functions Extension This script tests all orchestrators as specified in the instructions document. diff --git a/tests/openai_agents/test_context.py b/tests/openai_agents/test_context.py index 6b9d9389..155426d7 100644 --- a/tests/openai_agents/test_context.py +++ b/tests/openai_agents/test_context.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import pytest from unittest.mock import Mock, patch diff --git a/tests/openai_agents/test_task_tracker.py b/tests/openai_agents/test_task_tracker.py index 46d8e7f7..92ba93ba 100644 --- a/tests/openai_agents/test_task_tracker.py +++ b/tests/openai_agents/test_task_tracker.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import pytest import json from unittest.mock import Mock diff --git a/tests/openai_agents/test_usage_telemetry.py b/tests/openai_agents/test_usage_telemetry.py index 05f8f09d..4f911b24 100644 --- a/tests/openai_agents/test_usage_telemetry.py +++ b/tests/openai_agents/test_usage_telemetry.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import unittest.mock diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py index aa2f1e6a..a351985d 100644 --- a/tests/orchestrator/openai_agents/test_openai_agents.py +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import azure.durable_functions as df import azure.functions as func import json