Skip to content

Commit b3aa033

Browse files
feat(openai-agents): Set system instruction attribute on gen_ai.chat spans (#5370)
Set the system instruction attribute on `gen_ai.chat` spans instead of `gen_ai.invoke_agent` spans in `OpenAIAgentsIntegration`. Extract instructions from string input, content strings and parts-style content lists, since `openai-agents` uses an input schema compatible with the OpenAI Responses API.
1 parent a6b4f2e commit b3aa033

File tree

6 files changed

+598
-126
lines changed

6 files changed

+598
-126
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from collections.abc import Iterable
2+
3+
from typing import TYPE_CHECKING
4+
5+
if TYPE_CHECKING:
6+
from sentry_sdk._types import TextPart
7+
8+
from openai.types.chat import (
9+
ChatCompletionMessageParam,
10+
ChatCompletionSystemMessageParam,
11+
)
12+
13+
14+
def _is_system_instruction(message: "ChatCompletionMessageParam") -> bool:
15+
return isinstance(message, dict) and message.get("role") == "system"
16+
17+
18+
def _get_system_instructions(
19+
messages: "Iterable[ChatCompletionMessageParam]",
20+
) -> "list[ChatCompletionMessageParam]":
21+
if not isinstance(messages, Iterable):
22+
return []
23+
24+
return [message for message in messages if _is_system_instruction(message)]
25+
26+
27+
def _transform_system_instructions(
28+
system_instructions: "list[ChatCompletionSystemMessageParam]",
29+
) -> "list[TextPart]":
30+
instruction_text_parts: "list[TextPart]" = []
31+
32+
for instruction in system_instructions:
33+
if not isinstance(instruction, dict):
34+
continue
35+
36+
content = instruction.get("content")
37+
38+
if isinstance(content, str):
39+
instruction_text_parts.append({"type": "text", "content": content})
40+
41+
elif isinstance(content, list):
42+
for part in content:
43+
if isinstance(part, dict) and part.get("type") == "text":
44+
text = part.get("text", None)
45+
if text is not None:
46+
instruction_text_parts.append({"type": "text", "content": text})
47+
48+
return instruction_text_parts
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from typing import TYPE_CHECKING
2+
3+
if TYPE_CHECKING:
4+
from typing import Union
5+
6+
from openai.types.responses import ResponseInputParam, ResponseInputItemParam
7+
8+
9+
def _is_system_instruction(message: "ResponseInputItemParam") -> bool:
10+
if not isinstance(message, dict) or not message.get("role") == "system":
11+
return False
12+
13+
return "type" not in message or message["type"] == "message"
14+
15+
16+
def _get_system_instructions(
17+
messages: "Union[str, ResponseInputParam]",
18+
) -> "list[ResponseInputItemParam]":
19+
if not isinstance(messages, list):
20+
return []
21+
22+
return [message for message in messages if _is_system_instruction(message)]

sentry_sdk/integrations/openai.py

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
normalize_message_roles,
1212
truncate_and_annotate_messages,
1313
)
14+
from sentry_sdk.ai._openai_completions_api import (
15+
_is_system_instruction as _is_system_instruction_completions,
16+
_get_system_instructions as _get_system_instructions_completions,
17+
_transform_system_instructions,
18+
)
19+
from sentry_sdk.ai._openai_responses_api import (
20+
_is_system_instruction as _is_system_instruction_responses,
21+
_get_system_instructions as _get_system_instructions_responses,
22+
)
1423
from sentry_sdk.consts import SPANDATA
1524
from sentry_sdk.integrations import DidNotEnable, Integration
1625
from sentry_sdk.scope import should_send_default_pii
@@ -33,11 +42,12 @@
3342
AsyncIterator,
3443
Iterator,
3544
Union,
45+
Iterable,
3646
)
3747
from sentry_sdk.tracing import Span
3848
from sentry_sdk._types import TextPart
3949

40-
from openai.types.responses import ResponseInputParam, ResponseInputItemParam
50+
from openai.types.responses import ResponseInputParam
4151
from openai import Omit
4252

4353
try:
@@ -200,63 +210,6 @@ def _calculate_token_usage(
200210
)
201211

202212

203-
def _is_system_instruction_completions(message: "ChatCompletionMessageParam") -> bool:
204-
return isinstance(message, dict) and message.get("role") == "system"
205-
206-
207-
def _get_system_instructions_completions(
208-
messages: "Iterable[ChatCompletionMessageParam]",
209-
) -> "list[ChatCompletionMessageParam]":
210-
if not isinstance(messages, Iterable):
211-
return []
212-
213-
return [
214-
message for message in messages if _is_system_instruction_completions(message)
215-
]
216-
217-
218-
def _is_system_instruction_responses(message: "ResponseInputItemParam") -> bool:
219-
if not isinstance(message, dict) or not message.get("role") == "system":
220-
return False
221-
222-
return "type" not in message or message["type"] == "message"
223-
224-
225-
def _get_system_instructions_responses(
226-
messages: "Union[str, ResponseInputParam]",
227-
) -> "list[ResponseInputItemParam]":
228-
if not isinstance(messages, list):
229-
return []
230-
231-
return [
232-
message for message in messages if _is_system_instruction_responses(message)
233-
]
234-
235-
236-
def _transform_system_instructions(
237-
system_instructions: "list[ChatCompletionSystemMessageParam]",
238-
) -> "list[TextPart]":
239-
instruction_text_parts: "list[TextPart]" = []
240-
241-
for instruction in system_instructions:
242-
if not isinstance(instruction, dict):
243-
continue
244-
245-
content = instruction.get("content")
246-
247-
if isinstance(content, str):
248-
instruction_text_parts.append({"type": "text", "content": content})
249-
250-
elif isinstance(content, list):
251-
for part in content:
252-
if isinstance(part, dict) and part.get("type") == "text":
253-
text = part.get("text", "")
254-
if text:
255-
instruction_text_parts.append({"type": "text", "content": text})
256-
257-
return instruction_text_parts
258-
259-
260213
def _get_input_messages(
261214
kwargs: "dict[str, Any]",
262215
) -> "Optional[Union[Iterable[Any], list[str]]]":

