Skip to content

Commit f5231e8

Browse files
authored
Include built-in tool calls and results in OTel messages (#2954)
1 parent 925568a commit f5231e8

File tree

3 files changed

+86
-17
lines changed

3 files changed

+86
-17
lines changed

pydantic_ai_slim/pydantic_ai/_otel_messages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ class ToolCallPart(TypedDict):
2121
id: str
2222
name: str
2323
arguments: NotRequired[JsonValue]
24+
builtin: NotRequired[bool] # Not (currently?) part of the spec, used by Logfire
2425

2526

2627
class ToolCallResponsePart(TypedDict):
2728
type: Literal['tool_call_response']
2829
id: str
2930
name: str
3031
result: NotRequired[JsonValue]
32+
builtin: NotRequired[bool] # Not (currently?) part of the spec, used by Logfire
3133

3234

3335
class MediaUrlPart(TypedDict):

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -711,14 +711,16 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
711711
def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
712712
from .models.instrumented import InstrumentedModel
713713

714-
return [
715-
_otel_messages.ToolCallResponsePart(
716-
type='tool_call_response',
717-
id=self.tool_call_id,
718-
name=self.tool_name,
719-
**({'result': InstrumentedModel.serialize_any(self.content)} if settings.include_content else {}),
720-
)
721-
]
714+
part = _otel_messages.ToolCallResponsePart(
715+
type='tool_call_response',
716+
id=self.tool_call_id,
717+
name=self.tool_name,
718+
)
719+
720+
if settings.include_content and self.content is not None:
721+
part['result'] = InstrumentedModel.serialize_any(self.content)
722+
723+
return [part]
722724

723725
def has_content(self) -> bool:
724726
"""Return `True` if the tool return has content."""
@@ -823,14 +825,16 @@ def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_me
823825
if self.tool_name is None:
824826
return [_otel_messages.TextPart(type='text', content=self.model_response())]
825827
else:
826-
return [
827-
_otel_messages.ToolCallResponsePart(
828-
type='tool_call_response',
829-
id=self.tool_call_id,
830-
name=self.tool_name,
831-
**({'result': self.model_response()} if settings.include_content else {}),
832-
)
833-
]
828+
part = _otel_messages.ToolCallResponsePart(
829+
type='tool_call_response',
830+
id=self.tool_call_id,
831+
name=self.tool_name,
832+
)
833+
834+
if settings.include_content:
835+
part['result'] = self.model_response()
836+
837+
return [part]
834838

835839
__repr__ = _utils.dataclasses_no_defaults_repr
836840

@@ -1134,8 +1138,10 @@ def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_me
11341138
**({'content': part.content} if settings.include_content else {}),
11351139
)
11361140
)
1137-
elif isinstance(part, ToolCallPart):
1141+
elif isinstance(part, BaseToolCallPart):
11381142
call_part = _otel_messages.ToolCallPart(type='tool_call', id=part.tool_call_id, name=part.tool_name)
1143+
if isinstance(part, BuiltinToolCallPart):
1144+
call_part['builtin'] = True
11391145
if settings.include_content and part.args is not None:
11401146
from .models.instrumented import InstrumentedModel
11411147

@@ -1145,6 +1151,23 @@ def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_me
11451151
call_part['arguments'] = {k: InstrumentedModel.serialize_any(v) for k, v in part.args.items()}
11461152

11471153
parts.append(call_part)
1154+
elif isinstance(part, BuiltinToolReturnPart):
1155+
return_part = _otel_messages.ToolCallResponsePart(
1156+
type='tool_call_response',
1157+
id=part.tool_call_id,
1158+
name=part.tool_name,
1159+
builtin=True,
1160+
)
1161+
if settings.include_content and part.content is not None: # pragma: no branch
1162+
from .models.instrumented import InstrumentedModel
1163+
1164+
return_part['result'] = (
1165+
part.content
1166+
if isinstance(part.content, str)
1167+
else {k: InstrumentedModel.serialize_any(v) for k, v in part.content.items()}
1168+
)
1169+
1170+
parts.append(return_part)
11481171
return parts
11491172

11501173
@property

tests/models/test_instrumented.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from pydantic_ai.messages import (
1717
AudioUrl,
1818
BinaryContent,
19+
BuiltinToolCallPart,
20+
BuiltinToolReturnPart,
1921
DocumentUrl,
2022
FinalResultEvent,
2123
ImageUrl,
@@ -1347,3 +1349,45 @@ async def test_response_cost_error(capfire: CaptureLogfire, monkeypatch: pytest.
13471349
}
13481350
]
13491351
)
1352+
1353+
1354+
def test_message_with_builtin_tool_calls():
1355+
messages: list[ModelMessage] = [
1356+
ModelResponse(
1357+
parts=[
1358+
TextPart('text1'),
1359+
BuiltinToolCallPart('code_execution', {'code': '2 * 2'}, tool_call_id='tool_call_1'),
1360+
BuiltinToolReturnPart('code_execution', {'output': '4'}, tool_call_id='tool_call_1'),
1361+
TextPart('text2'),
1362+
]
1363+
),
1364+
]
1365+
settings = InstrumentationSettings()
1366+
# Built-in tool calls are only included in v2-style messages, not v1-style events,
1367+
# as the spec does not yet allow tool results coming from the assistant,
1368+
# and Logfire has special handling for the `type='tool_call_response', 'builtin=True'` messages, but not events.
1369+
assert settings.messages_to_otel_messages(messages) == snapshot(
1370+
[
1371+
{
1372+
'role': 'assistant',
1373+
'parts': [
1374+
{'type': 'text', 'content': 'text1'},
1375+
{
1376+
'type': 'tool_call',
1377+
'id': 'tool_call_1',
1378+
'name': 'code_execution',
1379+
'builtin': True,
1380+
'arguments': {'code': '2 * 2'},
1381+
},
1382+
{
1383+
'type': 'tool_call_response',
1384+
'id': 'tool_call_1',
1385+
'name': 'code_execution',
1386+
'builtin': True,
1387+
'result': {'output': '4'},
1388+
},
1389+
{'type': 'text', 'content': 'text2'},
1390+
],
1391+
}
1392+
]
1393+
)

0 commit comments

Comments
 (0)