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,466 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ import pytest
4
+ from unittest.mock import Mock, patch
5
+
6
+ from azure.durable_functions.openai_agents.context import DurableAIAgentContext
7
+ from azure.durable_functions.openai_agents.task_tracker import TaskTracker
8
+ from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext
9
+ from azure.durable_functions.models.RetryOptions import RetryOptions
10
+
11
+ from agents.tool import FunctionTool
12
+
13
+
14
+ class TestDurableAIAgentContext:
15
+ """Test suite for DurableAIAgentContext class."""
16
+
17
+ def _create_mock_orchestration_context(self):
18
+ """Create a mock DurableOrchestrationContext for testing."""
19
+ orchestration_context = Mock(spec=DurableOrchestrationContext)
20
+ orchestration_context.call_activity = Mock(return_value="mock_task")
21
+ orchestration_context.call_activity_with_retry = Mock(return_value="mock_task_with_retry")
22
+ orchestration_context.instance_id = "test_instance_id"
23
+ orchestration_context.current_utc_datetime = "2023-01-01T00:00:00Z"
24
+ orchestration_context.is_replaying = False
25
+ return orchestration_context
26
+
27
+ def _create_mock_task_tracker(self):
28
+ """Create a mock TaskTracker for testing."""
29
+ task_tracker = Mock(spec=TaskTracker)
30
+ task_tracker.record_activity_call = Mock()
31
+ task_tracker.get_activity_call_result = Mock(return_value="activity_result")
32
+ task_tracker.get_activity_call_result_with_retry = Mock(return_value="retry_activity_result")
33
+ return task_tracker
34
+
35
+ def _create_mock_activity_func(self, name="test_activity", input_name=None,
36
+ activity_name=None):
37
+ """Create a mock activity function with configurable parameters."""
38
+ mock_activity_func = Mock()
39
+ mock_activity_func._function._name = name
40
+ mock_activity_func._function._func = lambda x: x
41
+
42
+ if input_name is not None:
43
+ # Create trigger with input_name
44
+ mock_activity_func._function._trigger = Mock()
45
+ mock_activity_func._function._trigger.activity = activity_name
46
+ mock_activity_func._function._trigger.name = input_name
47
+ else:
48
+ # No trigger means no input_name
49
+ mock_activity_func._function._trigger = None
50
+
51
+ return mock_activity_func
52
+
53
+ def _setup_activity_tool_mocks(self, mock_function_tool, mock_function_schema,
54
+ activity_name="test_activity", description=""):
55
+ """Setup common mocks for function_schema and FunctionTool."""
56
+ mock_schema = Mock()
57
+ mock_schema.name = activity_name
58
+ mock_schema.description = description
59
+ mock_schema.params_json_schema = {"type": "object"}
60
+ mock_function_schema.return_value = mock_schema
61
+
62
+ mock_tool = Mock(spec=FunctionTool)
63
+ mock_function_tool.return_value = mock_tool
64
+
65
+ return mock_tool
66
+
67
+ def _invoke_activity_tool(self, run_activity, input_data):
68
+ """Helper to invoke the activity tool with asyncio."""
69
+ mock_ctx = Mock()
70
+ import asyncio
71
+ return asyncio.run(run_activity(mock_ctx, input_data))
72
+
73
+ def _test_activity_tool_input_processing(self, input_name=None, input_data="",
74
+ expected_input_parameter_value="",
75
+ retry_options=None,
76
+ activity_name="test_activity"):
77
+ """Framework method that runs a complete input processing test."""
78
+ with patch('azure.durable_functions.openai_agents.context.function_schema') \
79
+ as mock_function_schema, \
80
+ patch('azure.durable_functions.openai_agents.context.FunctionTool') \
81
+ as mock_function_tool:
82
+
83
+ # Setup
84
+ orchestration_context = self._create_mock_orchestration_context()
85
+ task_tracker = self._create_mock_task_tracker()
86
+ mock_activity_func = self._create_mock_activity_func(
87
+ name=activity_name, input_name=input_name)
88
+ self._setup_activity_tool_mocks(
89
+ mock_function_tool, mock_function_schema, activity_name)
90
+
91
+ # Create context and tool
92
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
93
+ ai_context.create_activity_tool(mock_activity_func, retry_options=retry_options)
94
+
95
+ # Get and invoke the run_activity function
96
+ call_args = mock_function_tool.call_args
97
+ run_activity = call_args[1]['on_invoke_tool']
98
+ self._invoke_activity_tool(run_activity, input_data)
99
+
100
+ # Verify the expected call was made
101
+ if retry_options:
102
+ task_tracker.get_activity_call_result_with_retry.assert_called_once_with(
103
+ activity_name, retry_options, expected_input_parameter_value
104
+ )
105
+ else:
106
+ task_tracker.get_activity_call_result.assert_called_once_with(
107
+ activity_name, expected_input_parameter_value
108
+ )
109
+
110
+ def test_init_creates_context_successfully(self):
111
+ """Test that __init__ creates a DurableAIAgentContext successfully."""
112
+ orchestration_context = self._create_mock_orchestration_context()
113
+ task_tracker = self._create_mock_task_tracker()
114
+ retry_options = RetryOptions(1000, 3)
115
+
116
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, retry_options)
117
+
118
+ assert isinstance(ai_context, DurableAIAgentContext)
119
+ assert not isinstance(ai_context, DurableOrchestrationContext)
120
+
121
+ def test_call_activity_delegates_and_records(self):
122
+ """Test that call_activity delegates to context and records activity call."""
123
+ orchestration_context = self._create_mock_orchestration_context()
124
+ task_tracker = self._create_mock_task_tracker()
125
+
126
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
127
+ result = ai_context.call_activity("test_activity", "test_input")
128
+
129
+ orchestration_context.call_activity.assert_called_once_with("test_activity", "test_input")
130
+ task_tracker.record_activity_call.assert_called_once()
131
+ assert result == "mock_task"
132
+
133
+ def test_call_activity_with_retry_delegates_and_records(self):
134
+ """Test that call_activity_with_retry delegates to context and records activity call."""
135
+ orchestration_context = self._create_mock_orchestration_context()
136
+ task_tracker = self._create_mock_task_tracker()
137
+ retry_options = RetryOptions(1000, 3)
138
+
139
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
140
+ result = ai_context.call_activity_with_retry("test_activity", retry_options, "test_input")
141
+
142
+ orchestration_context.call_activity_with_retry.assert_called_once_with(
143
+ "test_activity", retry_options, "test_input"
144
+ )
145
+ task_tracker.record_activity_call.assert_called_once()
146
+ assert result == "mock_task_with_retry"
147
+
148
+ @patch('azure.durable_functions.openai_agents.context.function_schema')
149
+ @patch('azure.durable_functions.openai_agents.context.FunctionTool')
150
+ def test_activity_as_tool_creates_function_tool(self, mock_function_tool, mock_function_schema):
151
+ """Test that create_activity_tool creates a FunctionTool with correct parameters."""
152
+ orchestration_context = self._create_mock_orchestration_context()
153
+ task_tracker = self._create_mock_task_tracker()
154
+
155
+ # Mock the activity function
156
+ mock_activity_func = Mock()
157
+ mock_activity_func._function._name = "test_activity"
158
+ mock_activity_func._function._func = lambda x: x
159
+
160
+ # Mock the schema
161
+ mock_schema = Mock()
162
+ mock_schema.name = "test_activity"
163
+ mock_schema.description = "Test activity description"
164
+ mock_schema.params_json_schema = {"type": "object"}
165
+ mock_function_schema.return_value = mock_schema
166
+
167
+ # Mock FunctionTool
168
+ mock_tool = Mock(spec=FunctionTool)
169
+ mock_function_tool.return_value = mock_tool
170
+
171
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
172
+ retry_options = RetryOptions(1000, 3)
173
+
174
+ result = ai_context.create_activity_tool(
175
+ mock_activity_func,
176
+ description="Custom description",
177
+ retry_options=retry_options
178
+ )
179
+
180
+ # Verify function_schema was called correctly
181
+ mock_function_schema.assert_called_once_with(
182
+ func=mock_activity_func._function._func,
183
+ docstring_style=None,
184
+ description_override="Custom description",
185
+ use_docstring_info=True,
186
+ strict_json_schema=True,
187
+ )
188
+
189
+ # Verify FunctionTool was created correctly
190
+ mock_function_tool.assert_called_once()
191
+ call_args = mock_function_tool.call_args
192
+ assert call_args[1]['name'] == "test_activity"
193
+ assert call_args[1]['description'] == "Test activity description"
194
+ assert call_args[1]['params_json_schema'] == {"type": "object"}
195
+ assert call_args[1]['strict_json_schema'] is True
196
+ assert callable(call_args[1]['on_invoke_tool'])
197
+
198
+ assert result is mock_tool
199
+
200
+ @patch('azure.durable_functions.openai_agents.context.function_schema')
201
+ @patch('azure.durable_functions.openai_agents.context.FunctionTool')
202
+ def test_activity_as_tool_with_default_retry_options(self, mock_function_tool, mock_function_schema):
203
+ """Test that create_activity_tool uses default retry options when none provided."""
204
+ orchestration_context = self._create_mock_orchestration_context()
205
+ task_tracker = self._create_mock_task_tracker()
206
+
207
+ mock_activity_func = Mock()
208
+ mock_activity_func._function._name = "test_activity"
209
+ mock_activity_func._function._func = lambda x: x
210
+
211
+ mock_schema = Mock()
212
+ mock_schema.name = "test_activity"
213
+ mock_schema.description = "Test description"
214
+ mock_schema.params_json_schema = {"type": "object"}
215
+ mock_function_schema.return_value = mock_schema
216
+
217
+ mock_tool = Mock(spec=FunctionTool)
218
+ mock_function_tool.return_value = mock_tool
219
+
220
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
221
+
222
+ # Call with default retry options
223
+ result = ai_context.create_activity_tool(mock_activity_func)
224
+
225
+ # Should still create the tool successfully
226
+ assert result is mock_tool
227
+ mock_function_tool.assert_called_once()
228
+
229
+ @patch('azure.durable_functions.openai_agents.context.function_schema')
230
+ @patch('azure.durable_functions.openai_agents.context.FunctionTool')
231
+ def test_activity_as_tool_run_activity_with_retry(self, mock_function_tool, mock_function_schema):
232
+ """Test that the run_activity function calls task tracker with retry options."""
233
+ orchestration_context = self._create_mock_orchestration_context()
234
+ task_tracker = self._create_mock_task_tracker()
235
+
236
+ mock_activity_func = Mock()
237
+ mock_activity_func._function._name = "test_activity"
238
+ mock_activity_func._function._trigger = None
239
+ mock_activity_func._function._func = lambda x: x
240
+
241
+ mock_schema = Mock()
242
+ mock_schema.name = "test_activity"
243
+ mock_schema.description = ""
244
+ mock_schema.params_json_schema = {"type": "object"}
245
+ mock_function_schema.return_value = mock_schema
246
+
247
+ mock_tool = Mock(spec=FunctionTool)
248
+ mock_function_tool.return_value = mock_tool
249
+
250
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
251
+ retry_options = RetryOptions(1000, 3)
252
+
253
+ ai_context.create_activity_tool(mock_activity_func, retry_options=retry_options)
254
+
255
+ # Get the run_activity function that was passed to FunctionTool
256
+ call_args = mock_function_tool.call_args
257
+ run_activity = call_args[1]['on_invoke_tool']
258
+
259
+ # Create a mock context wrapper
260
+ mock_ctx = Mock()
261
+
262
+ # Call the run_activity function
263
+ import asyncio
264
+ result = asyncio.run(run_activity(mock_ctx, "test_input"))
265
+
266
+ # Verify the task tracker was called with retry options
267
+ task_tracker.get_activity_call_result_with_retry.assert_called_once_with(
268
+ "test_activity", retry_options, "test_input"
269
+ )
270
+ assert result == "retry_activity_result"
271
+
272
+ @patch('azure.durable_functions.openai_agents.context.function_schema')
273
+ @patch('azure.durable_functions.openai_agents.context.FunctionTool')
274
+ def test_activity_as_tool_run_activity_without_retry(self, mock_function_tool, mock_function_schema):
275
+ """Test that the run_activity function calls task tracker without retry when retry_options is None."""
276
+ orchestration_context = self._create_mock_orchestration_context()
277
+ task_tracker = self._create_mock_task_tracker()
278
+
279
+ mock_activity_func = Mock()
280
+ mock_activity_func._function._name = "test_activity"
281
+ mock_activity_func._function._trigger = None
282
+ mock_activity_func._function._func = lambda x: x
283
+
284
+ mock_schema = Mock()
285
+ mock_schema.name = "test_activity"
286
+ mock_schema.description = ""
287
+ mock_schema.params_json_schema = {"type": "object"}
288
+ mock_function_schema.return_value = mock_schema
289
+
290
+ mock_tool = Mock(spec=FunctionTool)
291
+ mock_function_tool.return_value = mock_tool
292
+
293
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
294
+
295
+ ai_context.create_activity_tool(mock_activity_func, retry_options=None)
296
+
297
+ # Get the run_activity function that was passed to FunctionTool
298
+ call_args = mock_function_tool.call_args
299
+ run_activity = call_args[1]['on_invoke_tool']
300
+
301
+ # Create a mock context wrapper
302
+ mock_ctx = Mock()
303
+
304
+ # Call the run_activity function
305
+ import asyncio
306
+ result = asyncio.run(run_activity(mock_ctx, "test_input"))
307
+
308
+ # Verify the task tracker was called without retry options
309
+ task_tracker.get_activity_call_result.assert_called_once_with(
310
+ "test_activity", "test_input"
311
+ )
312
+ assert result == "activity_result"
313
+
314
+ @patch('azure.durable_functions.openai_agents.context.function_schema')
315
+ @patch('azure.durable_functions.openai_agents.context.FunctionTool')
316
+ def test_activity_as_tool_extracts_activity_name_from_trigger(self, mock_function_tool, mock_function_schema):
317
+ """Test that the run_activity function calls task tracker with the activity name specified in the trigger."""
318
+ orchestration_context = self._create_mock_orchestration_context()
319
+ task_tracker = self._create_mock_task_tracker()
320
+
321
+ mock_activity_func = Mock()
322
+ mock_activity_func._function._name = "test_activity"
323
+ mock_activity_func._function._trigger.activity = "activity_name_from_trigger"
324
+ mock_activity_func._function._func = lambda x: x
325
+
326
+ mock_schema = Mock()
327
+ mock_schema.name = "test_activity"
328
+ mock_schema.description = ""
329
+ mock_schema.params_json_schema = {"type": "object"}
330
+ mock_function_schema.return_value = mock_schema
331
+
332
+ mock_tool = Mock(spec=FunctionTool)
333
+ mock_function_tool.return_value = mock_tool
334
+
335
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
336
+
337
+ ai_context.create_activity_tool(mock_activity_func, retry_options=None)
338
+
339
+ # Get the run_activity function that was passed to FunctionTool
340
+ call_args = mock_function_tool.call_args
341
+ run_activity = call_args[1]['on_invoke_tool']
342
+
343
+ # Create a mock context wrapper
344
+ mock_ctx = Mock()
345
+
346
+ # Call the run_activity function
347
+ import asyncio
348
+ result = asyncio.run(run_activity(mock_ctx, "test_input"))
349
+
350
+ # Verify the task tracker was called without retry options
351
+ task_tracker.get_activity_call_result.assert_called_once_with(
352
+ "activity_name_from_trigger", "test_input"
353
+ )
354
+ assert result == "activity_result"
355
+
356
+ def test_create_activity_tool_parses_json_input_with_input_name(self):
357
+ """Test JSON input parsing and named value extraction with input_name."""
358
+ self._test_activity_tool_input_processing(
359
+ input_name="max",
360
+ input_data='{"max": 100}',
361
+ expected_input_parameter_value=100,
362
+ activity_name="random_number_tool"
363
+ )
364
+
365
+ def test_create_activity_tool_handles_non_json_input_gracefully(self):
366
+ """Test non-JSON input passes through unchanged with input_name."""
367
+ self._test_activity_tool_input_processing(
368
+ input_name="param",
369
+ input_data="not json",
370
+ expected_input_parameter_value="not json"
371
+ )
372
+
373
+ def test_create_activity_tool_handles_json_missing_named_parameter(self):
374
+ """Test JSON input without named parameter passes through unchanged."""
375
+ json_input = '{"other_param": 200}'
376
+ self._test_activity_tool_input_processing(
377
+ input_name="expected_param",
378
+ input_data=json_input,
379
+ expected_input_parameter_value=json_input
380
+ )
381
+
382
+ def test_create_activity_tool_handles_malformed_json_gracefully(self):
383
+ """Test malformed JSON passes through unchanged."""
384
+ malformed_json = '{"param": 100' # Missing closing brace
385
+ self._test_activity_tool_input_processing(
386
+ input_name="param",
387
+ input_data=malformed_json,
388
+ expected_input_parameter_value=malformed_json
389
+ )
390
+
391
+ def test_create_activity_tool_json_parsing_works_with_retry_options(self):
392
+ """Test JSON parsing works correctly with retry options."""
393
+ retry_options = RetryOptions(1000, 3)
394
+ self._test_activity_tool_input_processing(
395
+ input_name="value",
396
+ input_data='{"value": "test_data"}',
397
+ expected_input_parameter_value="test_data",
398
+ retry_options=retry_options
399
+ )
400
+
401
+ def test_create_activity_tool_no_input_name_passes_through_json(self):
402
+ """Test JSON input passes through unchanged when no input_name."""
403
+ json_input = '{"param": 100}'
404
+ self._test_activity_tool_input_processing(
405
+ input_name=None, # No input_name
406
+ input_data=json_input,
407
+ expected_input_parameter_value=json_input
408
+ )
409
+
410
+ def test_context_delegation_methods_work(self):
411
+ """Test that common context methods work through delegation."""
412
+ orchestration_context = self._create_mock_orchestration_context()
413
+ task_tracker = self._create_mock_task_tracker()
414
+
415
+ # Add some mock methods to the orchestration context
416
+ orchestration_context.wait_for_external_event = Mock(return_value="external_event_task")
417
+ orchestration_context.create_timer = Mock(return_value="timer_task")
418
+
419
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
420
+
421
+ # These should work through delegation
422
+ result1 = ai_context.wait_for_external_event("test_event")
423
+ result2 = ai_context.create_timer("2023-01-01T00:00:00Z")
424
+
425
+ assert result1 == "external_event_task"
426
+ assert result2 == "timer_task"
427
+ orchestration_context.wait_for_external_event.assert_called_once_with("test_event")
428
+ orchestration_context.create_timer.assert_called_once_with("2023-01-01T00:00:00Z")
429
+
430
+ def test_getattr_delegates_to_context(self):
431
+ """Test that __getattr__ delegates attribute access to the underlying context."""
432
+ orchestration_context = self._create_mock_orchestration_context()
433
+ task_tracker = self._create_mock_task_tracker()
434
+
435
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
436
+
437
+ # Test delegation of various attributes
438
+ assert ai_context.instance_id == "test_instance_id"
439
+ assert ai_context.current_utc_datetime == "2023-01-01T00:00:00Z"
440
+ assert ai_context.is_replaying is False
441
+
442
+ def test_getattr_raises_attribute_error_for_nonexistent_attributes(self):
443
+ """Test that __getattr__ raises AttributeError for non-existent attributes."""
444
+ orchestration_context = self._create_mock_orchestration_context()
445
+ task_tracker = self._create_mock_task_tracker()
446
+
447
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
448
+
449
+ with pytest.raises(AttributeError, match="'DurableAIAgentContext' object has no attribute 'nonexistent_attr'"):
450
+ _ = ai_context.nonexistent_attr
451
+
452
+ def test_dir_includes_delegated_attributes(self):
453
+ """Test that __dir__ includes attributes from the underlying context."""
454
+ orchestration_context = self._create_mock_orchestration_context()
455
+ task_tracker = self._create_mock_task_tracker()
456
+
457
+ ai_context = DurableAIAgentContext(orchestration_context, task_tracker, None)
458
+ dir_result = dir(ai_context)
459
+
460
+ # Should include delegated attributes from the underlying context
461
+ assert 'instance_id' in dir_result
462
+ assert 'current_utc_datetime' in dir_result
463
+ assert 'is_replaying' in dir_result
464
+ # Should also include public methods
465
+ assert 'call_activity' in dir_result
466
+ assert 'create_activity_tool' in dir_result