sentry_sdk/integrations/openai_agents/spans/invoke_agent.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,6 @@
1818
import agents
1919
from typing import Any, Optional
2020

21-
from sentry_sdk._types import TextPart
22-
23-
24-
def _transform_system_instruction(system_instructions: "str") -> "list[TextPart]":
25-
return [
26-
{
27-
"type": "text",
28-
"content": system_instructions,
29-
}
30-
]
31-
3221

3322
def invoke_agent_span(
3423
context: "agents.RunContextWrapper", agent: "agents.Agent", kwargs: "dict[str, Any]"
@@ -46,16 +35,16 @@ def invoke_agent_span(
4635
if should_send_default_pii():
4736
messages = []
4837
if agent.instructions:
49-
system_instruction = (
38+
message = (
5039
agent.instructions
5140
if isinstance(agent.instructions, str)
5241
else safe_serialize(agent.instructions)
5342
)
54-
set_data_normalized(
55-
span,
56-
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
57-
_transform_system_instruction(system_instruction),
58-
unpack=False,
43+
messages.append(
44+
{
45+
"content": [{"text": message, "type": "text"}],
46+
"role": "system",
47+
}
5948
)
6049

6150
original_input = kwargs.get("original_input")

sentry_sdk/integrations/openai_agents/utils.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@
1111
from sentry_sdk.scope import should_send_default_pii
1212
from sentry_sdk.tracing_utils import set_span_errored
1313
from sentry_sdk.utils import event_from_exception, safe_serialize
14+
from sentry_sdk.ai._openai_completions_api import _transform_system_instructions
15+
from sentry_sdk.ai._openai_responses_api import (
16+
_is_system_instruction,
17+
_get_system_instructions,
18+
)
1419

1520
from typing import TYPE_CHECKING
1621

1722
if TYPE_CHECKING:
1823
from typing import Any
19-
from agents import Usage
24+
from agents import Usage, TResponseInputItem
2025

2126
from sentry_sdk.tracing import Span
27+
from sentry_sdk._types import TextPart
2228

2329
try:
2430
import agents
@@ -121,19 +127,39 @@ def _set_input_data(
121127
return
122128
request_messages = []
123129

124-
system_instructions = get_response_kwargs.get("system_instructions")
125-
if system_instructions:
126-
request_messages.append(
130+
messages: "str | list[TResponseInputItem]" = get_response_kwargs.get("input", [])
131+
132+
instructions_text_parts: "list[TextPart]" = []
133+
explicit_instructions = get_response_kwargs.get("system_instructions")
134+
if explicit_instructions is not None:
135+
instructions_text_parts.append(
127136
{
128-
"role": GEN_AI_ALLOWED_MESSAGE_ROLES.SYSTEM,
129-
"content": [{"type": "text", "text": system_instructions}],
137+
"type": "text",
138+
"content": explicit_instructions,
130139
}
131140
)
132141

133-
for message in get_response_kwargs.get("input", []):
142+
system_instructions = _get_system_instructions(messages)
143+
144+
# Deliberate use of function accepting completions API type because
145+
# of shared structure FOR THIS PURPOSE ONLY.
146+
instructions_text_parts += _transform_system_instructions(system_instructions)
147+
148+
if len(instructions_text_parts) > 0:
149+
set_data_normalized(
150+
span,
151+
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
152+
instructions_text_parts,
153+
unpack=False,
154+
)
155+
156+
non_system_messages = [
157+
message for message in messages if not _is_system_instruction(message)
158+
]
159+
for message in non_system_messages:
134160
if "role" in message:
135-
normalized_role = normalize_message_role(message.get("role"))
136-
content = message.get("content")
161+
normalized_role = normalize_message_role(message.get("role")) # type: ignore
162+
content = message.get("content") # type: ignore
137163
request_messages.append(
138164
{
139165
"role": normalized_role,
@@ -145,14 +171,14 @@ def _set_input_data(
145171
}
146172
)
147173
else:
148-
if message.get("type") == "function_call":
174+
if message.get("type") == "function_call": # type: ignore
149175
request_messages.append(
150176
{
151177
"role": GEN_AI_ALLOWED_MESSAGE_ROLES.ASSISTANT,
152178
"content": [message],
153179
}
154180
)
155-
elif message.get("type") == "function_call_output":
181+
elif message.get("type") == "function_call_output": # type: ignore
156182
request_messages.append(
157183
{
158184
"role": GEN_AI_ALLOWED_MESSAGE_ROLES.TOOL,

0 commit comments

Comments
 (0)