azure-functions-durable 1.3.3__py3-none-any.whl → 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azure/durable_functions/__init__.py +8 -0
- azure/durable_functions/decorators/durable_app.py +64 -1
- azure/durable_functions/openai_agents/__init__.py +13 -0
- azure/durable_functions/openai_agents/context.py +194 -0
- azure/durable_functions/openai_agents/event_loop.py +17 -0
- azure/durable_functions/openai_agents/exceptions.py +11 -0
- azure/durable_functions/openai_agents/handoffs.py +67 -0
- azure/durable_functions/openai_agents/model_invocation_activity.py +268 -0
- azure/durable_functions/openai_agents/orchestrator_generator.py +67 -0
- azure/durable_functions/openai_agents/runner.py +103 -0
- azure/durable_functions/openai_agents/task_tracker.py +171 -0
- azure/durable_functions/openai_agents/tools.py +148 -0
- azure/durable_functions/openai_agents/usage_telemetry.py +69 -0
- {azure_functions_durable-1.3.3.dist-info → azure_functions_durable-1.4.0.dist-info}/METADATA +6 -1
- {azure_functions_durable-1.3.3.dist-info → azure_functions_durable-1.4.0.dist-info}/RECORD +24 -7
- tests/openai_agents/__init__.py +0 -0
- tests/openai_agents/test_context.py +466 -0
- tests/openai_agents/test_task_tracker.py +290 -0
- tests/openai_agents/test_usage_telemetry.py +99 -0
- tests/orchestrator/openai_agents/__init__.py +0 -0
- tests/orchestrator/openai_agents/test_openai_agents.py +316 -0
- {azure_functions_durable-1.3.3.dist-info → azure_functions_durable-1.4.0.dist-info}/LICENSE +0 -0
- {azure_functions_durable-1.3.3.dist-info → azure_functions_durable-1.4.0.dist-info}/WHEEL +0 -0
- {azure_functions_durable-1.3.3.dist-info → azure_functions_durable-1.4.0.dist-info}/top_level.txt +0 -0
|
@@ -79,3 +79,11 @@ try:
|
|
|
79
79
|
__all__.append('Blueprint')
|
|
80
80
|
except ModuleNotFoundError:
|
|
81
81
|
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
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
2
|
# Licensed under the MIT License.
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
from azure.durable_functions.models.RetryOptions import RetryOptions
|
|
5
|
+
from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \
|
|
4
6
|
DurableClient
|
|
5
7
|
from typing import Callable, Optional
|
|
6
8
|
from azure.durable_functions.entity import Entity
|
|
@@ -45,6 +47,7 @@ class Blueprint(TriggerApi, BindingApi, SettingsApi):
|
|
|
45
47
|
New instance of a Durable Functions app
|
|
46
48
|
"""
|
|
47
49
|
super().__init__(auth_level=http_auth_level)
|
|
50
|
+
self._is_durable_openai_agent_setup = False
|
|
48
51
|
|
|
49
52
|
def _configure_entity_callable(self, wrap) -> Callable:
|
|
50
53
|
"""Obtain decorator to construct an Entity class from a user-defined Function.
|
|
@@ -250,6 +253,66 @@ class Blueprint(TriggerApi, BindingApi, SettingsApi):
|
|
|
250
253
|
|
|
251
254
|
return wrap
|
|
252
255
|
|
|
256
|
+
def _create_invoke_model_activity(self, model_provider, activity_name):
|
|
257
|
+
"""Create and register the invoke_model_activity function with the provided FunctionApp."""
|
|
258
|
+
|
|
259
|
+
@self.activity_trigger(input_name="input", activity=activity_name)
|
|
260
|
+
async def run_model_activity(input: str):
|
|
261
|
+
from azure.durable_functions.openai_agents.orchestrator_generator\
|
|
262
|
+
import durable_openai_agent_activity
|
|
263
|
+
|
|
264
|
+
return await durable_openai_agent_activity(input, model_provider)
|
|
265
|
+
|
|
266
|
+
return run_model_activity
|
|
267
|
+
|
|
268
|
+
def _setup_durable_openai_agent(self, model_provider, activity_name):
|
|
269
|
+
if not self._is_durable_openai_agent_setup:
|
|
270
|
+
self._create_invoke_model_activity(model_provider, activity_name)
|
|
271
|
+
self._is_durable_openai_agent_setup = True
|
|
272
|
+
|
|
273
|
+
def durable_openai_agent_orchestrator(
|
|
274
|
+
self,
|
|
275
|
+
_func=None,
|
|
276
|
+
*,
|
|
277
|
+
model_provider=None,
|
|
278
|
+
model_retry_options: Optional[RetryOptions] = RetryOptions(
|
|
279
|
+
first_retry_interval_in_milliseconds=2000, max_number_of_attempts=5
|
|
280
|
+
),
|
|
281
|
+
):
|
|
282
|
+
"""Decorate Azure Durable Functions orchestrators that use OpenAI Agents.
|
|
283
|
+
|
|
284
|
+
Parameters
|
|
285
|
+
----------
|
|
286
|
+
model_provider: Optional[ModelProvider]
|
|
287
|
+
Use a non-default ModelProvider instead of the default OpenAIProvider,
|
|
288
|
+
such as when testing.
|
|
289
|
+
"""
|
|
290
|
+
from agents import ModelProvider
|
|
291
|
+
from azure.durable_functions.openai_agents.orchestrator_generator\
|
|
292
|
+
import durable_openai_agent_orchestrator_generator
|
|
293
|
+
|
|
294
|
+
if model_provider is not None and type(model_provider) is not ModelProvider:
|
|
295
|
+
raise TypeError("Provided model provider must be of type ModelProvider")
|
|
296
|
+
|
|
297
|
+
activity_name = "run_model"
|
|
298
|
+
|
|
299
|
+
self._setup_durable_openai_agent(model_provider, activity_name)
|
|
300
|
+
|
|
301
|
+
def generator_wrapper_wrapper(func):
|
|
302
|
+
|
|
303
|
+
@wraps(func)
|
|
304
|
+
def generator_wrapper(context):
|
|
305
|
+
return durable_openai_agent_orchestrator_generator(
|
|
306
|
+
func, context, model_retry_options, activity_name
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return generator_wrapper
|
|
310
|
+
|
|
311
|
+
if _func is None:
|
|
312
|
+
return generator_wrapper_wrapper
|
|
313
|
+
else:
|
|
314
|
+
return generator_wrapper_wrapper(_func)
|
|
315
|
+
|
|
253
316
|
|
|
254
317
|
class DFApp(Blueprint, FunctionRegister):
|
|
255
318
|
"""Durable Functions (DF) app.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""OpenAI Agents integration for Durable Functions.
|
|
4
|
+
|
|
5
|
+
This module provides decorators and utilities to integrate OpenAI Agents
|
|
6
|
+
with Durable Functions orchestration patterns.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .context import DurableAIAgentContext
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'DurableAIAgentContext',
|
|
13
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable, Optional, TYPE_CHECKING, Union
|
|
5
|
+
|
|
6
|
+
from azure.durable_functions.models.DurableOrchestrationContext import (
|
|
7
|
+
DurableOrchestrationContext,
|
|
8
|
+
)
|
|
9
|
+
from azure.durable_functions.models.RetryOptions import RetryOptions
|
|
10
|
+
|
|
11
|
+
from agents import RunContextWrapper, Tool
|
|
12
|
+
from agents.function_schema import function_schema
|
|
13
|
+
from agents.tool import FunctionTool
|
|
14
|
+
|
|
15
|
+
from azure.durable_functions.models.Task import TaskBase
|
|
16
|
+
from .task_tracker import TaskTracker
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
# At type-check time we want all members / signatures for IDE & linters.
|
|
21
|
+
_BaseDurableContext = DurableOrchestrationContext
|
|
22
|
+
else:
|
|
23
|
+
class _BaseDurableContext: # lightweight runtime stub
|
|
24
|
+
"""Runtime stub base class for delegation; real context is wrapped.
|
|
25
|
+
|
|
26
|
+
At runtime we avoid inheriting from DurableOrchestrationContext so that
|
|
27
|
+
attribute lookups for its members are delegated via __getattr__ to the
|
|
28
|
+
wrapped ``_context`` instance.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DurableAIAgentContext(_BaseDurableContext):
|
|
35
|
+
"""Context for AI agents running in Azure Durable Functions orchestration.
|
|
36
|
+
|
|
37
|
+
Design
|
|
38
|
+
------
|
|
39
|
+
* Static analysis / IDEs: Appears to subclass ``DurableOrchestrationContext`` so
|
|
40
|
+
you get autocompletion and type hints (under TYPE_CHECKING branch).
|
|
41
|
+
* Runtime: Inherits from a trivial stub. All durable orchestration operations
|
|
42
|
+
are delegated to the real ``DurableOrchestrationContext`` instance provided
|
|
43
|
+
as ``context`` and stored in ``_context``.
|
|
44
|
+
|
|
45
|
+
Consequences
|
|
46
|
+
------------
|
|
47
|
+
* ``isinstance(DurableAIAgentContext, DurableOrchestrationContext)`` is **False** at
|
|
48
|
+
runtime (expected).
|
|
49
|
+
* Delegation via ``__getattr__`` works for every member of the real context.
|
|
50
|
+
* No reliance on internal initialization side-effects of the durable SDK.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
context: DurableOrchestrationContext,
|
|
56
|
+
task_tracker: TaskTracker,
|
|
57
|
+
model_retry_options: Optional[RetryOptions],
|
|
58
|
+
):
|
|
59
|
+
self._context = context
|
|
60
|
+
self._task_tracker = task_tracker
|
|
61
|
+
self._model_retry_options = model_retry_options
|
|
62
|
+
|
|
63
|
+
def call_activity(
|
|
64
|
+
self, name: Union[str, Callable], input_: Optional[Any] = None
|
|
65
|
+
) -> TaskBase:
|
|
66
|
+
"""Schedule an activity for execution.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
name: str | Callable
|
|
71
|
+
Either the name of the activity function to call, as a string or,
|
|
72
|
+
in the Python V2 programming model, the activity function itself.
|
|
73
|
+
input_: Optional[Any]
|
|
74
|
+
The JSON-serializable input to pass to the activity function.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
Task
|
|
79
|
+
A Durable Task that completes when the called activity function completes or fails.
|
|
80
|
+
"""
|
|
81
|
+
task = self._context.call_activity(name, input_)
|
|
82
|
+
self._task_tracker.record_activity_call()
|
|
83
|
+
return task
|
|
84
|
+
|
|
85
|
+
def call_activity_with_retry(
|
|
86
|
+
self,
|
|
87
|
+
name: Union[str, Callable],
|
|
88
|
+
retry_options: RetryOptions,
|
|
89
|
+
input_: Optional[Any] = None,
|
|
90
|
+
) -> TaskBase:
|
|
91
|
+
"""Schedule an activity for execution with retry options.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
name: str | Callable
|
|
96
|
+
Either the name of the activity function to call, as a string or,
|
|
97
|
+
in the Python V2 programming model, the activity function itself.
|
|
98
|
+
retry_options: RetryOptions
|
|
99
|
+
The retry options for the activity function.
|
|
100
|
+
input_: Optional[Any]
|
|
101
|
+
The JSON-serializable input to pass to the activity function.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
Task
|
|
106
|
+
A Durable Task that completes when the called activity function completes or
|
|
107
|
+
fails completely.
|
|
108
|
+
"""
|
|
109
|
+
task = self._context.call_activity_with_retry(name, retry_options, input_)
|
|
110
|
+
self._task_tracker.record_activity_call()
|
|
111
|
+
return task
|
|
112
|
+
|
|
113
|
+
def create_activity_tool(
|
|
114
|
+
self,
|
|
115
|
+
activity_func: Callable,
|
|
116
|
+
*,
|
|
117
|
+
description: Optional[str] = None,
|
|
118
|
+
retry_options: Optional[RetryOptions] = RetryOptions(
|
|
119
|
+
first_retry_interval_in_milliseconds=2000, max_number_of_attempts=5
|
|
120
|
+
),
|
|
121
|
+
) -> Tool:
|
|
122
|
+
"""Convert an Azure Durable Functions activity to an OpenAI Agents SDK Tool.
|
|
123
|
+
|
|
124
|
+
Args
|
|
125
|
+
----
|
|
126
|
+
activity_func: The Azure Functions activity function to convert
|
|
127
|
+
description: Optional description override for the tool
|
|
128
|
+
retry_options: The retry options for the activity function
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
Tool: An OpenAI Agents SDK Tool object
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
if activity_func._function is None:
|
|
136
|
+
raise ValueError("The provided function is not a valid Azure Function.")
|
|
137
|
+
|
|
138
|
+
if (activity_func._function._trigger is not None
|
|
139
|
+
and activity_func._function._trigger.activity is not None):
|
|
140
|
+
activity_name = activity_func._function._trigger.activity
|
|
141
|
+
else:
|
|
142
|
+
activity_name = activity_func._function._name
|
|
143
|
+
|
|
144
|
+
input_name = None
|
|
145
|
+
if (activity_func._function._trigger is not None
|
|
146
|
+
and hasattr(activity_func._function._trigger, 'name')):
|
|
147
|
+
input_name = activity_func._function._trigger.name
|
|
148
|
+
|
|
149
|
+
async def run_activity(ctx: RunContextWrapper[Any], input: str) -> Any:
|
|
150
|
+
# Parse JSON input and extract the named value if input_name is specified
|
|
151
|
+
activity_input = input
|
|
152
|
+
if input_name:
|
|
153
|
+
try:
|
|
154
|
+
parsed_input = json.loads(input)
|
|
155
|
+
if isinstance(parsed_input, dict) and input_name in parsed_input:
|
|
156
|
+
activity_input = parsed_input[input_name]
|
|
157
|
+
# If parsing fails or the named parameter is not found, pass the original input
|
|
158
|
+
except (json.JSONDecodeError, TypeError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
if retry_options:
|
|
162
|
+
result = self._task_tracker.get_activity_call_result_with_retry(
|
|
163
|
+
activity_name, retry_options, activity_input
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
result = self._task_tracker.get_activity_call_result(activity_name, activity_input)
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
schema = function_schema(
|
|
170
|
+
func=activity_func._function._func,
|
|
171
|
+
docstring_style=None,
|
|
172
|
+
description_override=description,
|
|
173
|
+
use_docstring_info=True,
|
|
174
|
+
strict_json_schema=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return FunctionTool(
|
|
178
|
+
name=schema.name,
|
|
179
|
+
description=schema.description or "",
|
|
180
|
+
params_json_schema=schema.params_json_schema,
|
|
181
|
+
on_invoke_tool=run_activity,
|
|
182
|
+
strict_json_schema=True,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def __getattr__(self, name):
|
|
186
|
+
"""Delegate missing attributes to the underlying DurableOrchestrationContext."""
|
|
187
|
+
try:
|
|
188
|
+
return getattr(self._context, name)
|
|
189
|
+
except AttributeError:
|
|
190
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
191
|
+
|
|
192
|
+
def __dir__(self):
|
|
193
|
+
"""Improve introspection and tab-completion by including delegated attributes."""
|
|
194
|
+
return sorted(set(dir(type(self)) + list(self.__dict__) + dir(self._context)))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ensure_event_loop():
|
|
7
|
+
"""Ensure an event loop is available for sync execution context.
|
|
8
|
+
|
|
9
|
+
This is necessary when calling Runner.run_sync from Azure Functions
|
|
10
|
+
Durable orchestrators, which run in a synchronous context but need
|
|
11
|
+
an event loop for internal async operations.
|
|
12
|
+
"""
|
|
13
|
+
try:
|
|
14
|
+
asyncio.get_running_loop()
|
|
15
|
+
except RuntimeError:
|
|
16
|
+
loop = asyncio.new_event_loop()
|
|
17
|
+
asyncio.set_event_loop(loop)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
from azure.durable_functions.models.Task import TaskBase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class YieldException(BaseException):
|
|
7
|
+
"""Exception raised when an orchestrator should yield control."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, task: TaskBase):
|
|
10
|
+
super().__init__("Orchestrator should yield.")
|
|
11
|
+
self.task = task
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Handoff conversion utilities for Azure Durable Functions OpenAI agent operations."""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agents import Handoff
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DurableHandoff(BaseModel):
|
|
12
|
+
"""Serializable representation of a Handoff.
|
|
13
|
+
|
|
14
|
+
Contains only the data needed by the model execution to
|
|
15
|
+
determine what to handoff to, not the actual handoff invocation.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
tool_name: str
|
|
19
|
+
tool_description: str
|
|
20
|
+
input_json_schema: dict[str, Any]
|
|
21
|
+
agent_name: str
|
|
22
|
+
strict_json_schema: bool = True
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_handoff(cls, handoff: Handoff) -> "DurableHandoff":
|
|
26
|
+
"""Create a DurableHandoff from an OpenAI agent Handoff.
|
|
27
|
+
|
|
28
|
+
This method converts OpenAI agent Handoff instances into serializable
|
|
29
|
+
DurableHandoff objects for use within Azure Durable Functions.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
handoff : Handoff
|
|
34
|
+
The OpenAI agent Handoff to convert
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
DurableHandoff
|
|
39
|
+
A serializable handoff representation
|
|
40
|
+
"""
|
|
41
|
+
return cls(
|
|
42
|
+
tool_name=handoff.tool_name,
|
|
43
|
+
tool_description=handoff.tool_description,
|
|
44
|
+
input_json_schema=handoff.input_json_schema,
|
|
45
|
+
agent_name=handoff.agent_name,
|
|
46
|
+
strict_json_schema=handoff.strict_json_schema,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def to_handoff(self) -> Handoff[Any, Any]:
|
|
50
|
+
"""Create an OpenAI agent Handoff instance from this DurableHandoff.
|
|
51
|
+
|
|
52
|
+
This method converts the serializable DurableHandoff back into an
|
|
53
|
+
OpenAI agent Handoff instance for execution.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
Handoff
|
|
58
|
+
OpenAI agent Handoff instance
|
|
59
|
+
"""
|
|
60
|
+
return Handoff(
|
|
61
|
+
tool_name=self.tool_name,
|
|
62
|
+
tool_description=self.tool_description,
|
|
63
|
+
input_json_schema=self.input_json_schema,
|
|
64
|
+
agent_name=self.agent_name,
|
|
65
|
+
strict_json_schema=self.strict_json_schema,
|
|
66
|
+
on_invoke_handoff=lambda ctx, input: None,
|
|
67
|
+
)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, AsyncIterator, Optional, Union, cast
|
|
6
|
+
|
|
7
|
+
from azure.durable_functions.models.RetryOptions import RetryOptions
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from agents import (
|
|
10
|
+
AgentOutputSchema,
|
|
11
|
+
AgentOutputSchemaBase,
|
|
12
|
+
Handoff,
|
|
13
|
+
Model,
|
|
14
|
+
ModelProvider,
|
|
15
|
+
ModelResponse,
|
|
16
|
+
ModelSettings,
|
|
17
|
+
ModelTracing,
|
|
18
|
+
OpenAIProvider,
|
|
19
|
+
Tool,
|
|
20
|
+
TResponseInputItem,
|
|
21
|
+
UserError,
|
|
22
|
+
)
|
|
23
|
+
from agents.items import TResponseStreamEvent
|
|
24
|
+
from openai.types.responses.response_prompt_param import ResponsePromptParam
|
|
25
|
+
|
|
26
|
+
from .task_tracker import TaskTracker
|
|
27
|
+
from .tools import (
|
|
28
|
+
DurableTool,
|
|
29
|
+
create_tool_from_durable_tool,
|
|
30
|
+
convert_tool_to_durable_tool,
|
|
31
|
+
)
|
|
32
|
+
from .handoffs import DurableHandoff
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DurableAgentOutputSchema(AgentOutputSchemaBase, BaseModel):
|
|
36
|
+
"""Serializable representation of agent output schema."""
|
|
37
|
+
|
|
38
|
+
output_type_name: Optional[str] = None
|
|
39
|
+
output_schema: Optional[dict[str, Any]] = None
|
|
40
|
+
strict_json_schema: bool
|
|
41
|
+
|
|
42
|
+
def is_plain_text(self) -> bool:
|
|
43
|
+
"""Whether the output type is plain text (versus a JSON object)."""
|
|
44
|
+
return self.output_type_name in (None, "str")
|
|
45
|
+
|
|
46
|
+
def name(self) -> str:
|
|
47
|
+
"""Get the name of the output type."""
|
|
48
|
+
if self.output_type_name is None:
|
|
49
|
+
raise ValueError("Output type name has not been specified")
|
|
50
|
+
return self.output_type_name
|
|
51
|
+
|
|
52
|
+
def json_schema(self) -> dict[str, Any]:
|
|
53
|
+
"""Return the JSON schema of the output.
|
|
54
|
+
|
|
55
|
+
Will only be called if the output type is not plain text.
|
|
56
|
+
"""
|
|
57
|
+
if self.is_plain_text():
|
|
58
|
+
raise UserError("Cannot provide JSON schema for plain text output types")
|
|
59
|
+
if self.output_schema is None:
|
|
60
|
+
raise UserError("Output schema definition is missing")
|
|
61
|
+
return self.output_schema
|
|
62
|
+
|
|
63
|
+
def is_strict_json_schema(self) -> bool:
|
|
64
|
+
"""Check if the JSON schema is in strict mode.
|
|
65
|
+
|
|
66
|
+
Strict mode constrains the JSON schema features, but guarantees valid JSON.
|
|
67
|
+
See here for details:
|
|
68
|
+
https://platform.openai.com/docs/guides/structured-outputs#supported-schemas
|
|
69
|
+
"""
|
|
70
|
+
return self.strict_json_schema
|
|
71
|
+
|
|
72
|
+
def validate_json(self, json_str: str) -> Any:
|
|
73
|
+
"""Validate a JSON string against the output type.
|
|
74
|
+
|
|
75
|
+
You must return the validated object, or raise a `ModelBehaviorError` if
|
|
76
|
+
the JSON is invalid.
|
|
77
|
+
"""
|
|
78
|
+
raise NotImplementedError()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ModelTracingLevel(enum.IntEnum):
|
|
82
|
+
"""Serializable IntEnum representation of ModelTracing for Azure Durable Functions.
|
|
83
|
+
|
|
84
|
+
Values must match ModelTracing from the OpenAI SDK. This separate enum is required
|
|
85
|
+
because ModelTracing is a standard Enum while Pydantic serialization requires IntEnum
|
|
86
|
+
for proper JSON serialization in activity inputs.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
DISABLED = 0
|
|
90
|
+
ENABLED = 1
|
|
91
|
+
ENABLED_WITHOUT_DATA = 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DurableModelActivityInput(BaseModel):
|
|
95
|
+
"""Serializable input for the durable model invocation activity."""
|
|
96
|
+
|
|
97
|
+
input: Union[str, list[TResponseInputItem]]
|
|
98
|
+
model_settings: ModelSettings
|
|
99
|
+
tracing: ModelTracingLevel
|
|
100
|
+
model_name: Optional[str] = None
|
|
101
|
+
system_instructions: Optional[str] = None
|
|
102
|
+
tools: list[DurableTool] = Field(default_factory=list)
|
|
103
|
+
output_schema: Optional[DurableAgentOutputSchema] = None
|
|
104
|
+
handoffs: list[DurableHandoff] = Field(default_factory=list)
|
|
105
|
+
previous_response_id: Optional[str] = None
|
|
106
|
+
prompt: Optional[Any] = None
|
|
107
|
+
|
|
108
|
+
def to_json(self) -> str:
|
|
109
|
+
"""Convert to a JSON string."""
|
|
110
|
+
try:
|
|
111
|
+
return self.model_dump_json(warnings=False)
|
|
112
|
+
except Exception:
|
|
113
|
+
# Fallback to basic JSON serialization
|
|
114
|
+
try:
|
|
115
|
+
return json.dumps(self.model_dump(warnings=False), default=str)
|
|
116
|
+
except Exception as fallback_error:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Unable to serialize DurableModelActivityInput: {fallback_error}"
|
|
119
|
+
) from fallback_error
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_json(cls, json_str: str) -> 'DurableModelActivityInput':
|
|
123
|
+
"""Create from a JSON string."""
|
|
124
|
+
return cls.model_validate_json(json_str)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ModelInvoker:
|
|
128
|
+
"""Handles OpenAI model invocations for Durable Functions activities."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, model_provider: Optional[ModelProvider] = None):
|
|
131
|
+
"""Initialize the activity with a model provider."""
|
|
132
|
+
self._model_provider = model_provider or OpenAIProvider()
|
|
133
|
+
|
|
134
|
+
async def invoke_model_activity(self, input: DurableModelActivityInput) -> ModelResponse:
|
|
135
|
+
"""Activity that invokes a model with the given input."""
|
|
136
|
+
model = self._model_provider.get_model(input.model_name)
|
|
137
|
+
|
|
138
|
+
# Avoid https://github.com/pydantic/pydantic/issues/9541
|
|
139
|
+
normalized_input = json.loads(json.dumps(input.input, default=str))
|
|
140
|
+
|
|
141
|
+
# Convert durable tools to agent tools
|
|
142
|
+
tools = [
|
|
143
|
+
create_tool_from_durable_tool(durable_tool)
|
|
144
|
+
for durable_tool in input.tools
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
# Convert handoff descriptors to agent handoffs
|
|
148
|
+
handoffs = [
|
|
149
|
+
durable_handoff.to_handoff()
|
|
150
|
+
for durable_handoff in input.handoffs
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
return await model.get_response(
|
|
154
|
+
system_instructions=input.system_instructions,
|
|
155
|
+
input=normalized_input,
|
|
156
|
+
model_settings=input.model_settings,
|
|
157
|
+
tools=tools,
|
|
158
|
+
output_schema=input.output_schema,
|
|
159
|
+
handoffs=handoffs,
|
|
160
|
+
tracing=ModelTracing(input.tracing),
|
|
161
|
+
previous_response_id=input.previous_response_id,
|
|
162
|
+
prompt=input.prompt,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DurableActivityModel(Model):
|
|
167
|
+
"""A model implementation that uses durable activities for model invocations."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
model_name: Optional[str],
|
|
172
|
+
task_tracker: TaskTracker,
|
|
173
|
+
retry_options: Optional[RetryOptions],
|
|
174
|
+
activity_name: str,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.model_name = model_name
|
|
177
|
+
self.task_tracker = task_tracker
|
|
178
|
+
self.retry_options = retry_options
|
|
179
|
+
self.activity_name = activity_name
|
|
180
|
+
|
|
181
|
+
async def get_response(
|
|
182
|
+
self,
|
|
183
|
+
system_instructions: Optional[str],
|
|
184
|
+
input: Union[str, list[TResponseInputItem]],
|
|
185
|
+
model_settings: ModelSettings,
|
|
186
|
+
tools: list[Tool],
|
|
187
|
+
output_schema: Optional[AgentOutputSchemaBase],
|
|
188
|
+
handoffs: list[Handoff],
|
|
189
|
+
tracing: ModelTracing,
|
|
190
|
+
*,
|
|
191
|
+
previous_response_id: Optional[str],
|
|
192
|
+
prompt: Optional[ResponsePromptParam],
|
|
193
|
+
conversation_id: Optional[str] = None,
|
|
194
|
+
) -> ModelResponse:
|
|
195
|
+
"""Get a response from the model."""
|
|
196
|
+
# Convert agent tools to Durable tools
|
|
197
|
+
durable_tools = [convert_tool_to_durable_tool(tool) for tool in tools]
|
|
198
|
+
|
|
199
|
+
# Convert agent handoffs to Durable handoff descriptors
|
|
200
|
+
durable_handoffs = [DurableHandoff.from_handoff(handoff) for handoff in handoffs]
|
|
201
|
+
if output_schema is not None and not isinstance(
|
|
202
|
+
output_schema, AgentOutputSchema
|
|
203
|
+
):
|
|
204
|
+
raise TypeError(
|
|
205
|
+
f"Only AgentOutputSchema is supported by Durable Model, "
|
|
206
|
+
f"got {type(output_schema).__name__}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
output_schema_input = (
|
|
210
|
+
None
|
|
211
|
+
if output_schema is None
|
|
212
|
+
else DurableAgentOutputSchema(
|
|
213
|
+
output_type_name=output_schema.name(),
|
|
214
|
+
output_schema=(
|
|
215
|
+
output_schema.json_schema()
|
|
216
|
+
if not output_schema.is_plain_text()
|
|
217
|
+
else None
|
|
218
|
+
),
|
|
219
|
+
strict_json_schema=output_schema.is_strict_json_schema(),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
activity_input = DurableModelActivityInput(
|
|
224
|
+
model_name=self.model_name,
|
|
225
|
+
system_instructions=system_instructions,
|
|
226
|
+
input=cast(Union[str, list[TResponseInputItem]], input),
|
|
227
|
+
model_settings=model_settings,
|
|
228
|
+
tools=durable_tools,
|
|
229
|
+
output_schema=output_schema_input,
|
|
230
|
+
handoffs=durable_handoffs,
|
|
231
|
+
tracing=ModelTracingLevel.DISABLED, # ModelTracingLevel(tracing.value),
|
|
232
|
+
previous_response_id=previous_response_id,
|
|
233
|
+
prompt=prompt,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
activity_input_json = activity_input.to_json()
|
|
237
|
+
|
|
238
|
+
if self.retry_options:
|
|
239
|
+
response = self.task_tracker.get_activity_call_result_with_retry(
|
|
240
|
+
self.activity_name,
|
|
241
|
+
self.retry_options,
|
|
242
|
+
activity_input_json,
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
response = self.task_tracker.get_activity_call_result(
|
|
246
|
+
self.activity_name,
|
|
247
|
+
activity_input_json
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
json_response = json.loads(response)
|
|
251
|
+
model_response = ModelResponse(**json_response)
|
|
252
|
+
return model_response
|
|
253
|
+
|
|
254
|
+
def stream_response(
|
|
255
|
+
self,
|
|
256
|
+
system_instructions: Optional[str],
|
|
257
|
+
input: Union[str, list[TResponseInputItem]],
|
|
258
|
+
model_settings: ModelSettings,
|
|
259
|
+
tools: list[Tool],
|
|
260
|
+
output_schema: Optional[AgentOutputSchemaBase],
|
|
261
|
+
handoffs: list[Handoff],
|
|
262
|
+
tracing: ModelTracing,
|
|
263
|
+
*,
|
|
264
|
+
previous_response_id: Optional[str],
|
|
265
|
+
prompt: Optional[ResponsePromptParam],
|
|
266
|
+
) -> AsyncIterator[TResponseStreamEvent]:
|
|
267
|
+
"""Stream a response from the model."""
|
|
268
|
+
raise NotImplementedError("Durable model doesn't support streams yet")
|