From 2e9184652df88463f9c2d50103fe037fdb9a381d Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 11:32:05 -0700 Subject: [PATCH 01/20] Scaffold some basic tests. --- .../openai_agents/decorators.py | 117 ++++++++++-------- .../model_invocation_activity.py | 5 +- tests/orchestrator/test_openai_agents.py | 70 +++++++++++ 3 files changed, 135 insertions(+), 57 deletions(-) create mode 100644 tests/orchestrator/test_openai_agents.py diff --git a/azure/durable_functions/openai_agents/decorators.py b/azure/durable_functions/openai_agents/decorators.py index ace8e29a..0a3497f1 100644 --- a/azure/durable_functions/openai_agents/decorators.py +++ b/azure/durable_functions/openai_agents/decorators.py @@ -2,6 +2,7 @@ import inspect import sys import azure.functions as func +from agents import ModelProvider from agents.run import set_default_agent_runner from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext from .runner import DurableOpenAIRunner @@ -15,14 +16,14 @@ _registered_apps = set() -def _setup_durable_openai_agent(app: func.FunctionApp): +def _setup_durable_openai_agent(app: func.FunctionApp, model_provider: ModelProvider | None): """ Set up the Durable OpenAI Agent framework for the given FunctionApp. This is automatically called when using the framework decorators. """ app_id = id(app) if app_id not in _registered_apps: - create_invoke_model_activity(app) + create_invoke_model_activity(app, model_provider) _registered_apps.add(app_id) @@ -40,7 +41,7 @@ def _find_function_app_in_module(module): return None -def _auto_setup_durable_openai_agent(decorated_func): +def _auto_setup_durable_openai_agent(decorated_func, model_provider: ModelProvider | None): """ Automatically detect and setup the FunctionApp for Durable OpenAI Agents. This finds the FunctionApp in the same module as the decorated function. @@ -54,66 +55,72 @@ def _auto_setup_durable_openai_agent(decorated_func): # Find the FunctionApp instance in that module app = _find_function_app_in_module(func_module) if app is not None: - _setup_durable_openai_agent(app) + _setup_durable_openai_agent(app, model_provider) except Exception: # Silently fail if auto-setup doesn't work # The user can still manually call create_invoke_model_activity if needed pass -def durable_openai_agent_orchestrator(func): +def durable_openai_agent_orchestrator(_func=None, *, model_provider: ModelProvider | None = None): # Auto-setup: Find and configure the FunctionApp when decorator is applied - _auto_setup_durable_openai_agent(func) - - @wraps(func) - def wrapper(durable_orchestration_context: DurableOrchestrationContext): - ensure_event_loop() - durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) - durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) - set_default_agent_runner(durable_openai_runner) + def wrapper_wrapper(func): + _auto_setup_durable_openai_agent(func, model_provider) - if inspect.isgeneratorfunction(func): - gen = iter(func(durable_ai_agent_context)) - try: - # prime the subiterator - value = next(gen) - yield from durable_ai_agent_context._yield_and_clear_tasks() - while True: - try: - # send whatever was sent into us down to the subgenerator - yield from durable_ai_agent_context._yield_and_clear_tasks() - sent = yield value - except GeneratorExit: - # ensure the subgenerator is closed - if hasattr(gen, "close"): - gen.close() - raise - except BaseException as exc: - # forward thrown exceptions if possible - if hasattr(gen, "throw"): - value = gen.throw(type(exc), exc, exc.__traceback__) - else: + @wraps(func) + def wrapper(durable_orchestration_context: DurableOrchestrationContext): + ensure_event_loop() + durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) + durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) + set_default_agent_runner(durable_openai_runner) + + if inspect.isgeneratorfunction(func): + gen = iter(func(durable_ai_agent_context)) + try: + # prime the subiterator + value = next(gen) + yield from durable_ai_agent_context._yield_and_clear_tasks() + while True: + try: + # send whatever was sent into us down to the subgenerator + yield from durable_ai_agent_context._yield_and_clear_tasks() + sent = yield value + except GeneratorExit: + # ensure the subgenerator is closed + if hasattr(gen, "close"): + gen.close() raise - else: - # normal path: forward .send (or .__next__) - if hasattr(gen, "send"): - value = gen.send(sent) + except BaseException as exc: + # forward thrown exceptions if possible + if hasattr(gen, "throw"): + value = gen.throw(type(exc), exc, exc.__traceback__) + else: + raise else: - value = next(gen) - except StopIteration as e: - yield from durable_ai_agent_context._yield_and_clear_tasks() - return e.value - except YieldException as e: - yield from durable_ai_agent_context._yield_and_clear_tasks() - yield e.task - else: - try: - result = func(durable_ai_agent_context) - return result - except YieldException as e: - yield from durable_ai_agent_context._yield_and_clear_tasks() - yield e.task - finally: - yield from durable_ai_agent_context._yield_and_clear_tasks() + # normal path: forward .send (or .__next__) + if hasattr(gen, "send"): + value = gen.send(sent) + else: + value = next(gen) + except StopIteration as e: + yield from durable_ai_agent_context._yield_and_clear_tasks() + return e.value + except YieldException as e: + yield from durable_ai_agent_context._yield_and_clear_tasks() + yield e.task + else: + try: + result = func(durable_ai_agent_context) + return result + except YieldException as e: + yield from durable_ai_agent_context._yield_and_clear_tasks() + yield e.task + finally: + yield from durable_ai_agent_context._yield_and_clear_tasks() + + return wrapper - return wrapper \ No newline at end of file + if _func is None: + return wrapper_wrapper + else: + return wrapper_wrapper(_func) diff --git a/azure/durable_functions/openai_agents/model_invocation_activity.py b/azure/durable_functions/openai_agents/model_invocation_activity.py index 2f5dea16..255ca45a 100644 --- a/azure/durable_functions/openai_agents/model_invocation_activity.py +++ b/azure/durable_functions/openai_agents/model_invocation_activity.py @@ -293,6 +293,7 @@ async def get_response( tracing: ModelTracing, *, previous_response_id: Optional[str], + conversation_id: Optional[str], prompt: Optional[ResponsePromptParam], ) -> ModelResponse: def make_tool_info(tool: Tool) -> ToolInput: @@ -385,7 +386,7 @@ def stream_response( raise NotImplementedError("Durable model doesn't support streams yet") -def create_invoke_model_activity(app: func.FunctionApp): +def create_invoke_model_activity(app: func.FunctionApp, model_provider: ModelProvider | None): """Create and register the invoke_model_activity function with the provided FunctionApp.""" @app.activity_trigger(input_name="input") @@ -393,7 +394,7 @@ async def invoke_model_activity(input: str): """Activity that handles OpenAI model invocations.""" activity_input = ActivityModelInput.from_json(input) - model_invoker = ModelInvoker() + model_invoker = ModelInvoker(model_provider=model_provider) result = await model_invoker.invoke_model_activity(activity_input) json_obj = ModelResponse.__pydantic_serializer__.to_json(result) diff --git a/tests/orchestrator/test_openai_agents.py b/tests/orchestrator/test_openai_agents.py new file mode 100644 index 00000000..187342bf --- /dev/null +++ b/tests/orchestrator/test_openai_agents.py @@ -0,0 +1,70 @@ +import azure.durable_functions as df +import azure.functions as func +import json +from agents import Agent, Runner +from azure.durable_functions.models import OrchestratorState +from azure.durable_functions.models.actions import CallActivityAction +from azure.durable_functions.models.ReplaySchema import ReplaySchema +from azure.durable_functions.openai_agents import durable_openai_agent_orchestrator +from tests.orchestrator.orchestrator_test_utils import get_orchestration_state_result, assert_valid_schema, \ + assert_orchestration_state_equals +from tests.test_utils.ContextBuilder import ContextBuilder + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +@app.function_name("openai_agent_input_with_string") +@app.orchestration_trigger(context_name="context") +@durable_openai_agent_orchestrator +def openai_agent_input_with_string(context): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + model="gpt-4.1", + ) + + result = Runner.run_sync(agent, "Tell me about recursion in programming.") + + return result.final_output; + +def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: + return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema) + +def add_hello_action(state: OrchestratorState, input_: str, activity_name="invoke_model_activity"): + action = CallActivityAction(function_name=activity_name, input_=input_) + state.actions.append([action]) + +def add_hello_completed_events( + context_builder: ContextBuilder, id_: int, result: str, is_played=False, activity_name="invoke_model_activity"): + context_builder.add_task_scheduled_event(name=activity_name, id_=id_) + context_builder.add_orchestrator_completed_event() + context_builder.add_orchestrator_started_event() + context_builder.add_task_completed_event(id_=id_, result=json.dumps(result), is_played=is_played) + +def test_openai_agent_hello_world_start(): + context_builder = ContextBuilder('test_openai_agent_hello_world_start') + + result = get_orchestration_state_result( + context_builder, openai_agent_input_with_string, uses_pystein=True) + + expected_state = base_expected_state() + add_hello_action(expected_state, "{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"verbosity\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"top_logprobs\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":\"gpt-4.1\",\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_input_hello_world_completed(): + context_builder = ContextBuilder('test_openai_agent_input_hello_world_completed') + add_hello_completed_events(context_builder, 0, '{"output":[{"id":"msg_68b9b2a9c67c81a38559c20c18fe86040a86c28ba39b53e8","content":[{"annotations":[],"text":"Skyscrapers whisper— \\nTaxis hum beneath the lights, \\nCity dreams don’t sleep.","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message"}],"usage":{"requests":1,"input_tokens":27,"input_tokens_details":{"cached_tokens":0},"output_tokens":21,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":48},"response_id":"resp_68b9b2a9461481a3984d0f790dd33f7b0a86c28ba39b53e8"}') + + result = get_orchestration_state_result( + context_builder, openai_agent_input_with_string, uses_pystein=True) + + expected_state = base_expected_state() + add_hello_action(expected_state, "{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"verbosity\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"top_logprobs\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":\"gpt-4.1\",\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + expected_state._is_done = True + expected_state._output = 'Skyscrapers whisper— \nTaxis hum beneath the lights, \nCity dreams don’t sleep.' + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) From b8115487d41eda7dffbf8078a76b931a729d4db6 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 12:25:24 -0700 Subject: [PATCH 02/20] Move OpenAI tests. --- requirements.txt | 6 ++++-- tests/orchestrator/openai_agents/__init__.py | 0 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 tests/orchestrator/openai_agents/__init__.py diff --git a/requirements.txt b/requirements.txt index a853b88a..0ba77721 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ flake8-docstrings==1.5.0 pytest==7.1.2 python-dateutil==2.8.0 requests==2.32.4 -jsonschema==3.2.0 +jsonschema==4.25.1 aiohttp==3.12.14 azure-functions>=1.11.3b3 nox==2019.11.9 @@ -12,4 +12,6 @@ pytest-asyncio==0.20.2 autopep8 types-python-dateutil opentelemetry-api==1.32.1 -opentelemetry-sdk==1.32.1 \ No newline at end of file +opentelemetry-sdk==1.32.1 +openai==1.98.0 +openai-agents==0.2.4 diff --git a/tests/orchestrator/openai_agents/__init__.py b/tests/orchestrator/openai_agents/__init__.py new file mode 100644 index 00000000..e69de29b From c962be037b28f86dd8e2e368d49ace07d8b0d359 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 13:02:01 -0700 Subject: [PATCH 03/20] Move OpenAI tests. --- .../openai_agents/test_openai_agents.py | 118 ++++++++++++++++++ tests/orchestrator/test_openai_agents.py | 70 ----------- 2 files changed, 118 insertions(+), 70 deletions(-) create mode 100644 tests/orchestrator/openai_agents/test_openai_agents.py delete mode 100644 tests/orchestrator/test_openai_agents.py diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py new file mode 100644 index 00000000..ad45e95d --- /dev/null +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -0,0 +1,118 @@ +import azure.durable_functions as df +import azure.functions as func +import json +from agents import Agent, Runner +from azure.durable_functions.models import OrchestratorState +from azure.durable_functions.models.actions import CallActivityAction +from azure.durable_functions.models.ReplaySchema import ReplaySchema +from azure.durable_functions.openai_agents import durable_openai_agent_orchestrator +from openai import BaseModel +from tests.orchestrator.orchestrator_test_utils import get_orchestration_state_result, assert_valid_schema, \ + assert_orchestration_state_equals +from tests.test_utils.ContextBuilder import ContextBuilder + +app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +@app.function_name("openai_agent_hello_world") +@app.orchestration_trigger(context_name="context") +@durable_openai_agent_orchestrator +def openai_agent_hello_world(context): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus." + ) + + result = Runner.run_sync(agent, "Tell me about recursion in programming.") + + return result.final_output; + +# +# Run an agent that uses various tools. +# +class Weather(BaseModel): + city: str + temperature_range: str + conditions: str + + @staticmethod + def from_json(data: str) -> "Weather": + return Weather(**json.loads(data)) + +@app.activity_trigger(input_name="city") +def get_weather(city: str) -> Weather: + print("[debug] get_weather called") + return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") + +@app.function_name("openai_agent_use_tool") +@app.orchestration_trigger(context_name="context") +@durable_openai_agent_orchestrator +def openai_agent_use_tool(context): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + tools=[context.activity_as_tool(get_weather)] + ) + + result = Runner.run_sync(agent, "Tell me the weather in Seattle.", ) + + return result.final_output; + +model_activity_name = "invoke_model_activity" + +def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: + return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema) + +def add_hello_action(state: OrchestratorState, input_: str, activity_name=model_activity_name): + action = CallActivityAction(function_name=activity_name, input_=input_) + state.actions.append([action]) + +def add_hello_completed_events( + context_builder: ContextBuilder, id_: int, result: str, is_played=False, activity_name=model_activity_name): + context_builder.add_task_scheduled_event(name=activity_name, id_=id_) + context_builder.add_orchestrator_completed_event() + context_builder.add_orchestrator_started_event() + context_builder.add_task_completed_event(id_=id_, result=json.dumps(result), is_played=is_played) + +def test_openai_agent_hello_world_start(): + context_builder = ContextBuilder('test_openai_agent_hello_world_start') + + result = get_orchestration_state_result( + context_builder, openai_agent_hello_world, uses_pystein=True) + + expected_state = base_expected_state() + add_hello_action(expected_state,'{"input":[{"content":"Tell me about recursion in programming.","role":"user"}],"model_settings":{"temperature":null,"top_p":null,"frequency_penalty":null,"presence_penalty":null,"tool_choice":null,"parallel_tool_calls":null,"truncation":null,"max_tokens":null,"reasoning":null,"verbosity":null,"metadata":null,"store":null,"include_usage":null,"response_include":null,"top_logprobs":null,"extra_query":null,"extra_body":null,"extra_headers":null,"extra_args":null},"tracing":0,"model_name":"gpt-4.1","system_instructions":"You only respond in haikus.","tools":[],"output_schema":null,"handoffs":[],"previous_response_id":null,"prompt":null}') + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_hello_world_completed(): + context_builder = ContextBuilder('test_openai_agent_hello_world_completed') + add_hello_completed_events(context_builder, 0, '{"output":[{"id":"msg_68b9b2a9c67c81a38559c20c18fe86040a86c28ba39b53e8","content":[{"annotations":[],"text":"Skyscrapers whisper— \\nTaxis hum beneath the lights, \\nCity dreams don’t sleep.","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message"}],"usage":{"requests":1,"input_tokens":27,"input_tokens_details":{"cached_tokens":0},"output_tokens":21,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":48},"response_id":"resp_68b9b2a9461481a3984d0f790dd33f7b0a86c28ba39b53e8"}') + + result = get_orchestration_state_result( + context_builder, openai_agent_hello_world, uses_pystein=True) + + expected_state = base_expected_state() + add_hello_action(expected_state,"{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"verbosity\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"top_logprobs\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":\"gpt-4.1\",\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + expected_state._is_done = True + expected_state._output = 'Skyscrapers whisper— \nTaxis hum beneath the lights, \nCity dreams don’t sleep.' + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_use_tool_activity_start(): + context_builder = ContextBuilder('test_openai_agent_use_tool_start') + add_hello_completed_events(context_builder, 0, '{"output":[{"arguments":"{\\"args\\":\\"Seattle, WA\\"}","call_id":"call_mEdywElQTNpxAdivuEFjO0cT","name":"get_weather","type":"function_call","id":"fc_68b9ecc0ff9c819f863d6cf9e0a1b4e101011fd6f5f8c0a6","status":"completed"}],"usage":{"requests":1,"input_tokens":57,"input_tokens_details":{"cached_tokens":0},"output_tokens":17,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":74},"response_id":"resp_68b9ecc092e0819fb79b97c11aacef2001011fd6f5f8c0a6"}') + + result = get_orchestration_state_result( + context_builder, openai_agent_use_tool, uses_pystein=True) + + expected_state = base_expected_state() + add_hello_action(expected_state,"{\"input\": [{\"content\": \"What's the weather in Seattle, WA?\", \"role\": \"user\"}], \"instance_id\": \"91333bd1a0a94b4d8a73820516cf73f6\", \"model_name\": \"gpt-4.1\", \"output_schema\": null, \"system_instructions\": \"You are a helpful agent.\", \"tools\": [{\"description\": \"\", \"name\": \"get_weather\", \"params_json_schema\": {\"properties\": {\"args\": {\"title\": \"Args\", \"type\": \"string\"}}, \"required\": [\"args\"], \"title\": \"get_weather_args\", \"type\": \"object\"}, \"strict_json_schema\": false}]}") + add_hello_action(expected_state, '"{\\"args\\":\\"Seattle, WA\\"}"', activity_name="get_weather") + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) diff --git a/tests/orchestrator/test_openai_agents.py b/tests/orchestrator/test_openai_agents.py deleted file mode 100644 index 187342bf..00000000 --- a/tests/orchestrator/test_openai_agents.py +++ /dev/null @@ -1,70 +0,0 @@ -import azure.durable_functions as df -import azure.functions as func -import json -from agents import Agent, Runner -from azure.durable_functions.models import OrchestratorState -from azure.durable_functions.models.actions import CallActivityAction -from azure.durable_functions.models.ReplaySchema import ReplaySchema -from azure.durable_functions.openai_agents import durable_openai_agent_orchestrator -from tests.orchestrator.orchestrator_test_utils import get_orchestration_state_result, assert_valid_schema, \ - assert_orchestration_state_equals -from tests.test_utils.ContextBuilder import ContextBuilder - -app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS) - -@app.function_name("openai_agent_input_with_string") -@app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator -def openai_agent_input_with_string(context): - agent = Agent( - name="Assistant", - instructions="You only respond in haikus.", - model="gpt-4.1", - ) - - result = Runner.run_sync(agent, "Tell me about recursion in programming.") - - return result.final_output; - -def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: - return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema) - -def add_hello_action(state: OrchestratorState, input_: str, activity_name="invoke_model_activity"): - action = CallActivityAction(function_name=activity_name, input_=input_) - state.actions.append([action]) - -def add_hello_completed_events( - context_builder: ContextBuilder, id_: int, result: str, is_played=False, activity_name="invoke_model_activity"): - context_builder.add_task_scheduled_event(name=activity_name, id_=id_) - context_builder.add_orchestrator_completed_event() - context_builder.add_orchestrator_started_event() - context_builder.add_task_completed_event(id_=id_, result=json.dumps(result), is_played=is_played) - -def test_openai_agent_hello_world_start(): - context_builder = ContextBuilder('test_openai_agent_hello_world_start') - - result = get_orchestration_state_result( - context_builder, openai_agent_input_with_string, uses_pystein=True) - - expected_state = base_expected_state() - add_hello_action(expected_state, "{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"verbosity\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"top_logprobs\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":\"gpt-4.1\",\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") - expected = expected_state.to_json() - - assert_valid_schema(result) - assert_orchestration_state_equals(expected, result) - -def test_openai_agent_input_hello_world_completed(): - context_builder = ContextBuilder('test_openai_agent_input_hello_world_completed') - add_hello_completed_events(context_builder, 0, '{"output":[{"id":"msg_68b9b2a9c67c81a38559c20c18fe86040a86c28ba39b53e8","content":[{"annotations":[],"text":"Skyscrapers whisper— \\nTaxis hum beneath the lights, \\nCity dreams don’t sleep.","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message"}],"usage":{"requests":1,"input_tokens":27,"input_tokens_details":{"cached_tokens":0},"output_tokens":21,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":48},"response_id":"resp_68b9b2a9461481a3984d0f790dd33f7b0a86c28ba39b53e8"}') - - result = get_orchestration_state_result( - context_builder, openai_agent_input_with_string, uses_pystein=True) - - expected_state = base_expected_state() - add_hello_action(expected_state, "{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"verbosity\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"top_logprobs\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":\"gpt-4.1\",\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") - expected_state._is_done = True - expected_state._output = 'Skyscrapers whisper— \nTaxis hum beneath the lights, \nCity dreams don’t sleep.' - expected = expected_state.to_json() - - assert_valid_schema(result) - assert_orchestration_state_equals(expected, result) From 18c9378a5a1ad068343585c43cc1628704ff5eb0 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 13:26:36 -0700 Subject: [PATCH 04/20] Add tests for tool use. --- .../model_invocation_activity.py | 1 - .../openai_agents/test_openai_agents.py | 53 ++++++++++++++++--- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/azure/durable_functions/openai_agents/model_invocation_activity.py b/azure/durable_functions/openai_agents/model_invocation_activity.py index 255ca45a..3405834a 100644 --- a/azure/durable_functions/openai_agents/model_invocation_activity.py +++ b/azure/durable_functions/openai_agents/model_invocation_activity.py @@ -293,7 +293,6 @@ async def get_response( tracing: ModelTracing, *, previous_response_id: Optional[str], - conversation_id: Optional[str], prompt: Optional[ResponsePromptParam], ) -> ModelResponse: def make_tool_info(tool: Tool) -> ToolInput: diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py index ad45e95d..e8ab3d16 100644 --- a/tests/orchestrator/openai_agents/test_openai_agents.py +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -62,11 +62,11 @@ def openai_agent_use_tool(context): def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema) -def add_hello_action(state: OrchestratorState, input_: str, activity_name=model_activity_name): +def add_activity_action(state: OrchestratorState, input_: str, activity_name=model_activity_name): action = CallActivityAction(function_name=activity_name, input_=input_) state.actions.append([action]) -def add_hello_completed_events( +def add_activity_completed_events( context_builder: ContextBuilder, id_: int, result: str, is_played=False, activity_name=model_activity_name): context_builder.add_task_scheduled_event(name=activity_name, id_=id_) context_builder.add_orchestrator_completed_event() @@ -80,7 +80,7 @@ def test_openai_agent_hello_world_start(): context_builder, openai_agent_hello_world, uses_pystein=True) expected_state = base_expected_state() - add_hello_action(expected_state,'{"input":[{"content":"Tell me about recursion in programming.","role":"user"}],"model_settings":{"temperature":null,"top_p":null,"frequency_penalty":null,"presence_penalty":null,"tool_choice":null,"parallel_tool_calls":null,"truncation":null,"max_tokens":null,"reasoning":null,"verbosity":null,"metadata":null,"store":null,"include_usage":null,"response_include":null,"top_logprobs":null,"extra_query":null,"extra_body":null,"extra_headers":null,"extra_args":null},"tracing":0,"model_name":"gpt-4.1","system_instructions":"You only respond in haikus.","tools":[],"output_schema":null,"handoffs":[],"previous_response_id":null,"prompt":null}') + add_activity_action(expected_state, "{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":null,\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") expected = expected_state.to_json() assert_valid_schema(result) @@ -88,13 +88,13 @@ def test_openai_agent_hello_world_start(): def test_openai_agent_hello_world_completed(): context_builder = ContextBuilder('test_openai_agent_hello_world_completed') - add_hello_completed_events(context_builder, 0, '{"output":[{"id":"msg_68b9b2a9c67c81a38559c20c18fe86040a86c28ba39b53e8","content":[{"annotations":[],"text":"Skyscrapers whisper— \\nTaxis hum beneath the lights, \\nCity dreams don’t sleep.","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message"}],"usage":{"requests":1,"input_tokens":27,"input_tokens_details":{"cached_tokens":0},"output_tokens":21,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":48},"response_id":"resp_68b9b2a9461481a3984d0f790dd33f7b0a86c28ba39b53e8"}') + add_activity_completed_events(context_builder, 0, '{"output":[{"id":"msg_68b9b2a9c67c81a38559c20c18fe86040a86c28ba39b53e8","content":[{"annotations":[],"text":"Skyscrapers whisper— \\nTaxis hum beneath the lights, \\nCity dreams don’t sleep.","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message"}],"usage":{"requests":1,"input_tokens":27,"input_tokens_details":{"cached_tokens":0},"output_tokens":21,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":48},"response_id":"resp_68b9b2a9461481a3984d0f790dd33f7b0a86c28ba39b53e8"}') result = get_orchestration_state_result( context_builder, openai_agent_hello_world, uses_pystein=True) expected_state = base_expected_state() - add_hello_action(expected_state,"{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"verbosity\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"top_logprobs\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":\"gpt-4.1\",\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + add_activity_action(expected_state, "{\"input\":[{\"content\":\"Tell me about recursion in programming.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":null,\"system_instructions\":\"You only respond in haikus.\",\"tools\":[],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") expected_state._is_done = True expected_state._output = 'Skyscrapers whisper— \nTaxis hum beneath the lights, \nCity dreams don’t sleep.' expected = expected_state.to_json() @@ -104,14 +104,51 @@ def test_openai_agent_hello_world_completed(): def test_openai_agent_use_tool_activity_start(): context_builder = ContextBuilder('test_openai_agent_use_tool_start') - add_hello_completed_events(context_builder, 0, '{"output":[{"arguments":"{\\"args\\":\\"Seattle, WA\\"}","call_id":"call_mEdywElQTNpxAdivuEFjO0cT","name":"get_weather","type":"function_call","id":"fc_68b9ecc0ff9c819f863d6cf9e0a1b4e101011fd6f5f8c0a6","status":"completed"}],"usage":{"requests":1,"input_tokens":57,"input_tokens_details":{"cached_tokens":0},"output_tokens":17,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":74},"response_id":"resp_68b9ecc092e0819fb79b97c11aacef2001011fd6f5f8c0a6"}') + add_activity_completed_events(context_builder, 0, '{"output":[{"arguments":"{\\"args\\":\\"Seattle, WA\\"}","call_id":"call_mEdywElQTNpxAdivuEFjO0cT","name":"get_weather","type":"function_call","id":"fc_68b9ecc0ff9c819f863d6cf9e0a1b4e101011fd6f5f8c0a6","status":"completed"}],"usage":{"requests":1,"input_tokens":57,"input_tokens_details":{"cached_tokens":0},"output_tokens":17,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":74},"response_id":"resp_68b9ecc092e0819fb79b97c11aacef2001011fd6f5f8c0a6"}') result = get_orchestration_state_result( context_builder, openai_agent_use_tool, uses_pystein=True) expected_state = base_expected_state() - add_hello_action(expected_state,"{\"input\": [{\"content\": \"What's the weather in Seattle, WA?\", \"role\": \"user\"}], \"instance_id\": \"91333bd1a0a94b4d8a73820516cf73f6\", \"model_name\": \"gpt-4.1\", \"output_schema\": null, \"system_instructions\": \"You are a helpful agent.\", \"tools\": [{\"description\": \"\", \"name\": \"get_weather\", \"params_json_schema\": {\"properties\": {\"args\": {\"title\": \"Args\", \"type\": \"string\"}}, \"required\": [\"args\"], \"title\": \"get_weather_args\", \"type\": \"object\"}, \"strict_json_schema\": false}]}") - add_hello_action(expected_state, '"{\\"args\\":\\"Seattle, WA\\"}"', activity_name="get_weather") + add_activity_action(expected_state, "{\"input\":[{\"content\":\"Tell me the weather in Seattle.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":null,\"system_instructions\":\"You only respond in haikus.\",\"tools\":[{\"name\":\"get_weather\",\"description\":\"\",\"params_json_schema\":{\"properties\":{\"city\":{\"title\":\"City\",\"type\":\"string\"}},\"required\":[\"city\"],\"title\":\"get_weather_args\",\"type\":\"object\",\"additionalProperties\":false},\"strict_json_schema\":true}],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + add_activity_action(expected_state, "{\"args\":\"Seattle, WA\"}", activity_name="get_weather") + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_use_tool_activity_completed(): + context_builder = ContextBuilder('test_openai_agent_use_tool_start') + add_activity_completed_events(context_builder, 0, '{"output":[{"arguments":"{\\"args\\":\\"Seattle, WA\\"}","call_id":"call_mEdywElQTNpxAdivuEFjO0cT","name":"get_weather","type":"function_call","id":"fc_68b9ecc0ff9c819f863d6cf9e0a1b4e101011fd6f5f8c0a6","status":"completed"}],"usage":{"requests":1,"input_tokens":57,"input_tokens_details":{"cached_tokens":0},"output_tokens":17,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":74},"response_id":"resp_68b9ecc092e0819fb79b97c11aacef2001011fd6f5f8c0a6"}') + add_activity_completed_events(context_builder, 1, '{"__class__":"Weather","__module__":"function_app","__data__":"{\n \"city\": \"{\\\"args\\\":\\\"Seattle, WA\\\"}\",\n \"temperature_range\": \"14-20C\",\n \"conditions\": \"Sunny with wind.\"\n}"}') + + result = get_orchestration_state_result( + context_builder, openai_agent_use_tool, uses_pystein=True) + + expected_state = base_expected_state() + add_activity_action(expected_state, "{\"input\":[{\"content\":\"Tell me the weather in Seattle.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":null,\"system_instructions\":\"You only respond in haikus.\",\"tools\":[{\"name\":\"get_weather\",\"description\":\"\",\"params_json_schema\":{\"properties\":{\"city\":{\"title\":\"City\",\"type\":\"string\"}},\"required\":[\"city\"],\"title\":\"get_weather_args\",\"type\":\"object\",\"additionalProperties\":false},\"strict_json_schema\":true}],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + add_activity_action(expected_state, "{\"args\":\"Seattle, WA\"}", activity_name="get_weather") + add_activity_action(expected_state, "{\"input\":[{\"content\":\"Tell me the weather in Seattle.\",\"role\":\"user\"},{\"arguments\":\"{\\\"args\\\":\\\"Seattle, WA\\\"}\",\"call_id\":\"call_mEdywElQTNpxAdivuEFjO0cT\",\"name\":\"get_weather\",\"type\":\"function_call\",\"id\":\"fc_68b9ecc0ff9c819f863d6cf9e0a1b4e101011fd6f5f8c0a6\",\"status\":\"completed\"},{\"call_id\":\"call_mEdywElQTNpxAdivuEFjO0cT\",\"output\":\"{\\\"__class__\\\":\\\"Weather\\\",\\\"__module__\\\":\\\"function_app\\\",\\\"__data__\\\":\\\"{\\n \\\"city\\\": \\\"{\\\\\\\"args\\\\\\\":\\\\\\\"Seattle, WA\\\\\\\"}\\\",\\n \\\"temperature_range\\\": \\\"14-20C\\\",\\n \\\"conditions\\\": \\\"Sunny with wind.\\\"\\n}\\\"}\",\"type\":\"function_call_output\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":null,\"system_instructions\":\"You only respond in haikus.\",\"tools\":[{\"name\":\"get_weather\",\"description\":\"\",\"params_json_schema\":{\"properties\":{\"city\":{\"title\":\"City\",\"type\":\"string\"}},\"required\":[\"city\"],\"title\":\"get_weather_args\",\"type\":\"object\",\"additionalProperties\":false},\"strict_json_schema\":true}],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_use_tool_analysis_completed(): + context_builder = ContextBuilder('test_openai_agent_use_tool_start') + add_activity_completed_events(context_builder, 0, '{"output":[{"arguments":"{\\"args\\":\\"Seattle, WA\\"}","call_id":"call_mEdywElQTNpxAdivuEFjO0cT","name":"get_weather","type":"function_call","id":"fc_68b9ecc0ff9c819f863d6cf9e0a1b4e101011fd6f5f8c0a6","status":"completed"}],"usage":{"requests":1,"input_tokens":57,"input_tokens_details":{"cached_tokens":0},"output_tokens":17,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":74},"response_id":"resp_68b9ecc092e0819fb79b97c11aacef2001011fd6f5f8c0a6"}') + add_activity_completed_events(context_builder, 1, '{"__class__":"Weather","__module__":"function_app","__data__":"{\n \"city\": \"{\\\"args\\\":\\\"Seattle, WA\\\"}\",\n \"temperature_range\": \"14-20C\",\n \"conditions\": \"Sunny with wind.\"\n}"}') + add_activity_completed_events(context_builder, 2, '{"output":[{"id":"msg_68b9f4b09c14819faa62abfd69cb53e501011fd6f5f8c0a6","content":[{"annotations":[],"text":"The weather in Seattle, WA is currently sunny with some wind. Temperatures are ranging from 14°C to 20°C.","type":"output_text","logprobs":null}],"role":"assistant","status":"completed","type":"message"}],"usage":{"requests":1,"input_tokens":107,"input_tokens_details":{"cached_tokens":0},"output_tokens":28,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":135},"response_id":"resp_68b9f4b00804819f9fe99eac95bd198e01011fd6f5f8c0a6"}') + + result = get_orchestration_state_result( + context_builder, openai_agent_use_tool, uses_pystein=True) + + expected_state = base_expected_state() + add_activity_action(expected_state, "{\"input\":[{\"content\":\"Tell me the weather in Seattle.\",\"role\":\"user\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":null,\"system_instructions\":\"You only respond in haikus.\",\"tools\":[{\"name\":\"get_weather\",\"description\":\"\",\"params_json_schema\":{\"properties\":{\"city\":{\"title\":\"City\",\"type\":\"string\"}},\"required\":[\"city\"],\"title\":\"get_weather_args\",\"type\":\"object\",\"additionalProperties\":false},\"strict_json_schema\":true}],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + add_activity_action(expected_state, "{\"args\":\"Seattle, WA\"}", activity_name="get_weather") + add_activity_action(expected_state, "{\"input\":[{\"content\":\"Tell me the weather in Seattle.\",\"role\":\"user\"},{\"arguments\":\"{\\\"args\\\":\\\"Seattle, WA\\\"}\",\"call_id\":\"call_mEdywElQTNpxAdivuEFjO0cT\",\"name\":\"get_weather\",\"type\":\"function_call\",\"id\":\"fc_68b9ecc0ff9c819f863d6cf9e0a1b4e101011fd6f5f8c0a6\",\"status\":\"completed\"},{\"call_id\":\"call_mEdywElQTNpxAdivuEFjO0cT\",\"output\":\"{\\\"__class__\\\":\\\"Weather\\\",\\\"__module__\\\":\\\"function_app\\\",\\\"__data__\\\":\\\"{\\n \\\"city\\\": \\\"{\\\\\\\"args\\\\\\\":\\\\\\\"Seattle, WA\\\\\\\"}\\\",\\n \\\"temperature_range\\\": \\\"14-20C\\\",\\n \\\"conditions\\\": \\\"Sunny with wind.\\\"\\n}\\\"}\",\"type\":\"function_call_output\"}],\"model_settings\":{\"temperature\":null,\"top_p\":null,\"frequency_penalty\":null,\"presence_penalty\":null,\"tool_choice\":null,\"parallel_tool_calls\":null,\"truncation\":null,\"max_tokens\":null,\"reasoning\":null,\"metadata\":null,\"store\":null,\"include_usage\":null,\"response_include\":null,\"extra_query\":null,\"extra_body\":null,\"extra_headers\":null,\"extra_args\":null},\"tracing\":0,\"model_name\":null,\"system_instructions\":\"You only respond in haikus.\",\"tools\":[{\"name\":\"get_weather\",\"description\":\"\",\"params_json_schema\":{\"properties\":{\"city\":{\"title\":\"City\",\"type\":\"string\"}},\"required\":[\"city\"],\"title\":\"get_weather_args\",\"type\":\"object\",\"additionalProperties\":false},\"strict_json_schema\":true}],\"output_schema\":null,\"handoffs\":[],\"previous_response_id\":null,\"prompt\":null}") + expected_state._is_done = True + expected_state._output = 'The weather in Seattle, WA is currently sunny with some wind. Temperatures are ranging from 14°C to 20°C.' expected = expected_state.to_json() assert_valid_schema(result) From c0029dce9064a8d04d003f1c8f384240c9be7792 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 14:35:01 -0700 Subject: [PATCH 05/20] Fixup merge. --- azure/durable_functions/openai_agents/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/durable_functions/openai_agents/decorators.py b/azure/durable_functions/openai_agents/decorators.py index d4873035..357b0adb 100644 --- a/azure/durable_functions/openai_agents/decorators.py +++ b/azure/durable_functions/openai_agents/decorators.py @@ -62,7 +62,7 @@ def _auto_setup_durable_openai_agent(decorated_func, model_provider: ModelProvid pass -def durable_openai_agent_orchestrator(func, *, model_provider: ModelProvider | None = None): +def durable_openai_agent_orchestrator(_func=None, *, model_provider: ModelProvider | None = None): """Decorate Azure Durable Functions orchestrators that use OpenAI Agents.""" # Auto-setup: Find and configure the FunctionApp when decorator is applied def wrapper_wrapper(func): From dea3a53fd003e405d981c3ae433e8b54d0af06f7 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 15:17:42 -0700 Subject: [PATCH 06/20] Resolve annotations error. --- .../durable_functions/openai_agents/model_invocation_activity.py | 1 - requirements.txt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/durable_functions/openai_agents/model_invocation_activity.py b/azure/durable_functions/openai_agents/model_invocation_activity.py index 3ff0760d..6e08e52c 100644 --- a/azure/durable_functions/openai_agents/model_invocation_activity.py +++ b/azure/durable_functions/openai_agents/model_invocation_activity.py @@ -1,4 +1,3 @@ -from __future__ import annotations import enum import json import logging diff --git a/requirements.txt b/requirements.txt index 0ba77721..d172a748 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ opentelemetry-api==1.32.1 opentelemetry-sdk==1.32.1 openai==1.98.0 openai-agents==0.2.4 +eval-type-backport From 3e9d95fc08f17d14c685cf59ef339a011a5024a1 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 15:21:03 -0700 Subject: [PATCH 07/20] A better way to resolve linter. --- azure/durable_functions/openai_agents/decorators.py | 7 ++++--- .../openai_agents/model_invocation_activity.py | 4 ++-- requirements.txt | 1 - 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/azure/durable_functions/openai_agents/decorators.py b/azure/durable_functions/openai_agents/decorators.py index 357b0adb..7e086827 100644 --- a/azure/durable_functions/openai_agents/decorators.py +++ b/azure/durable_functions/openai_agents/decorators.py @@ -1,6 +1,7 @@ from functools import wraps import inspect import sys +from typing import Optional import azure.functions as func from agents import ModelProvider from agents.run import set_default_agent_runner @@ -16,7 +17,7 @@ _registered_apps = set() -def _setup_durable_openai_agent(app: func.FunctionApp, model_provider: ModelProvider | None): +def _setup_durable_openai_agent(app: func.FunctionApp, model_provider: Optional[ModelProvider]): """Set up the Durable OpenAI Agent framework for the given FunctionApp. This is automatically called when using the framework decorators. @@ -41,7 +42,7 @@ def _find_function_app_in_module(module): return None -def _auto_setup_durable_openai_agent(decorated_func, model_provider: ModelProvider | None): +def _auto_setup_durable_openai_agent(decorated_func, model_provider: Optional[ModelProvider]): """Automatically detect and setup the FunctionApp for Durable OpenAI Agents. This finds the FunctionApp in the same module as the decorated function. @@ -62,7 +63,7 @@ def _auto_setup_durable_openai_agent(decorated_func, model_provider: ModelProvid pass -def durable_openai_agent_orchestrator(_func=None, *, model_provider: ModelProvider | None = None): +def durable_openai_agent_orchestrator(_func=None, *, model_provider: Optional[ModelProvider] = None): """Decorate Azure Durable Functions orchestrators that use OpenAI Agents.""" # Auto-setup: Find and configure the FunctionApp when decorator is applied def wrapper_wrapper(func): diff --git a/azure/durable_functions/openai_agents/model_invocation_activity.py b/azure/durable_functions/openai_agents/model_invocation_activity.py index 6e08e52c..9f4f1287 100644 --- a/azure/durable_functions/openai_agents/model_invocation_activity.py +++ b/azure/durable_functions/openai_agents/model_invocation_activity.py @@ -393,12 +393,12 @@ def stream_response( tracing: ModelTracing, *, previous_response_id: Optional[str], - prompt: ResponsePromptParam | None, + prompt: Optional[ResponsePromptParam], ) -> AsyncIterator[TResponseStreamEvent]: raise NotImplementedError("Durable model doesn't support streams yet") -def create_invoke_model_activity(app: func.FunctionApp, model_provider: ModelProvider | None): +def create_invoke_model_activity(app: func.FunctionApp, model_provider: Optional[ModelProvider]): """Create and register the invoke_model_activity function with the provided FunctionApp.""" @app.activity_trigger(input_name="input") diff --git a/requirements.txt b/requirements.txt index d172a748..0ba77721 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,3 @@ opentelemetry-api==1.32.1 opentelemetry-sdk==1.32.1 openai==1.98.0 openai-agents==0.2.4 -eval-type-backport From 09a1fcb0874342dc94b607868d44e377658bf657 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 23:08:29 -0700 Subject: [PATCH 08/20] Move decorator to app class. --- .../decorators/durable_app.py | 55 ++++++ .../openai_agents/decorators.py | 159 +++++------------- .../openai_agents/test_openai_agents.py | 4 +- 3 files changed, 103 insertions(+), 115 deletions(-) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index 62b5b704..99a1b39b 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger,\ DurableClient from typing import Callable, Optional @@ -45,6 +46,7 @@ def __init__(self, New instance of a Durable Functions app """ super().__init__(auth_level=http_auth_level) + self._is_durable_openai_agent_setup = False def _configure_entity_callable(self, wrap) -> Callable: """Obtain decorator to construct an Entity class from a user-defined Function. @@ -250,6 +252,59 @@ def decorator(): return wrap + def _create_invoke_model_activity(self, model_provider): + """Create and register the invoke_model_activity function with the provided FunctionApp.""" + + @self.activity_trigger(input_name="input") + async def invoke_model_activity(input: str): + """Activity that handles OpenAI model invocations.""" + from azure.durable_functions.openai_agents.model_invocation_activity import ActivityModelInput + from agents import ModelResponse + from azure.durable_functions.openai_agents.model_invocation_activity import ModelInvoker + + activity_input = ActivityModelInput.from_json(input) + + model_invoker = ModelInvoker(model_provider=model_provider) + result = await model_invoker.invoke_model_activity(activity_input) + + json_obj = ModelResponse.__pydantic_serializer__.to_json(result) + return json_obj.decode() + + return invoke_model_activity + + def _setup_durable_openai_agent(self, model_provider): + if not self._is_durable_openai_agent_setup: + self._create_invoke_model_activity(model_provider) + self._is_durable_openai_agent_setup = True + + def durable_openai_agent_orchestrator(self, _func=None, *, model_provider = None): + """Decorate Azure Durable Functions orchestrators that use OpenAI Agents. + + Parameters + ---------- + model_provider: Optional[ModelProvider] + Use a non-default ModelProvider instead of the default OpenAIProvider, such as when testing. + """ + from agents import ModelProvider + from azure.durable_functions.openai_agents.decorators import durable_openai_agent_orchestrator + + if model_provider is not None and type(model_provider) is not ModelProvider: + raise TypeError("Provided model provider must be of type ModelProvider") + + self._setup_durable_openai_agent(model_provider) + + def wrapper_wrapper(func): + + @wraps(func) + def wrapper(context): + return durable_openai_agent_orchestrator(func, context, model_provider) + + return wrapper + + if _func is None: + return wrapper_wrapper + else: + return wrapper_wrapper(_func) class DFApp(Blueprint, FunctionRegister): """Durable Functions (DF) app. diff --git a/azure/durable_functions/openai_agents/decorators.py b/azure/durable_functions/openai_agents/decorators.py index 7e086827..4946d7b2 100644 --- a/azure/durable_functions/openai_agents/decorators.py +++ b/azure/durable_functions/openai_agents/decorators.py @@ -1,8 +1,5 @@ -from functools import wraps import inspect -import sys from typing import Optional -import azure.functions as func from agents import ModelProvider from agents.run import set_default_agent_runner from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext @@ -10,119 +7,55 @@ from .exceptions import YieldException from .context import DurableAIAgentContext from .event_loop import ensure_event_loop -from .model_invocation_activity import create_invoke_model_activity - -# Global registry to track which apps have been set up -_registered_apps = set() - - -def _setup_durable_openai_agent(app: func.FunctionApp, model_provider: Optional[ModelProvider]): - """Set up the Durable OpenAI Agent framework for the given FunctionApp. - - This is automatically called when using the framework decorators. - """ - app_id = id(app) - if app_id not in _registered_apps: - create_invoke_model_activity(app, model_provider) - _registered_apps.add(app_id) - - -def _find_function_app_in_module(module): - """Find a FunctionApp instance in the given module. - - Returns the first FunctionApp instance found, or None if none found. - """ - if not hasattr(module, '__dict__'): - return None - - for name, obj in module.__dict__.items(): - if isinstance(obj, func.FunctionApp): - return obj - return None - - -def _auto_setup_durable_openai_agent(decorated_func, model_provider: Optional[ModelProvider]): - """Automatically detect and setup the FunctionApp for Durable OpenAI Agents. - - This finds the FunctionApp in the same module as the decorated function. - """ - try: - # Get the module where the decorated function is defined - func_module = sys.modules.get(decorated_func.__module__) - if func_module is None: - return - - # Find the FunctionApp instance in that module - app = _find_function_app_in_module(func_module) - if app is not None: - _setup_durable_openai_agent(app, model_provider) - except Exception: - # Silently fail if auto-setup doesn't work - # The user can still manually call create_invoke_model_activity if needed - pass - - -def durable_openai_agent_orchestrator(_func=None, *, model_provider: Optional[ModelProvider] = None): +def durable_openai_agent_orchestrator(func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): """Decorate Azure Durable Functions orchestrators that use OpenAI Agents.""" # Auto-setup: Find and configure the FunctionApp when decorator is applied - def wrapper_wrapper(func): - _auto_setup_durable_openai_agent(func, model_provider) - - @wraps(func) - def wrapper(durable_orchestration_context: DurableOrchestrationContext): - ensure_event_loop() - durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) - durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) - set_default_agent_runner(durable_openai_runner) - - if inspect.isgeneratorfunction(func): - gen = iter(func(durable_ai_agent_context)) + ensure_event_loop() + durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) + durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) + set_default_agent_runner(durable_openai_runner) + + if inspect.isgeneratorfunction(func): + gen = iter(func(durable_ai_agent_context)) + try: + # prime the subiterator + value = next(gen) + yield from durable_ai_agent_context._yield_and_clear_tasks() + while True: try: - # prime the subiterator - value = next(gen) - yield from durable_ai_agent_context._yield_and_clear_tasks() - while True: - try: - # send whatever was sent into us down to the subgenerator - yield from durable_ai_agent_context._yield_and_clear_tasks() - sent = yield value - except GeneratorExit: - # ensure the subgenerator is closed - if hasattr(gen, "close"): - gen.close() - raise - except BaseException as exc: - # forward thrown exceptions if possible - if hasattr(gen, "throw"): - value = gen.throw(type(exc), exc, exc.__traceback__) - else: - raise - else: - # normal path: forward .send (or .__next__) - if hasattr(gen, "send"): - value = gen.send(sent) - else: - value = next(gen) - except StopIteration as e: - yield from durable_ai_agent_context._yield_and_clear_tasks() - return e.value - except YieldException as e: + # send whatever was sent into us down to the subgenerator yield from durable_ai_agent_context._yield_and_clear_tasks() - yield e.task - else: - try: - result = func(durable_ai_agent_context) - return result - except YieldException as e: - yield from durable_ai_agent_context._yield_and_clear_tasks() - yield e.task - finally: - yield from durable_ai_agent_context._yield_and_clear_tasks() - - return wrapper - - if _func is None: - return wrapper_wrapper + sent = yield value + except GeneratorExit: + # ensure the subgenerator is closed + if hasattr(gen, "close"): + gen.close() + raise + except BaseException as exc: + # forward thrown exceptions if possible + if hasattr(gen, "throw"): + value = gen.throw(type(exc), exc, exc.__traceback__) + else: + raise + else: + # normal path: forward .send (or .__next__) + if hasattr(gen, "send"): + value = gen.send(sent) + else: + value = next(gen) + except StopIteration as e: + yield from durable_ai_agent_context._yield_and_clear_tasks() + return e.value + except YieldException as e: + yield from durable_ai_agent_context._yield_and_clear_tasks() + yield e.task else: - return wrapper_wrapper(_func) + try: + result = func(durable_ai_agent_context) + return result + except YieldException as e: + yield from durable_ai_agent_context._yield_and_clear_tasks() + yield e.task + finally: + yield from durable_ai_agent_context._yield_and_clear_tasks() diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py index e8ab3d16..5379c4c4 100644 --- a/tests/orchestrator/openai_agents/test_openai_agents.py +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -15,7 +15,7 @@ @app.function_name("openai_agent_hello_world") @app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator +@app.durable_openai_agent_orchestrator def openai_agent_hello_world(context): agent = Agent( name="Assistant", @@ -45,7 +45,7 @@ def get_weather(city: str) -> Weather: @app.function_name("openai_agent_use_tool") @app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator +@app.durable_openai_agent_orchestrator def openai_agent_use_tool(context): agent = Agent( name="Assistant", From 6d091b6e1e83dfe4386e211f9d38b489ac20c787 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 4 Sep 2025 23:15:28 -0700 Subject: [PATCH 09/20] Rename functions. --- azure/durable_functions/decorators/durable_app.py | 14 +++++++------- azure/durable_functions/openai_agents/__init__.py | 3 +-- .../{decorators.py => orchestrator_generator.py} | 4 +--- samples-v2/openai_agents/function_app.py | 4 ++-- .../openai_agents/test_openai_agents.py | 1 - 5 files changed, 11 insertions(+), 15 deletions(-) rename azure/durable_functions/openai_agents/{decorators.py => orchestrator_generator.py} (88%) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index 99a1b39b..a1544561 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -286,25 +286,25 @@ def durable_openai_agent_orchestrator(self, _func=None, *, model_provider = None Use a non-default ModelProvider instead of the default OpenAIProvider, such as when testing. """ from agents import ModelProvider - from azure.durable_functions.openai_agents.decorators import durable_openai_agent_orchestrator + from azure.durable_functions.openai_agents.orchestrator_generator import durable_openai_agent_orchestrator_generator if model_provider is not None and type(model_provider) is not ModelProvider: raise TypeError("Provided model provider must be of type ModelProvider") self._setup_durable_openai_agent(model_provider) - def wrapper_wrapper(func): + def generator_wrapper_wrapper(func): @wraps(func) - def wrapper(context): - return durable_openai_agent_orchestrator(func, context, model_provider) + def generator_wrapper(context): + return durable_openai_agent_orchestrator_generator(func, context, model_provider) - return wrapper + return generator_wrapper if _func is None: - return wrapper_wrapper + return generator_wrapper_wrapper else: - return wrapper_wrapper(_func) + return generator_wrapper_wrapper(_func) class DFApp(Blueprint, FunctionRegister): """Durable Functions (DF) app. diff --git a/azure/durable_functions/openai_agents/__init__.py b/azure/durable_functions/openai_agents/__init__.py index e9c89d60..5e83c42d 100644 --- a/azure/durable_functions/openai_agents/__init__.py +++ b/azure/durable_functions/openai_agents/__init__.py @@ -4,10 +4,9 @@ with Azure Durable Functions orchestration patterns. """ -from .decorators import durable_openai_agent_orchestrator +from .orchestrator_generator import durable_openai_agent_orchestrator_generator from .context import DurableAIAgentContext __all__ = [ - 'durable_openai_agent_orchestrator', 'DurableAIAgentContext', ] diff --git a/azure/durable_functions/openai_agents/decorators.py b/azure/durable_functions/openai_agents/orchestrator_generator.py similarity index 88% rename from azure/durable_functions/openai_agents/decorators.py rename to azure/durable_functions/openai_agents/orchestrator_generator.py index 4946d7b2..0578e9d0 100644 --- a/azure/durable_functions/openai_agents/decorators.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -8,9 +8,7 @@ from .context import DurableAIAgentContext from .event_loop import ensure_event_loop -def durable_openai_agent_orchestrator(func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): - """Decorate Azure Durable Functions orchestrators that use OpenAI Agents.""" - # Auto-setup: Find and configure the FunctionApp when decorator is applied +def durable_openai_agent_orchestrator_generator(func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): ensure_event_loop() durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) diff --git a/samples-v2/openai_agents/function_app.py b/samples-v2/openai_agents/function_app.py index 9b06c573..1957ccee 100644 --- a/samples-v2/openai_agents/function_app.py +++ b/samples-v2/openai_agents/function_app.py @@ -1,7 +1,7 @@ import os import azure.functions as func -from azure.durable_functions.openai_agents import durable_openai_agent_orchestrator +from azure.durable_functions.openai_agents import durable_openai_agent_orchestrator_generator from azure.identity import AzureDefaultCredential from openai import AsyncAzureOpenAI @@ -45,7 +45,7 @@ async def orchestration_starter(req: func.HttpRequest, client): @app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator +@durable_openai_agent_orchestrator_generator def hello_world(context): import basic.hello_world return basic.hello_world.main() diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py index 5379c4c4..9db52b91 100644 --- a/tests/orchestrator/openai_agents/test_openai_agents.py +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -5,7 +5,6 @@ from azure.durable_functions.models import OrchestratorState from azure.durable_functions.models.actions import CallActivityAction from azure.durable_functions.models.ReplaySchema import ReplaySchema -from azure.durable_functions.openai_agents import durable_openai_agent_orchestrator from openai import BaseModel from tests.orchestrator.orchestrator_test_utils import get_orchestration_state_result, assert_valid_schema, \ assert_orchestration_state_equals From 968b8893c8308c28132110c1dcc88df2f6138a35 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 14:14:21 -0700 Subject: [PATCH 10/20] Fix linting errors. --- azure/durable_functions/decorators/durable_app.py | 14 +++++++++----- azure/durable_functions/openai_agents/__init__.py | 1 - .../openai_agents/orchestrator_generator.py | 7 ++++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index a1544561..884ef30e 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -258,9 +258,11 @@ def _create_invoke_model_activity(self, model_provider): @self.activity_trigger(input_name="input") async def invoke_model_activity(input: str): """Activity that handles OpenAI model invocations.""" - from azure.durable_functions.openai_agents.model_invocation_activity import ActivityModelInput + from azure.durable_functions.openai_agents.model_invocation_activity\ + import ActivityModelInput from agents import ModelResponse - from azure.durable_functions.openai_agents.model_invocation_activity import ModelInvoker + from azure.durable_functions.openai_agents.model_invocation_activity\ + import ModelInvoker activity_input = ActivityModelInput.from_json(input) @@ -277,16 +279,18 @@ def _setup_durable_openai_agent(self, model_provider): self._create_invoke_model_activity(model_provider) self._is_durable_openai_agent_setup = True - def durable_openai_agent_orchestrator(self, _func=None, *, model_provider = None): + def durable_openai_agent_orchestrator(self, _func=None, *, model_provider=None): """Decorate Azure Durable Functions orchestrators that use OpenAI Agents. Parameters ---------- model_provider: Optional[ModelProvider] - Use a non-default ModelProvider instead of the default OpenAIProvider, such as when testing. + Use a non-default ModelProvider instead of the default OpenAIProvider, + such as when testing. """ from agents import ModelProvider - from azure.durable_functions.openai_agents.orchestrator_generator import durable_openai_agent_orchestrator_generator + from azure.durable_functions.openai_agents.orchestrator_generator\ + import durable_openai_agent_orchestrator_generator if model_provider is not None and type(model_provider) is not ModelProvider: raise TypeError("Provided model provider must be of type ModelProvider") diff --git a/azure/durable_functions/openai_agents/__init__.py b/azure/durable_functions/openai_agents/__init__.py index 5e83c42d..916af484 100644 --- a/azure/durable_functions/openai_agents/__init__.py +++ b/azure/durable_functions/openai_agents/__init__.py @@ -4,7 +4,6 @@ with Azure Durable Functions orchestration patterns. """ -from .orchestrator_generator import durable_openai_agent_orchestrator_generator from .context import DurableAIAgentContext __all__ = [ diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index 0578e9d0..cc5146ae 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -8,7 +8,12 @@ from .context import DurableAIAgentContext from .event_loop import ensure_event_loop -def durable_openai_agent_orchestrator_generator(func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): +def durable_openai_agent_orchestrator_generator( + func, + durable_orchestration_context: DurableOrchestrationContext, + model_provider: Optional[ModelProvider]): + + ensure_event_loop() durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) From 8d9d1fc7a1768c1bae134f13cd7ce2b3dea28050 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 14:19:44 -0700 Subject: [PATCH 11/20] Resolve more linter errors. --- .../durable_functions/openai_agents/orchestrator_generator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index cc5146ae..177888e5 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -8,11 +8,12 @@ from .context import DurableAIAgentContext from .event_loop import ensure_event_loop + def durable_openai_agent_orchestrator_generator( func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): - + """Adapts the synchronous OpenAI Agents function to an Durable Functions orchestration generator function.""" ensure_event_loop() durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) From cfacc8a6a2c760168884853cb891c09f6c7a8eb1 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 14:23:07 -0700 Subject: [PATCH 12/20] More linting. --- .../durable_functions/openai_agents/orchestrator_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index 177888e5..bf1dec69 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -13,8 +13,8 @@ def durable_openai_agent_orchestrator_generator( func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): - """Adapts the synchronous OpenAI Agents function to an Durable Functions orchestration generator function.""" - + """Adapts the synchronous OpenAI Agents function to an Durable Functions orchestration + generator function.""" ensure_event_loop() durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) From 35163fc6cbb47381a619f4da544780f895ea3f2d Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 14:26:31 -0700 Subject: [PATCH 13/20] Better? --- .../openai_agents/orchestrator_generator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index bf1dec69..ab2259fe 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -13,8 +13,9 @@ def durable_openai_agent_orchestrator_generator( func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): - """Adapts the synchronous OpenAI Agents function to an Durable Functions orchestration - generator function.""" + """Adapts the synchronous OpenAI Agents function to an Durable Functions orchestrator + generator function. + """ ensure_event_loop() durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) From a634a87f24f5849dd1dd880a41079b3da577c33d Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 14:31:50 -0700 Subject: [PATCH 14/20] Less text is more. --- .../durable_functions/openai_agents/orchestrator_generator.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index ab2259fe..ac608d42 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -13,9 +13,7 @@ def durable_openai_agent_orchestrator_generator( func, durable_orchestration_context: DurableOrchestrationContext, model_provider: Optional[ModelProvider]): - """Adapts the synchronous OpenAI Agents function to an Durable Functions orchestrator - generator function. - """ + """Adapts the synchronous OpenAI Agents function to an Durable orchestrator generator.""" ensure_event_loop() durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context) durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context) From 6c3bccf243ac3eed83440be10035007b52ffb37b Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 14:33:15 -0700 Subject: [PATCH 15/20] More lines is more. --- azure/durable_functions/decorators/durable_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index 884ef30e..3c95fb59 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -310,6 +310,7 @@ def generator_wrapper(context): else: return generator_wrapper_wrapper(_func) + class DFApp(Blueprint, FunctionRegister): """Durable Functions (DF) app. From 536ca853da10d958b36db904517d811c15aaa1bf Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 15:47:07 -0700 Subject: [PATCH 16/20] Update sample. --- samples-v2/openai_agents/function_app.py | 14 ++++++++------ samples-v2/openai_agents/test_orchestrators.py | 0 2 files changed, 8 insertions(+), 6 deletions(-) mode change 100644 => 100755 samples-v2/openai_agents/test_orchestrators.py diff --git a/samples-v2/openai_agents/function_app.py b/samples-v2/openai_agents/function_app.py index 11f85003..91edf4dc 100644 --- a/samples-v2/openai_agents/function_app.py +++ b/samples-v2/openai_agents/function_app.py @@ -1,6 +1,8 @@ import os import azure.functions as func +import azure.durable_functions as df + from azure.identity import DefaultAzureCredential from openai import AsyncAzureOpenAI @@ -30,7 +32,7 @@ def get_azure_token(): # endregion -app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) +app = df.DFApp(http_auth_level=func.AuthLevel.FUNCTION) @app.route(route="orchestrators/{functionName}") @app.durable_client_input(client_name="client") @@ -43,7 +45,7 @@ async def orchestration_starter(req: func.HttpRequest, client): @app.orchestration_trigger(context_name="context") -@app.durable_openai_agent_orchestrator_generator +@app.durable_openai_agent_orchestrator def hello_world(context): import basic.hello_world return basic.hello_world.main() @@ -76,26 +78,26 @@ def local_image(context): @app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator +@app.durable_openai_agent_orchestrator def non_strict_output_type(context): import basic.non_strict_output_type return basic.non_strict_output_type.main() @app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator +@app.durable_openai_agent_orchestrator def previous_response_id(context): import basic.previous_response_id return basic.previous_response_id.main() @app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator +@app.durable_openai_agent_orchestrator def remote_image(context): import basic.remote_image return basic.remote_image.main() @app.orchestration_trigger(context_name="context") -@durable_openai_agent_orchestrator +@app.durable_openai_agent_orchestrator def tools(context): import basic.tools return basic.tools.main() diff --git a/samples-v2/openai_agents/test_orchestrators.py b/samples-v2/openai_agents/test_orchestrators.py old mode 100644 new mode 100755 From 380ce511fbe372cf99b182b04c62e338d35eca93 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 5 Sep 2025 16:56:49 -0700 Subject: [PATCH 17/20] Better serialization of orchestration output. --- .../openai_agents/orchestrator_generator.py | 12 ++- .../openai_agents/test_openai_agents.py | 101 ++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index ac608d42..75661454 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -1,5 +1,5 @@ import inspect -from typing import Optional +from typing import Optional, Any from agents import ModelProvider from agents.run import set_default_agent_runner from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext @@ -9,6 +9,12 @@ from .event_loop import ensure_event_loop +def _durable_serializer(obj: Any) -> Any: + if callable(getattr(obj, "to_json", None)): + return obj.to_json() + + return obj + def durable_openai_agent_orchestrator_generator( func, durable_orchestration_context: DurableOrchestrationContext, @@ -49,14 +55,14 @@ def durable_openai_agent_orchestrator_generator( value = next(gen) except StopIteration as e: yield from durable_ai_agent_context._yield_and_clear_tasks() - return e.value + return _durable_serializer(e.value) except YieldException as e: yield from durable_ai_agent_context._yield_and_clear_tasks() yield e.task else: try: result = func(durable_ai_agent_context) - return result + return _durable_serializer(result) except YieldException as e: yield from durable_ai_agent_context._yield_and_clear_tasks() yield e.task diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py index 9db52b91..c1a3af0f 100644 --- a/tests/orchestrator/openai_agents/test_openai_agents.py +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -1,3 +1,5 @@ +from typing import Optional, TypedDict + import azure.durable_functions as df import azure.functions as func import json @@ -56,6 +58,49 @@ def openai_agent_use_tool(context): return result.final_output; +@app.function_name("openai_agent_return_string_type") +@app.orchestration_trigger(context_name="context") +@app.durable_openai_agent_orchestrator +def openai_agent_return_string_type(context): + return "Hello World" + +class DurableModel: + def __init__(self, property: str) -> None: + self._property = property + + def to_json(self) -> str: + return json.dumps({"property": self._property}) + +@app.function_name("openai_agent_return_durable_model_type") +@app.orchestration_trigger(context_name="context") +@app.durable_openai_agent_orchestrator +def openai_agent_return_durable_model_type(context): + model = DurableModel(property="value") + + return model + +class TypedDictionaryModel(TypedDict): + property: str + +@app.function_name("openai_agent_return_typed_dictionary_model_type") +@app.orchestration_trigger(context_name="context") +@app.durable_openai_agent_orchestrator +def openai_agent_return_typed_dictionary_model_type(context): + model = TypedDictionaryModel(property="value") + + return model + +class PydanticModel(BaseModel): + property: str + +@app.function_name("openai_agent_return_pydantic_model_type") +@app.orchestration_trigger(context_name="context") +@app.durable_openai_agent_orchestrator +def openai_agent_return_pydantic_model_type(context): + model = PydanticModel(property="value") + + return model + model_activity_name = "invoke_model_activity" def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: @@ -152,3 +197,59 @@ def test_openai_agent_use_tool_analysis_completed(): assert_valid_schema(result) assert_orchestration_state_equals(expected, result) + +def test_openai_agent_string_serialization(): + context_builder = ContextBuilder('test_openai_agent_string_serialization') + + result = get_orchestration_state_result( + context_builder, openai_agent_return_string_type, uses_pystein=True) + + expected_state = base_expected_state() + expected_state._is_done = True + expected_state._output = "Hello World" + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_durable_model_serialization(): + context_builder = ContextBuilder('test_openai_agent_durable_model_serialization') + + result = get_orchestration_state_result( + context_builder, openai_agent_return_durable_model_type, uses_pystein=True) + + expected_state = base_expected_state() + expected_state._is_done = True + expected_state._output = DurableModel(property="value").to_json() + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_typed_dictionary_model_serialization(): + context_builder = ContextBuilder('test_openai_agent_typed_dictionary_model_serialization') + + result = get_orchestration_state_result( + context_builder, openai_agent_return_durable_model_type, uses_pystein=True) + + expected_state = base_expected_state() + expected_state._is_done = True + expected_state._output = json.dumps(TypedDictionaryModel(property="value")) + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_pydantic_model_serialization(): + context_builder = ContextBuilder('test_openai_agent_pydantic_model_serialization') + + result = get_orchestration_state_result( + context_builder, openai_agent_return_pydantic_model_type, uses_pystein=True) + + expected_state = base_expected_state() + expected_state._is_done = True + expected_state._output = PydanticModel(property="value").to_json() + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) From efb38b9092d7bea260b81c3b226579e6d8d15996 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 8 Sep 2025 14:10:33 -0700 Subject: [PATCH 18/20] Account for "real" Pydantic models. Signed-off-by: Phillip Hoff --- .../openai_agents/orchestrator_generator.py | 6 +++- .../openai_agents/test_openai_agents.py | 34 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index 75661454..9963c913 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -1,5 +1,6 @@ import inspect -from typing import Optional, Any +import json +from typing import Optional, Any, TypedDict from agents import ModelProvider from agents.run import set_default_agent_runner from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext @@ -13,6 +14,9 @@ def _durable_serializer(obj: Any) -> Any: if callable(getattr(obj, "to_json", None)): return obj.to_json() + if callable(getattr(obj, "model_dump_json", None)): + return obj.model_dump_json() + return obj def durable_openai_agent_orchestrator_generator( diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py index c1a3af0f..8ef4754a 100644 --- a/tests/orchestrator/openai_agents/test_openai_agents.py +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -3,6 +3,7 @@ import azure.durable_functions as df import azure.functions as func import json +import pydantic from agents import Agent, Runner from azure.durable_functions.models import OrchestratorState from azure.durable_functions.models.actions import CallActivityAction @@ -90,7 +91,18 @@ def openai_agent_return_typed_dictionary_model_type(context): return model -class PydanticModel(BaseModel): +class OpenAIPydanticModel(BaseModel): + property: str + +@app.function_name("openai_agent_return_openai_pydantic_model_type") +@app.orchestration_trigger(context_name="context") +@app.durable_openai_agent_orchestrator +def openai_agent_return_openai_pydantic_model_type(context): + model = OpenAIPydanticModel(property="value") + + return model + +class PydanticModel(pydantic.BaseModel): property: str @app.function_name("openai_agent_return_pydantic_model_type") @@ -230,11 +242,25 @@ def test_openai_agent_typed_dictionary_model_serialization(): context_builder = ContextBuilder('test_openai_agent_typed_dictionary_model_serialization') result = get_orchestration_state_result( - context_builder, openai_agent_return_durable_model_type, uses_pystein=True) + context_builder, openai_agent_return_typed_dictionary_model_type, uses_pystein=True) + + expected_state = base_expected_state() + expected_state._is_done = True + expected_state._output = TypedDictionaryModel(property="value") + expected = expected_state.to_json() + + assert_valid_schema(result) + assert_orchestration_state_equals(expected, result) + +def test_openai_agent_openai_pydantic_model_serialization(): + context_builder = ContextBuilder('test_openai_agent_openai_pydantic_model_serialization') + + result = get_orchestration_state_result( + context_builder, openai_agent_return_openai_pydantic_model_type, uses_pystein=True) expected_state = base_expected_state() expected_state._is_done = True - expected_state._output = json.dumps(TypedDictionaryModel(property="value")) + expected_state._output = OpenAIPydanticModel(property="value").to_json() expected = expected_state.to_json() assert_valid_schema(result) @@ -248,7 +274,7 @@ def test_openai_agent_pydantic_model_serialization(): expected_state = base_expected_state() expected_state._is_done = True - expected_state._output = PydanticModel(property="value").to_json() + expected_state._output = PydanticModel(property="value").model_dump_json() expected = expected_state.to_json() assert_valid_schema(result) From e67fb29b408979ccf0a6ece0981b0c20d75b66cb Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 9 Sep 2025 15:24:40 -0700 Subject: [PATCH 19/20] Account for more native types. Signed-off-by: Phillip Hoff --- .../openai_agents/orchestrator_generator.py | 15 ++++++++++++--- .../openai_agents/test_openai_agents.py | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index 1c1eeb4d..770d36dc 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -1,6 +1,6 @@ import inspect import json -from typing import Optional, Any, TypedDict +from typing import Any from agents import ModelProvider, ModelResponse from agents.run import set_default_agent_runner from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext @@ -11,14 +11,23 @@ from .context import DurableAIAgentContext from .event_loop import ensure_event_loop -def _durable_serializer(obj: Any) -> Any: + +def _durable_serializer(obj: Any) -> str: + # Strings are already "serialized" + if type(obj) is str: + return obj + + # Serialize "Durable" and OpenAI models, and typed dictionaries if callable(getattr(obj, "to_json", None)): return obj.to_json() + # Serialize Pydantic models if callable(getattr(obj, "model_dump_json", None)): return obj.model_dump_json() - return obj + # Fallback to default JSON serialization + return json.dumps(obj) + async def durable_openai_agent_activity(input: str, model_provider: ModelProvider): """Activity logic that handles OpenAI model invocations.""" diff --git a/tests/orchestrator/openai_agents/test_openai_agents.py b/tests/orchestrator/openai_agents/test_openai_agents.py index 8ef4754a..4cd6f522 100644 --- a/tests/orchestrator/openai_agents/test_openai_agents.py +++ b/tests/orchestrator/openai_agents/test_openai_agents.py @@ -246,7 +246,7 @@ def test_openai_agent_typed_dictionary_model_serialization(): expected_state = base_expected_state() expected_state._is_done = True - expected_state._output = TypedDictionaryModel(property="value") + expected_state._output = json.dumps(TypedDictionaryModel(property="value")) expected = expected_state.to_json() assert_valid_schema(result) From 1fb2c74b95b2163d58b23007c84ef01f718e97eb Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 9 Sep 2025 15:27:17 -0700 Subject: [PATCH 20/20] Fixup merge. Signed-off-by: Phillip Hoff --- azure/durable_functions/decorators/durable_app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index bdacb7b4..0ef92d02 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -48,7 +48,6 @@ def __init__(self, """ super().__init__(auth_level=http_auth_level) self._is_durable_openai_agent_setup = False - self._is_durable_openai_agent_setup = False def _configure_entity_callable(self, wrap) -> Callable: """Obtain decorator to construct an Entity class from a user-defined Function.