Skip to content

Commit 4070a19

Browse files
committed
Move implementation to azure/durable_functions/openai_agents
1 parent 4767c5c commit 4070a19

File tree

11 files changed

+185
-192
lines changed

11 files changed

+185
-192
lines changed

azure/durable_functions/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,11 @@ def validate_extension_bundles():
7979
__all__.append('Blueprint')
8080
except ModuleNotFoundError:
8181
pass
82+
83+
# Import OpenAI Agents integration (optional dependency)
84+
try:
85+
from . import openai_agents # noqa
86+
__all__.append('openai_agents')
87+
except ImportError:
88+
# OpenAI agents integration requires additional dependencies
89+
pass
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""OpenAI Agents integration for Azure Durable Functions.
2+
3+
This module provides decorators and utilities to integrate OpenAI Agents
4+
with Azure Durable Functions orchestration patterns.
5+
"""
6+
7+
from .decorators import durable_openai_agent_orchestrator
8+
from .context import DurableAIAgentContext
9+
10+
__all__ = [
11+
'durable_openai_agent_orchestrator',
12+
'DurableAIAgentContext',
13+
]

samples-v2/openai_agents/durable_ai_agent_context.py renamed to azure/durable_functions/openai_agents/context.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from agents import RunContextWrapper, Tool
99
from agents.function_schema import function_schema
1010
from agents.tool import FunctionTool
11-
from yield_exception import YieldException
11+
from .exceptions import YieldException
1212

1313

1414
class DurableAIAgentContext:
@@ -63,6 +63,7 @@ def activity_as_tool(
6363
6464
Args:
6565
activity_func: The Azure Functions activity function to convert
66+
description: Optional description override for the tool
6667
6768
Returns:
6869
Tool: An OpenAI Agents SDK Tool object
@@ -89,4 +90,4 @@ async def run_activity(ctx: RunContextWrapper[Any], input: str) -> Any:
8990
params_json_schema=schema.params_json_schema,
9091
on_invoke_tool=run_activity,
9192
strict_json_schema=True,
92-
)
93+
)

samples-v2/openai_agents/durable_decorators.py renamed to azure/durable_functions/openai_agents/decorators.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
import azure.functions as func
55
from agents.run import set_default_agent_runner
66
from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext
7-
from durable_openai_runner import DurableOpenAIRunner
8-
from yield_exception import YieldException
9-
from durable_ai_agent_context import DurableAIAgentContext
10-
import event_loop
11-
from model_activity import create_invoke_model_activity
7+
from .runner import DurableOpenAIRunner
8+
from .exceptions import YieldException
9+
from .context import DurableAIAgentContext
10+
from .event_loop import ensure_event_loop
11+
from .model_invocation_activity import create_invoke_model_activity
1212

1313

1414
# Global registry to track which apps have been set up
@@ -67,7 +67,7 @@ def durable_openai_agent_orchestrator(func):
6767

6868
@wraps(func)
6969
def wrapper(durable_orchestration_context: DurableOrchestrationContext):
70-
event_loop.ensure_event_loop()
70+
ensure_event_loop()
7171
durable_ai_agent_context = DurableAIAgentContext(durable_orchestration_context)
7272
durable_openai_runner = DurableOpenAIRunner(context=durable_ai_agent_context)
7373
set_default_agent_runner(durable_openai_runner)
@@ -116,4 +116,4 @@ def wrapper(durable_orchestration_context: DurableOrchestrationContext):
116116
finally:
117117
yield from durable_ai_agent_context._yield_and_clear_tasks()
118118

119-
return wrapper
119+
return wrapper

samples-v2/openai_agents/event_loop.py renamed to azure/durable_functions/openai_agents/event_loop.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ def ensure_event_loop():
1313
asyncio.get_running_loop()
1414
except RuntimeError:
1515
loop = asyncio.new_event_loop()
16-
asyncio.set_event_loop(loop)
16+
asyncio.set_event_loop(loop)

samples-v2/openai_agents/yield_exception.py renamed to azure/durable_functions/openai_agents/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
class YieldException(BaseException):
44
def __init__(self, task: TaskBase):
55
super().__init__("Orchestrator should yield.")
6-
self.task = task
6+
self.task = task

samples-v2/openai_agents/model_invoker.py renamed to azure/durable_functions/openai_agents/model_invocation_activity.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
1+
from __future__ import annotations
12
import enum
23
import json
4+
import logging
35
from dataclasses import dataclass
46
from datetime import timedelta
5-
from typing import Any, Optional, Union
7+
from typing import Any, AsyncIterator, Optional, Union, cast
68

9+
import azure.functions as func
710
from pydantic import BaseModel, Field
811
from agents import (
12+
AgentOutputSchema,
913
AgentOutputSchemaBase,
1014
CodeInterpreterTool,
1115
FileSearchTool,
1216
FunctionTool,
1317
Handoff,
1418
HostedMCPTool,
1519
ImageGenerationTool,
20+
Model,
1621
ModelProvider,
1722
ModelResponse,
1823
ModelSettings,
@@ -24,11 +29,16 @@
2429
UserError,
2530
WebSearchTool,
2631
)
32+
from agents.items import TResponseStreamEvent
2733
from openai import (
2834
APIStatusError,
2935
AsyncOpenAI,
3036
)
3137
from openai.types.responses.tool_param import Mcp
38+
from openai.types.responses.response_prompt_param import ResponsePromptParam
39+
40+
from .context import DurableAIAgentContext
41+
3242
try:
3343
from azure.durable_functions import ApplicationError
3444
except ImportError:
@@ -39,6 +49,8 @@ def __init__(self, message: str, non_retryable: bool = False, next_retry_delay =
3949
self.non_retryable = non_retryable
4050
self.next_retry_delay = next_retry_delay
4151

52+
logger = logging.getLogger(__name__)
53+
4254

4355
class HandoffInput(BaseModel):
4456
"""Data conversion friendly representation of a Handoff. Contains only the fields which are needed by the model
@@ -259,3 +271,132 @@ def make_tool(tool: ToolInput) -> Tool:
259271
non_retryable=True,
260272
next_retry_delay=retry_after,
261273
) from e
274+
275+
276+
class _DurableModelStub(Model):
277+
def __init__(
278+
self,
279+
model_name: Optional[str],
280+
context: DurableAIAgentContext,
281+
) -> None:
282+
self.model_name = model_name
283+
self.context = context
284+
285+
async def get_response(
286+
self,
287+
system_instructions: Optional[str],
288+
input: Union[str, list[TResponseInputItem]],
289+
model_settings: ModelSettings,
290+
tools: list[Tool],
291+
output_schema: Optional[AgentOutputSchemaBase],
292+
handoffs: list[Handoff],
293+
tracing: ModelTracing,
294+
*,
295+
previous_response_id: Optional[str],
296+
prompt: Optional[ResponsePromptParam],
297+
) -> ModelResponse:
298+
def make_tool_info(tool: Tool) -> ToolInput:
299+
if isinstance(
300+
tool,
301+
(
302+
FileSearchTool,
303+
WebSearchTool,
304+
ImageGenerationTool,
305+
CodeInterpreterTool,
306+
),
307+
):
308+
return tool
309+
elif isinstance(tool, HostedMCPTool):
310+
return HostedMCPToolInput(tool_config=tool.tool_config)
311+
elif isinstance(tool, FunctionTool):
312+
return FunctionToolInput(
313+
name=tool.name,
314+
description=tool.description,
315+
params_json_schema=tool.params_json_schema,
316+
strict_json_schema=tool.strict_json_schema,
317+
)
318+
else:
319+
raise ValueError(f"Unsupported tool type: {tool.name}")
320+
321+
tool_infos = [make_tool_info(x) for x in tools]
322+
handoff_infos = [
323+
HandoffInput(
324+
tool_name=x.tool_name,
325+
tool_description=x.tool_description,
326+
input_json_schema=x.input_json_schema,
327+
agent_name=x.agent_name,
328+
strict_json_schema=x.strict_json_schema,
329+
)
330+
for x in handoffs
331+
]
332+
if output_schema is not None and not isinstance(
333+
output_schema, AgentOutputSchema
334+
):
335+
raise TypeError(
336+
f"Only AgentOutputSchema is supported by Durable Model, got {type(output_schema).__name__}"
337+
)
338+
agent_output_schema = output_schema
339+
output_schema_input = (
340+
None
341+
if agent_output_schema is None
342+
else AgentOutputSchemaInput(
343+
output_type_name=agent_output_schema.name(),
344+
is_wrapped=agent_output_schema._is_wrapped,
345+
output_schema=agent_output_schema.json_schema()
346+
if not agent_output_schema.is_plain_text()
347+
else None,
348+
strict_json_schema=agent_output_schema.is_strict_json_schema(),
349+
)
350+
)
351+
352+
activity_input = ActivityModelInput(
353+
model_name=self.model_name,
354+
system_instructions=system_instructions,
355+
input=cast(Union[str, list[TResponseInputItem]], input),
356+
model_settings=model_settings,
357+
tools=tool_infos,
358+
output_schema=output_schema_input,
359+
handoffs=handoff_infos,
360+
tracing=ModelTracingInput.DISABLED, # ModelTracingInput(tracing.value),
361+
previous_response_id=previous_response_id,
362+
prompt=prompt,
363+
)
364+
365+
activity_input_json = activity_input.to_json()
366+
367+
response = self.context._get_activity_call_result("invoke_model_activity", activity_input_json)
368+
json_response = json.loads(response)
369+
model_response = ModelResponse(**json_response)
370+
return model_response
371+
372+
def stream_response(
373+
self,
374+
system_instructions: Optional[str],
375+
input: Union[str, list[TResponseInputItem]],
376+
model_settings: ModelSettings,
377+
tools: list[Tool],
378+
output_schema: Optional[AgentOutputSchemaBase],
379+
handoffs: list[Handoff],
380+
tracing: ModelTracing,
381+
*,
382+
previous_response_id: Optional[str],
383+
prompt: ResponsePromptParam | None,
384+
) -> AsyncIterator[TResponseStreamEvent]:
385+
raise NotImplementedError("Durable model doesn't support streams yet")
386+
387+
388+
def create_invoke_model_activity(app: func.FunctionApp):
389+
"""Create and register the invoke_model_activity function with the provided FunctionApp."""
390+
391+
@app.activity_trigger(input_name="input")
392+
async def invoke_model_activity(input: str):
393+
"""Activity that handles OpenAI model invocations."""
394+
activity_input = ActivityModelInput.from_json(input)
395+
396+
model_invoker = ModelInvoker()
397+
result = await model_invoker.invoke_model_activity(activity_input)
398+
399+
json_obj = ModelResponse.__pydantic_serializer__.to_json(result)
400+
return json_obj.decode()
401+
402+
return invoke_model_activity

samples-v2/openai_agents/durable_openai_runner.py renamed to azure/durable_functions/openai_agents/runner.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
from agents.run import DEFAULT_AGENT_RUNNER, DEFAULT_MAX_TURNS, AgentRunner
1515
from pydantic_core import to_json
1616

17-
from durable_ai_agent_context import DurableAIAgentContext
18-
from durable_model_stub import _DurableModelStub
17+
from .context import DurableAIAgentContext
18+
from .model_invocation_activity import _DurableModelStub
1919

2020
logger = logging.getLogger(__name__)
2121

22+
2223
class DurableOpenAIRunner:
2324
def __init__(self, context: DurableAIAgentContext) -> None:
2425
self._runner = DEFAULT_AGENT_RUNNER or AgentRunner()
@@ -84,4 +85,4 @@ def run_streamed(
8485
input: Union[str, list[TResponseInputItem]],
8586
**kwargs: Any,
8687
) -> RunResultStreaming:
87-
raise RuntimeError("Durable Functions do not support streaming.")
88+
raise RuntimeError("Durable Functions do not support streaming.")

0 commit comments

Comments
 (0)