Add instrumentation version 6, emitting tool results under `role: 'tool'`
This pull request was posted by Claude Code using claude-opus-5 on behalf of David.
David has not reviewed the code. He reviewed the two design decisions — version 6 being non-default, and the tool-availability delta keeping system — and directed the rest.
- Closes #7235
Why we make these changes
messages_to_otel_messages grouped a ModelRequest's parts by isinstance(p, SystemPromptPart | ToolAvailabilityDeltaPart) and labelled the result 'system' if is_system else 'user'. A tool result therefore arrived as a tool_call_response part inside a role: "user" message, while the GenAI semantic conventions pair that part with role: "tool". Role had no 'tool' member, so the right role was not representable. The reporter exports these spans to Databricks/MLflow, where the message role is what their consumers key on.
This is a changed value on telemetry we already emit, not an addition, so it ships as instrumentation format version 6 and versions 2–5 keep emitting user. That follows #4661, which took a new version to flip a span status on already-emitted telemetry while shipping its purely-additive half to every version in the same PR. Version 6 is not the default — #4219 and #4661 both shipped non-default, and the default flips at a major (TODO(v3) marker added next to the existing one).
The issue's second symptom — ToolAvailabilityDeltaPart appearing as a mid-conversation system message — is not changed, on any version. The semconv role vocabulary is exactly system | user | assistant | tool and none of those means "the set of tools changed"; tool is the one that would actively mislead, being the role paired with tool_call_response. system is also the mapping #6793 chose deliberately when it introduced the part. The reasoning is recorded in _otel_message_role's docstring so it doesn't get re-reported.
New public surface
InstrumentationSettings(version=6)— the only new value;versionwidens toLiteral[2, 3, 4, 5, 6].
_otel_messages.Role gains 'tool' unconditionally rather than per version: it is a private type describing what the field can hold, and only the mapping is gated.
Goal — a tool result carries the role the semantic conventions give it
Before — one user message holding everything the request sent:
InstrumentationSettings(include_content=True).messages_to_otel_messages(history)
# -> [{'role': 'system', 'parts': [text]},
# {'role': 'user', 'parts': [text]},
# {'role': 'assistant', 'parts': [tool_call]},
# {'role': 'user', 'parts': [tool_call_response, tool_call_response, text]}]
After, on version 6 — the request splits by role, in part order:
InstrumentationSettings(include_content=True, version=6).messages_to_otel_messages(history)
# -> [{'role': 'system', 'parts': [text]},
# {'role': 'user', 'parts': [text]},
# {'role': 'assistant', 'parts': [tool_call]},
# {'role': 'tool', 'parts': [tool_call_response, tool_call_response]},
# {'role': 'user', 'parts': [text]}]
A retry that answers a tool call goes to tool with the returns; a retry that answers nothing stays user, which is the role it reaches the model as — openai.py maps exactly this split to role='tool' / role='user' on the wire.
Playground
from pydantic_ai.messages import (
ModelRequest, ModelResponse, RetryPromptPart, SystemPromptPart,
ToolCallPart, ToolReturnPart, UserPromptPart,
)
from pydantic_ai.models.instrumented import InstrumentationSettings
history = [
ModelRequest(parts=[SystemPromptPart(content='Be brief.'), UserPromptPart(content='Convert 10 EUR.')]),
ModelResponse(parts=[ToolCallPart(tool_name='convert', args={'amount': 10}, tool_call_id='call_1')]),
ModelRequest(
parts=[
ToolReturnPart(tool_name='convert', content='11 USD', tool_call_id='call_1'),
RetryPromptPart(content='Unknown currency.', tool_name='convert', tool_call_id='call_2'),
RetryPromptPart(content='Output did not validate.'),
UserPromptPart(content='Thanks.'),
]
),
]
for version in (5, 6):
settings = InstrumentationSettings(include_content=True, version=version)
print(version, [m['role'] for m in settings.messages_to_otel_messages(history)])
# 5 ['system', 'user', 'assistant', 'user']
# 6 ['system', 'user', 'assistant', 'tool', 'user']
Test: test_messages_to_otel_messages_request_roles_v6
What changes for existing users: nothing. Version 5 stays the default and its output is byte-identical — the same test pins ['system', 'user', 'assistant', 'user'] for the default alongside the version 6 snapshot, and every existing snapshot across tests/test_logfire.py, tests/test_include_binary_content.py, tests/test_messages.py, tests/test_history_processor.py and tests/models/test_snowflake.py is unchanged.
Checklist
- Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
- No breaking changes in accordance with the version policy.
- PR title is fit for the release changelog.