azure-functions-durable 1.3.2__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/models/DurableOrchestrationContext.py +24 -0
- 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.2.dist-info → azure_functions_durable-1.4.0.dist-info}/METADATA +7 -2
- {azure_functions_durable-1.3.2.dist-info → azure_functions_durable-1.4.0.dist-info}/RECORD +26 -9
- tests/models/test_DurableOrchestrationContext.py +8 -0
- 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.2.dist-info → azure_functions_durable-1.4.0.dist-info}/LICENSE +0 -0
- {azure_functions_durable-1.3.2.dist-info → azure_functions_durable-1.4.0.dist-info}/WHEEL +0 -0
- {azure_functions_durable-1.3.2.dist-info → azure_functions_durable-1.4.0.dist-info}/top_level.txt +0 -0
|
@@ -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")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from agents import ModelProvider, ModelResponse
|
|
6
|
+
from agents.run import set_default_agent_runner
|
|
7
|
+
from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext
|
|
8
|
+
from azure.durable_functions.models.RetryOptions import RetryOptions
|
|
9
|
+
from .model_invocation_activity import DurableModelActivityInput, ModelInvoker
|
|
10
|
+
from .task_tracker import TaskTracker
|
|
11
|
+
from .runner import DurableOpenAIRunner
|
|
12
|
+
from .context import DurableAIAgentContext
|
|
13
|
+
from .event_loop import ensure_event_loop
|
|
14
|
+
from .usage_telemetry import UsageTelemetry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def durable_openai_agent_activity(input: str, model_provider: ModelProvider) -> str:
|
|
18
|
+
"""Activity logic that handles OpenAI model invocations."""
|
|
19
|
+
activity_input = DurableModelActivityInput.from_json(input)
|
|
20
|
+
|
|
21
|
+
model_invoker = ModelInvoker(model_provider=model_provider)
|
|
22
|
+
result = await model_invoker.invoke_model_activity(activity_input)
|
|
23
|
+
|
|
24
|
+
# Use safe/public Pydantic API when possible. Prefer model_dump_json if result is a BaseModel
|
|
25
|
+
# Otherwise handle common types (str/bytes/dict/list) and fall back to json.dumps.
|
|
26
|
+
import json as _json
|
|
27
|
+
|
|
28
|
+
if hasattr(result, "model_dump_json"):
|
|
29
|
+
# Pydantic v2 BaseModel
|
|
30
|
+
json_str = result.model_dump_json()
|
|
31
|
+
else:
|
|
32
|
+
if isinstance(result, bytes):
|
|
33
|
+
json_str = result.decode()
|
|
34
|
+
elif isinstance(result, str):
|
|
35
|
+
json_str = result
|
|
36
|
+
else:
|
|
37
|
+
# Try the internal serializer as a last resort, but fall back to json.dumps
|
|
38
|
+
try:
|
|
39
|
+
json_bytes = ModelResponse.__pydantic_serializer__.to_json(result)
|
|
40
|
+
json_str = json_bytes.decode()
|
|
41
|
+
except Exception:
|
|
42
|
+
json_str = _json.dumps(result)
|
|
43
|
+
|
|
44
|
+
return json_str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def durable_openai_agent_orchestrator_generator(
|
|
48
|
+
func,
|
|
49
|
+
durable_orchestration_context: DurableOrchestrationContext,
|
|
50
|
+
model_retry_options: Optional[RetryOptions],
|
|
51
|
+
activity_name: str,
|
|
52
|
+
):
|
|
53
|
+
"""Adapts the synchronous OpenAI Agents function to an Durable orchestrator generator."""
|
|
54
|
+
# Log versions the first time this generator is invoked
|
|
55
|
+
UsageTelemetry.log_usage_once()
|
|
56
|
+
|
|
57
|
+
ensure_event_loop()
|
|
58
|
+
task_tracker = TaskTracker(durable_orchestration_context)
|
|
59
|
+
durable_ai_agent_context = DurableAIAgentContext(
|
|
60
|
+
durable_orchestration_context, task_tracker, model_retry_options
|
|
61
|
+
)
|
|
62
|
+
durable_openai_runner = DurableOpenAIRunner(
|
|
63
|
+
context=durable_ai_agent_context, activity_name=activity_name)
|
|
64
|
+
set_default_agent_runner(durable_openai_runner)
|
|
65
|
+
|
|
66
|
+
func_with_context = partial(func, durable_ai_agent_context)
|
|
67
|
+
return task_tracker.execute_orchestrator_function(func_with_context)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Any, Union
|
|
6
|
+
|
|
7
|
+
from agents import (
|
|
8
|
+
Agent,
|
|
9
|
+
RunConfig,
|
|
10
|
+
RunResult,
|
|
11
|
+
RunResultStreaming,
|
|
12
|
+
TContext,
|
|
13
|
+
TResponseInputItem,
|
|
14
|
+
)
|
|
15
|
+
from agents.run import DEFAULT_AGENT_RUNNER, DEFAULT_MAX_TURNS, AgentRunner
|
|
16
|
+
from pydantic_core import to_json
|
|
17
|
+
|
|
18
|
+
from .context import DurableAIAgentContext
|
|
19
|
+
from .model_invocation_activity import DurableActivityModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DurableOpenAIRunner:
|
|
23
|
+
"""Runner for OpenAI agents using Durable Functions orchestration."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, context: DurableAIAgentContext, activity_name: str) -> None:
|
|
26
|
+
self._runner = DEFAULT_AGENT_RUNNER or AgentRunner()
|
|
27
|
+
self._context = context
|
|
28
|
+
self._activity_name = activity_name
|
|
29
|
+
|
|
30
|
+
def _prepare_run_config(
|
|
31
|
+
self,
|
|
32
|
+
starting_agent: Agent[TContext],
|
|
33
|
+
input: Union[str, list[TResponseInputItem]],
|
|
34
|
+
**kwargs: Any,
|
|
35
|
+
) -> tuple[Union[str, list[TResponseInputItem]], RunConfig, dict[str, Any]]:
|
|
36
|
+
"""Prepare and validate the run configuration and parameters for agent execution."""
|
|
37
|
+
# Avoid https://github.com/pydantic/pydantic/issues/9541
|
|
38
|
+
normalized_input = json.loads(to_json(input))
|
|
39
|
+
|
|
40
|
+
run_config = kwargs.get("run_config") or RunConfig()
|
|
41
|
+
|
|
42
|
+
model_name = run_config.model or starting_agent.model
|
|
43
|
+
if model_name and not isinstance(model_name, str):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"For agent execution in Durable Functions, model name in run_config or "
|
|
46
|
+
"starting_agent must be a string."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
updated_run_config = replace(
|
|
50
|
+
run_config,
|
|
51
|
+
model=DurableActivityModel(
|
|
52
|
+
model_name=model_name,
|
|
53
|
+
task_tracker=self._context._task_tracker,
|
|
54
|
+
retry_options=self._context._model_retry_options,
|
|
55
|
+
activity_name=self._activity_name,
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
run_params = {
|
|
60
|
+
"context": kwargs.get("context"),
|
|
61
|
+
"max_turns": kwargs.get("max_turns", DEFAULT_MAX_TURNS),
|
|
62
|
+
"hooks": kwargs.get("hooks"),
|
|
63
|
+
"previous_response_id": kwargs.get("previous_response_id"),
|
|
64
|
+
"session": kwargs.get("session"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return normalized_input, updated_run_config, run_params
|
|
68
|
+
|
|
69
|
+
def run_sync(
|
|
70
|
+
self,
|
|
71
|
+
starting_agent: Agent[TContext],
|
|
72
|
+
input: Union[str, list[TResponseInputItem]],
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> RunResult:
|
|
75
|
+
"""Run an agent synchronously with the given input and configuration."""
|
|
76
|
+
normalized_input, updated_run_config, run_params = self._prepare_run_config(
|
|
77
|
+
starting_agent, input, **kwargs
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return self._runner.run_sync(
|
|
81
|
+
starting_agent=starting_agent,
|
|
82
|
+
input=normalized_input,
|
|
83
|
+
run_config=updated_run_config,
|
|
84
|
+
**run_params,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def run(
|
|
88
|
+
self,
|
|
89
|
+
starting_agent: Agent[TContext],
|
|
90
|
+
input: Union[str, list[TResponseInputItem]],
|
|
91
|
+
**kwargs: Any,
|
|
92
|
+
) -> RunResult:
|
|
93
|
+
"""Run an agent asynchronously. Not supported in Durable Functions."""
|
|
94
|
+
raise RuntimeError("Durable Functions do not support asynchronous runs.")
|
|
95
|
+
|
|
96
|
+
def run_streamed(
|
|
97
|
+
self,
|
|
98
|
+
starting_agent: Agent[TContext],
|
|
99
|
+
input: Union[str, list[TResponseInputItem]],
|
|
100
|
+
**kwargs: Any,
|
|
101
|
+
) -> RunResultStreaming:
|
|
102
|
+
"""Run an agent with streaming. Not supported in Durable Functions."""
|
|
103
|
+
raise RuntimeError("Durable Functions do not support streaming.")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
import json
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from azure.durable_functions.models.DurableOrchestrationContext import (
|
|
8
|
+
DurableOrchestrationContext,
|
|
9
|
+
)
|
|
10
|
+
from azure.durable_functions.models.history.HistoryEventType import HistoryEventType
|
|
11
|
+
from azure.durable_functions.models.RetryOptions import RetryOptions
|
|
12
|
+
|
|
13
|
+
from .exceptions import YieldException
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaskTracker:
|
|
17
|
+
"""Tracks activity calls and handles task result processing for durable AI agents."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, context: DurableOrchestrationContext):
|
|
20
|
+
self._context = context
|
|
21
|
+
self._activities_called = 0
|
|
22
|
+
self._tasks_to_yield = []
|
|
23
|
+
|
|
24
|
+
def _get_activity_result_or_raise(self, task):
|
|
25
|
+
"""Return the activity result if available; otherwise raise ``YieldException`` to defer.
|
|
26
|
+
|
|
27
|
+
The first time an activity is scheduled its result won't yet exist in the
|
|
28
|
+
orchestration history, so we raise ``YieldException`` with the task so the
|
|
29
|
+
orchestrator can yield it. On replay, once the corresponding TASK_COMPLETED
|
|
30
|
+
history event is present, we capture the result and queue the task for a
|
|
31
|
+
later yield (to preserve ordering) while returning the deserialized value.
|
|
32
|
+
"""
|
|
33
|
+
self.record_activity_call()
|
|
34
|
+
|
|
35
|
+
histories = self._context.histories
|
|
36
|
+
completed_tasks = [
|
|
37
|
+
entry for entry in histories
|
|
38
|
+
if entry.event_type == HistoryEventType.TASK_COMPLETED
|
|
39
|
+
]
|
|
40
|
+
if len(completed_tasks) < self._activities_called:
|
|
41
|
+
# Result not yet available in history -> raise to signal a yield now
|
|
42
|
+
raise YieldException(task)
|
|
43
|
+
# Result exists (replay). Queue task to be yielded after returning value.
|
|
44
|
+
#
|
|
45
|
+
# We cannot just yield it now because this method can be called from
|
|
46
|
+
# deeply nested code paths that we don't control (such as the
|
|
47
|
+
# OpenAI Agents SDK internals), and yielding here would lead to
|
|
48
|
+
# unintended behavior. Instead, we queue the task to be yielded
|
|
49
|
+
# later and return the result recorded in the history, so the
|
|
50
|
+
# code invoking this method can continue executing normally.
|
|
51
|
+
self._tasks_to_yield.append(task)
|
|
52
|
+
|
|
53
|
+
result_json = completed_tasks[self._activities_called - 1].Result
|
|
54
|
+
result = json.loads(result_json)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def get_activity_call_result(self, activity_name, input: Any):
|
|
58
|
+
"""Call an activity and return its result or raise ``YieldException`` if pending."""
|
|
59
|
+
task = self._context.call_activity(activity_name, input)
|
|
60
|
+
return self._get_activity_result_or_raise(task)
|
|
61
|
+
|
|
62
|
+
def get_activity_call_result_with_retry(
|
|
63
|
+
self, activity_name, retry_options: RetryOptions, input: Any
|
|
64
|
+
):
|
|
65
|
+
"""Call an activity with retry and return its result or raise YieldException if pending."""
|
|
66
|
+
task = self._context.call_activity_with_retry(activity_name, retry_options, input)
|
|
67
|
+
return self._get_activity_result_or_raise(task)
|
|
68
|
+
|
|
69
|
+
def record_activity_call(self):
|
|
70
|
+
"""Record that an activity was called."""
|
|
71
|
+
self._activities_called += 1
|
|
72
|
+
|
|
73
|
+
def _yield_and_clear_tasks(self):
|
|
74
|
+
"""Yield all accumulated tasks and clear the tasks list."""
|
|
75
|
+
for task in self._tasks_to_yield:
|
|
76
|
+
yield task
|
|
77
|
+
self._tasks_to_yield.clear()
|
|
78
|
+
|
|
79
|
+
def execute_orchestrator_function(self, func):
|
|
80
|
+
"""Execute the orchestrator function with comprehensive task and exception handling.
|
|
81
|
+
|
|
82
|
+
The orchestrator function can exhibit any combination of the following behaviors:
|
|
83
|
+
- Execute regular code and return a value or raise an exception
|
|
84
|
+
- Invoke get_activity_call_result or get_activity_call_result_with_retry, which leads to
|
|
85
|
+
either interrupting the orchestrator function immediately (because of YieldException),
|
|
86
|
+
or queueing the task for later yielding while continuing execution
|
|
87
|
+
- Invoke DurableAIAgentContext.call_activity or call_activity_with_retry (which must lead
|
|
88
|
+
to corresponding record_activity_call invocations)
|
|
89
|
+
- Yield tasks (typically produced by DurableAIAgentContext methods like call_activity,
|
|
90
|
+
wait_for_external_event, etc.), which may or may not interrupt orchestrator function
|
|
91
|
+
execution
|
|
92
|
+
- Mix all of the above in any combination
|
|
93
|
+
|
|
94
|
+
This method converts both YieldException and regular yields into a sequence of yields
|
|
95
|
+
preserving the order, while also capturing return values through the generator protocol.
|
|
96
|
+
For example, if the orchestrator function yields task A, then queues task B for yielding,
|
|
97
|
+
then raises YieldException wrapping task C, this method makes sure that the resulting
|
|
98
|
+
sequence of yields is: (A, B, C).
|
|
99
|
+
|
|
100
|
+
Args
|
|
101
|
+
----
|
|
102
|
+
func: The orchestrator function to execute (generator or regular function)
|
|
103
|
+
|
|
104
|
+
Yields
|
|
105
|
+
------
|
|
106
|
+
Tasks yielded by the orchestrator function and tasks wrapped in YieldException
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
The return value from the orchestrator function
|
|
111
|
+
"""
|
|
112
|
+
if inspect.isgeneratorfunction(func):
|
|
113
|
+
gen = iter(func())
|
|
114
|
+
try:
|
|
115
|
+
# prime the subiterator
|
|
116
|
+
value = next(gen)
|
|
117
|
+
yield from self._yield_and_clear_tasks()
|
|
118
|
+
while True:
|
|
119
|
+
try:
|
|
120
|
+
# send whatever was sent into us down to the subgenerator
|
|
121
|
+
yield from self._yield_and_clear_tasks()
|
|
122
|
+
sent = yield value
|
|
123
|
+
except GeneratorExit:
|
|
124
|
+
# ensure the subgenerator is closed
|
|
125
|
+
if hasattr(gen, "close"):
|
|
126
|
+
gen.close()
|
|
127
|
+
raise
|
|
128
|
+
except BaseException as exc:
|
|
129
|
+
# forward thrown exceptions if possible
|
|
130
|
+
if hasattr(gen, "throw"):
|
|
131
|
+
value = gen.throw(type(exc), exc, exc.__traceback__)
|
|
132
|
+
else:
|
|
133
|
+
raise
|
|
134
|
+
else:
|
|
135
|
+
# normal path: forward .send (or .__next__)
|
|
136
|
+
if hasattr(gen, "send"):
|
|
137
|
+
value = gen.send(sent)
|
|
138
|
+
else:
|
|
139
|
+
value = next(gen)
|
|
140
|
+
except StopIteration as e:
|
|
141
|
+
yield from self._yield_and_clear_tasks()
|
|
142
|
+
return TaskTracker._durable_serializer(e.value)
|
|
143
|
+
except YieldException as e:
|
|
144
|
+
yield from self._yield_and_clear_tasks()
|
|
145
|
+
yield e.task
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
result = func()
|
|
149
|
+
return TaskTracker._durable_serializer(result)
|
|
150
|
+
except YieldException as e:
|
|
151
|
+
yield from self._yield_and_clear_tasks()
|
|
152
|
+
yield e.task
|
|
153
|
+
finally:
|
|
154
|
+
yield from self._yield_and_clear_tasks()
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _durable_serializer(obj: Any) -> str:
|
|
158
|
+
# Strings are already "serialized"
|
|
159
|
+
if type(obj) is str:
|
|
160
|
+
return obj
|
|
161
|
+
|
|
162
|
+
# Serialize "Durable" and OpenAI models, and typed dictionaries
|
|
163
|
+
if callable(getattr(obj, "to_json", None)):
|
|
164
|
+
return obj.to_json()
|
|
165
|
+
|
|
166
|
+
# Serialize Pydantic models
|
|
167
|
+
if callable(getattr(obj, "model_dump_json", None)):
|
|
168
|
+
return obj.model_dump_json()
|
|
169
|
+
|
|
170
|
+
# Fallback to default JSON serialization
|
|
171
|
+
return json.dumps(obj)
|