Skip to content

Commit 3235404

Browse files
authored
Add a handoffs/message_filter sample, fix passing parameters to activity tools, fix ActivityModelInput serialization (#16)
1 parent c68fe8d commit 3235404

File tree

9 files changed

+369
-10
lines changed

9 files changed

+369
-10
lines changed

azure/durable_functions/openai_agents/context.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from typing import Any, Callable, Optional, TYPE_CHECKING, Union
23

34
from azure.durable_functions.models.DurableOrchestrationContext import (
@@ -138,13 +139,29 @@ def create_activity_tool(
138139
else:
139140
activity_name = activity_func._function._name
140141

142+
input_name = None
143+
if (activity_func._function._trigger is not None
144+
and hasattr(activity_func._function._trigger, 'name')):
145+
input_name = activity_func._function._trigger.name
146+
141147
async def run_activity(ctx: RunContextWrapper[Any], input: str) -> Any:
148+
# Parse JSON input and extract the named value if input_name is specified
149+
activity_input = input
150+
if input_name:
151+
try:
152+
parsed_input = json.loads(input)
153+
if isinstance(parsed_input, dict) and input_name in parsed_input:
154+
activity_input = parsed_input[input_name]
155+
# If parsing fails or the named parameter is not found, pass the original input
156+
except (json.JSONDecodeError, TypeError):
157+
pass
158+
142159
if retry_options:
143160
result = self._task_tracker.get_activity_call_result_with_retry(
144-
activity_name, retry_options, input
161+
activity_name, retry_options, activity_input
145162
)
146163
else:
147-
result = self._task_tracker.get_activity_call_result(activity_name, input)
164+
result = self._task_tracker.get_activity_call_result(activity_name, activity_input)
148165
return result
149166

150167
schema = function_schema(

azure/durable_functions/openai_agents/model_invocation_activity.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,16 @@ class ActivityModelInput(BaseModel):
163163

164164
def to_json(self) -> str:
165165
"""Convert the ActivityModelInput to a JSON string."""
166-
return self.model_dump_json()
166+
try:
167+
return self.model_dump_json(warnings=False)
168+
except Exception:
169+
# Fallback to basic JSON serialization
170+
try:
171+
return json.dumps(self.model_dump(warnings=False), default=str)
172+
except Exception as fallback_error:
173+
raise ValueError(
174+
f"Unable to serialize ActivityModelInput: {fallback_error}"
175+
) from fallback_error
167176

168177
@classmethod
169178
def from_json(cls, json_str: str) -> 'ActivityModelInput':
@@ -310,6 +319,7 @@ async def get_response(
310319
*,
311320
previous_response_id: Optional[str],
312321
prompt: Optional[ResponsePromptParam],
322+
conversation_id: Optional[str] = None,
313323
) -> ModelResponse:
314324
"""Get a response from the model."""
315325
def make_tool_info(tool: Tool) -> ToolInput:

azure/durable_functions/openai_agents/orchestrator_generator.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,27 @@ async def durable_openai_agent_activity(input: str, model_provider: ModelProvide
1919
model_invoker = ModelInvoker(model_provider=model_provider)
2020
result = await model_invoker.invoke_model_activity(activity_input)
2121

22-
json_obj = ModelResponse.__pydantic_serializer__.to_json(result)
23-
return json_obj.decode()
22+
# Use safe/public Pydantic API when possible. Prefer model_dump_json if result is a BaseModel
23+
# Otherwise handle common types (str/bytes/dict/list) and fall back to json.dumps.
24+
import json as _json
25+
26+
if hasattr(result, "model_dump_json"):
27+
# Pydantic v2 BaseModel
28+
json_str = result.model_dump_json()
29+
else:
30+
if isinstance(result, bytes):
31+
json_str = result.decode()
32+
elif isinstance(result, str):
33+
json_str = result
34+
else:
35+
# Try the internal serializer as a last resort, but fall back to json.dumps
36+
try:
37+
json_bytes = ModelResponse.__pydantic_serializer__.to_json(result)
38+
json_str = json_bytes.decode()
39+
except Exception:
40+
json_str = _json.dumps(result)
41+
42+
return json_str
2443

2544

2645
def durable_openai_agent_orchestrator_generator(

azure/durable_functions/openai_agents/task_tracker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ def _get_activity_result_or_raise(self, task):
5252
result = json.loads(result_json)
5353
return result
5454

55-
def get_activity_call_result(self, activity_name, input: str):
55+
def get_activity_call_result(self, activity_name, input: Any):
5656
"""Call an activity and return its result or raise ``YieldException`` if pending."""
5757
task = self._context.call_activity(activity_name, input)
5858
return self._get_activity_result_or_raise(task)
5959

6060
def get_activity_call_result_with_retry(
61-
self, activity_name, retry_options: RetryOptions, input: str
61+
self, activity_name, retry_options: RetryOptions, input: Any
6262
):
6363
"""Call an activity with retry and return its result or raise YieldException if pending."""
6464
task = self._context.call_activity_with_retry(activity_name, retry_options, input)

samples-v2/openai_agents/function_app.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import random
23

34
import azure.functions as func
45
import azure.durable_functions as df
@@ -102,4 +103,13 @@ def tools(context):
102103
import basic.tools
103104
return basic.tools.main()
104105

106+
@app.activity_trigger(input_name="max")
107+
async def random_number_tool(max: int) -> int:
108+
"""Return a random integer between 0 and the given maximum."""
109+
return random.randint(0, max)
105110

111+
@app.orchestration_trigger(context_name="context")
112+
@app.durable_openai_agent_orchestrator
113+
def message_filter(context):
114+
import handoffs.message_filter
115+
return handoffs.message_filter.main(context.create_activity_tool(random_number_tool))
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from agents import Agent, HandoffInputData, Runner, function_tool, handoff
6+
from agents.extensions import handoff_filters
7+
from agents.models import is_gpt_5_default
8+
9+
10+
def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData:
11+
if is_gpt_5_default():
12+
print("gpt-5 is enabled, so we're not filtering the input history")
13+
# when using gpt-5, removing some of the items could break things, so we do this filtering only for other models
14+
return HandoffInputData(
15+
input_history=handoff_message_data.input_history,
16+
pre_handoff_items=tuple(handoff_message_data.pre_handoff_items),
17+
new_items=tuple(handoff_message_data.new_items),
18+
)
19+
20+
# First, we'll remove any tool-related messages from the message history
21+
handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data)
22+
23+
# Second, we'll also remove the first two items from the history, just for demonstration
24+
history = (
25+
tuple(handoff_message_data.input_history[2:])
26+
if isinstance(handoff_message_data.input_history, tuple)
27+
else handoff_message_data.input_history
28+
)
29+
30+
# or, you can use the HandoffInputData.clone(kwargs) method
31+
return HandoffInputData(
32+
input_history=history,
33+
pre_handoff_items=tuple(handoff_message_data.pre_handoff_items),
34+
new_items=tuple(handoff_message_data.new_items),
35+
)
36+
37+
38+
def main(random_number_tool):
39+
first_agent = Agent(
40+
name="Assistant",
41+
instructions="Be extremely concise.",
42+
tools=[random_number_tool],
43+
)
44+
45+
spanish_agent = Agent(
46+
name="Spanish Assistant",
47+
instructions="You only speak Spanish and are extremely concise.",
48+
handoff_description="A Spanish-speaking assistant.",
49+
)
50+
51+
second_agent = Agent(
52+
name="Assistant",
53+
instructions=(
54+
"Be a helpful assistant. If the user speaks Spanish, handoff to the Spanish assistant."
55+
),
56+
handoffs=[handoff(spanish_agent, input_filter=spanish_handoff_message_filter)],
57+
)
58+
59+
# 1. Send a regular message to the first agent
60+
result = Runner.run_sync(first_agent, input="Hi, my name is Sora.")
61+
62+
print("Step 1 done")
63+
64+
# 2. Ask it to generate a number
65+
result = Runner.run_sync(
66+
first_agent,
67+
input=result.to_input_list()
68+
+ [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}],
69+
)
70+
71+
print("Step 2 done")
72+
73+
# 3. Call the second agent
74+
result = Runner.run_sync(
75+
second_agent,
76+
input=result.to_input_list()
77+
+ [
78+
{
79+
"content": "I live in New York City. Whats the population of the city?",
80+
"role": "user",
81+
}
82+
],
83+
)
84+
85+
print("Step 3 done")
86+
87+
# 4. Cause a handoff to occur
88+
result = Runner.run_sync(
89+
second_agent,
90+
input=result.to_input_list()
91+
+ [
92+
{
93+
"content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?",
94+
"role": "user",
95+
}
96+
],
97+
)
98+
99+
print("Step 4 done")
100+
101+
print("\n===Final messages===\n")
102+
103+
# 5. That should have caused spanish_handoff_message_filter to be called, which means the
104+
# output should be missing the first two messages, and have no tool calls.
105+
# Let's print the messages to see what happened
106+
for message in result.to_input_list():
107+
print(json.dumps(message, indent=2))
108+
# tool_calls = message.tool_calls if isinstance(message, AssistantMessage) else None
109+
110+
# print(f"{message.role}: {message.content}\n - Tool calls: {tool_calls or 'None'}")
111+
"""
112+
$python examples/handoffs/message_filter.py
113+
Step 1 done
114+
Step 2 done
115+
Step 3 done
116+
Step 4 done
117+
118+
===Final messages===
119+
120+
{
121+
"content": "Can you generate a random number between 0 and 100?",
122+
"role": "user"
123+
}
124+
{
125+
"id": "...",
126+
"content": [
127+
{
128+
"annotations": [],
129+
"text": "Sure! Here's a random number between 0 and 100: **42**.",
130+
"type": "output_text"
131+
}
132+
],
133+
"role": "assistant",
134+
"status": "completed",
135+
"type": "message"
136+
}
137+
{
138+
"content": "I live in New York City. Whats the population of the city?",
139+
"role": "user"
140+
}
141+
{
142+
"id": "...",
143+
"content": [
144+
{
145+
"annotations": [],
146+
"text": "As of the most recent estimates, the population of New York City is approximately 8.6 million people. However, this number is constantly changing due to various factors such as migration and birth rates. For the latest and most accurate information, it's always a good idea to check the official data from sources like the U.S. Census Bureau.",
147+
"type": "output_text"
148+
}
149+
],
150+
"role": "assistant",
151+
"status": "completed",
152+
"type": "message"
153+
}
154+
{
155+
"content": "Por favor habla en espa\u00f1ol. \u00bfCu\u00e1l es mi nombre y d\u00f3nde vivo?",
156+
"role": "user"
157+
}
158+
{
159+
"id": "...",
160+
"content": [
161+
{
162+
"annotations": [],
163+
"text": "No tengo acceso a esa informaci\u00f3n personal, solo s\u00e9 lo que me has contado: vives en Nueva York.",
164+
"type": "output_text"
165+
}
166+
],
167+
"role": "assistant",
168+
"status": "completed",
169+
"type": "message"
170+
}
171+
"""
172+
173+
return result.final_output

samples-v2/openai_agents/requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
azure-functions
66
azure-functions-durable
77
azure-identity
8-
openai==1.98.0
9-
openai-agents==0.2.4
8+
openai==1.107.3
9+
openai-agents==0.3.0
1010
pydantic

samples-v2/openai_agents/test_orchestrators.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"non_strict_output_type",
2323
"previous_response_id",
2424
"remote_image",
25-
"tools"
25+
"tools",
26+
"message_filter",
2627
]
2728

2829
BASE_URL = "http://localhost:7071/api/orchestrators"

0 commit comments

Comments
 (0)