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.
Files changed (26) hide show
  1. azure/durable_functions/__init__.py +8 -0
  2. azure/durable_functions/decorators/durable_app.py +64 -1
  3. azure/durable_functions/models/DurableOrchestrationContext.py +24 -0
  4. azure/durable_functions/openai_agents/__init__.py +13 -0
  5. azure/durable_functions/openai_agents/context.py +194 -0
  6. azure/durable_functions/openai_agents/event_loop.py +17 -0
  7. azure/durable_functions/openai_agents/exceptions.py +11 -0
  8. azure/durable_functions/openai_agents/handoffs.py +67 -0
  9. azure/durable_functions/openai_agents/model_invocation_activity.py +268 -0
  10. azure/durable_functions/openai_agents/orchestrator_generator.py +67 -0
  11. azure/durable_functions/openai_agents/runner.py +103 -0
  12. azure/durable_functions/openai_agents/task_tracker.py +171 -0
  13. azure/durable_functions/openai_agents/tools.py +148 -0
  14. azure/durable_functions/openai_agents/usage_telemetry.py +69 -0
  15. {azure_functions_durable-1.3.2.dist-info → azure_functions_durable-1.4.0.dist-info}/METADATA +7 -2
  16. {azure_functions_durable-1.3.2.dist-info → azure_functions_durable-1.4.0.dist-info}/RECORD +26 -9
  17. tests/models/test_DurableOrchestrationContext.py +8 -0
  18. tests/openai_agents/__init__.py +0 -0
  19. tests/openai_agents/test_context.py +466 -0
  20. tests/openai_agents/test_task_tracker.py +290 -0
  21. tests/openai_agents/test_usage_telemetry.py +99 -0
  22. tests/orchestrator/openai_agents/__init__.py +0 -0
  23. tests/orchestrator/openai_agents/test_openai_agents.py +316 -0
  24. {azure_functions_durable-1.3.2.dist-info → azure_functions_durable-1.4.0.dist-info}/LICENSE +0 -0
  25. {azure_functions_durable-1.3.2.dist-info → azure_functions_durable-1.4.0.dist-info}/WHEEL +0 -0
  26. {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)