9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
3723 lines
147 KiB
Python
3723 lines
147 KiB
Python
from __future__ import annotations as _annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any, Literal
|
|
|
|
import pytest
|
|
from dirty_equals import IsJson, IsList
|
|
from pydantic import BaseModel
|
|
from typing_extensions import NotRequired, Self, TypedDict
|
|
|
|
from pydantic_ai import Agent, ModelMessage, ModelRequest, ModelResponse, TextPart, ToolCallPart, UserPromptPart
|
|
from pydantic_ai._utils import get_traceparent
|
|
from pydantic_ai._warnings import PydanticAIDeprecationWarning
|
|
from pydantic_ai.capabilities import AbstractCapability
|
|
from pydantic_ai.capabilities.instrumentation import Instrumentation
|
|
from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry, UnexpectedModelBehavior
|
|
from pydantic_ai.models.function import AgentInfo, FunctionModel
|
|
from pydantic_ai.models.instrumented import InstrumentationSettings
|
|
from pydantic_ai.models.test import TestModel
|
|
from pydantic_ai.output import PromptedOutput, TextOutput
|
|
from pydantic_ai.tools import DeferredToolRequests, RunContext
|
|
from pydantic_ai.toolsets.abstract import ToolsetTool
|
|
from pydantic_ai.toolsets.function import FunctionToolset
|
|
from pydantic_ai.toolsets.wrapper import WrapperToolset
|
|
from pydantic_ai.usage import RequestUsage
|
|
|
|
from ._inline_snapshot import snapshot
|
|
from .conftest import IsDatetime, IsInt, IsStr, strip_logfire_metrics
|
|
|
|
try:
|
|
import logfire
|
|
from logfire.testing import CaptureLogfire
|
|
except ImportError: # pragma: lax no cover
|
|
logfire_installed = False
|
|
else:
|
|
logfire_installed = True
|
|
|
|
|
|
class SpanSummary(TypedDict):
|
|
id: int
|
|
name: str
|
|
message: str
|
|
children: NotRequired[list[SpanSummary]]
|
|
|
|
|
|
@dataclass(init=False)
|
|
class LogfireSummary:
|
|
traces: list[SpanSummary]
|
|
attributes: dict[int, dict[str, Any]]
|
|
|
|
def __init__(self, capfire: CaptureLogfire):
|
|
spans = capfire.exporter.exported_spans_as_dict()
|
|
spans.sort(key=lambda s: s['start_time'])
|
|
self.traces = []
|
|
span_lookup: dict[tuple[str, str], SpanSummary] = {}
|
|
self.attributes = {}
|
|
id_counter = 0
|
|
for span in spans:
|
|
tid = span['context']['trace_id'], span['context']['span_id']
|
|
span_lookup[tid] = span_summary = SpanSummary(
|
|
id=id_counter, name=span['name'], message=span['attributes']['logfire.msg']
|
|
)
|
|
# `logfire.metrics` is a logfire-version-dependent span decoration (added in 4.3x): newer
|
|
# logfire attaches the aggregated `gen_ai.client.token.usage` metric to spans, older does not.
|
|
# Strip it so these assertions hold across the supported logfire range; the token usage itself
|
|
# is still covered by the stable `gen_ai.usage.*` attributes and `get_collected_metrics()`.
|
|
self.attributes[id_counter] = {k: v for k, v in span['attributes'].items() if k != 'logfire.metrics'}
|
|
id_counter += 1
|
|
if parent := span['parent']:
|
|
parent_span = span_lookup[(parent['trace_id'], parent['span_id'])]
|
|
parent_span.setdefault('children', []).append(span_summary)
|
|
else:
|
|
self.traces.append(span_summary)
|
|
|
|
|
|
@pytest.fixture
|
|
def get_logfire_summary(capfire: CaptureLogfire) -> Callable[[], LogfireSummary]:
|
|
def get_summary() -> LogfireSummary:
|
|
return LogfireSummary(capfire)
|
|
|
|
return get_summary
|
|
|
|
|
|
def deprecated_instrumentation_settings(version: Literal[2, 3, 4], **kwargs: Any) -> InstrumentationSettings:
|
|
with pytest.warns(
|
|
PydanticAIDeprecationWarning,
|
|
match=r'Instrumentation format versions 2, 3, and 4 are deprecated',
|
|
):
|
|
return InstrumentationSettings(version=version, **kwargs)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'instrument',
|
|
[
|
|
True,
|
|
False,
|
|
deprecated_instrumentation_settings(version=2),
|
|
deprecated_instrumentation_settings(version=3),
|
|
],
|
|
)
|
|
def test_logfire(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
instrument: InstrumentationSettings | bool,
|
|
capfire: CaptureLogfire,
|
|
) -> None:
|
|
class InstrumentedToolset(WrapperToolset):
|
|
async def __aenter__(self) -> Self:
|
|
with logfire.span('toolset_enter'): # pyright: ignore[reportPossiblyUnboundVariable]
|
|
await super().__aenter__()
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any) -> bool | None:
|
|
with logfire.span('toolset_exit'): # pyright: ignore[reportPossiblyUnboundVariable]
|
|
return await super().__aexit__(*args)
|
|
|
|
async def call_tool(
|
|
self, name: str, tool_args: dict[str, Any], ctx: RunContext[Any], tool: ToolsetTool[Any]
|
|
) -> Any:
|
|
with logfire.span('toolset_call_tool {name}', name=name): # pyright: ignore[reportPossiblyUnboundVariable]
|
|
return await super().call_tool(name, tool_args, ctx, tool)
|
|
|
|
toolset = FunctionToolset()
|
|
|
|
@toolset.tool_plain
|
|
async def my_ret(x: int) -> str:
|
|
return str(x + 1)
|
|
|
|
if instrument:
|
|
toolset = InstrumentedToolset(toolset)
|
|
|
|
capabilities = (
|
|
[
|
|
Instrumentation(
|
|
settings=instrument if isinstance(instrument, InstrumentationSettings) else InstrumentationSettings()
|
|
)
|
|
]
|
|
if instrument
|
|
else []
|
|
)
|
|
my_agent = Agent(
|
|
model=TestModel(),
|
|
toolsets=[toolset],
|
|
capabilities=capabilities,
|
|
metadata={'env': 'test'},
|
|
)
|
|
|
|
result = my_agent.run_sync('Hello')
|
|
assert result.output == snapshot('{"my_ret":"1"}')
|
|
|
|
summary = get_logfire_summary()
|
|
if instrument is False:
|
|
assert summary.traces == []
|
|
return
|
|
|
|
if instrument is True or (isinstance(instrument, InstrumentationSettings) and instrument.version >= 3):
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'toolset_enter', 'message': 'toolset_enter'},
|
|
{'id': 2, 'name': 'chat test', 'message': 'chat test'},
|
|
{
|
|
'id': 3,
|
|
'name': 'execute_tool my_ret',
|
|
'message': 'running tool: my_ret',
|
|
'children': [
|
|
{'id': 4, 'name': 'toolset_call_tool {name}', 'message': 'toolset_call_tool my_ret'}
|
|
],
|
|
},
|
|
{'id': 5, 'name': 'chat test', 'message': 'chat test'},
|
|
{'id': 6, 'name': 'toolset_exit', 'message': 'toolset_exit'},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'toolset_enter', 'message': 'toolset_enter'},
|
|
{'id': 2, 'name': 'chat test', 'message': 'chat test'},
|
|
{
|
|
'id': 3,
|
|
'name': 'running tool',
|
|
'message': 'running tool: my_ret',
|
|
'children': [
|
|
{'id': 4, 'name': 'toolset_call_tool {name}', 'message': 'toolset_call_tool my_ret'}
|
|
],
|
|
},
|
|
{'id': 5, 'name': 'chat test', 'message': 'chat test'},
|
|
{'id': 6, 'name': 'toolset_exit', 'message': 'toolset_exit'},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
|
|
if isinstance(instrument, InstrumentationSettings) and instrument.version == 2:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'toolset_enter', 'message': 'toolset_enter'},
|
|
{'id': 2, 'name': 'chat test', 'message': 'chat test'},
|
|
{
|
|
'id': 3,
|
|
'name': 'running tool',
|
|
'message': 'running tool: my_ret',
|
|
'children': [
|
|
{'id': 4, 'name': 'toolset_call_tool {name}', 'message': 'toolset_call_tool my_ret'}
|
|
],
|
|
},
|
|
{'id': 5, 'name': 'chat test', 'message': 'chat test'},
|
|
{'id': 6, 'name': 'toolset_exit', 'message': 'toolset_exit'},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'toolset_enter', 'message': 'toolset_enter'},
|
|
{'id': 2, 'name': 'chat test', 'message': 'chat test'},
|
|
{
|
|
'id': 3,
|
|
'name': 'execute_tool my_ret',
|
|
'message': 'running tool: my_ret',
|
|
'children': [
|
|
{'id': 4, 'name': 'toolset_call_tool {name}', 'message': 'toolset_call_tool my_ret'}
|
|
],
|
|
},
|
|
{'id': 5, 'name': 'chat test', 'message': 'chat test'},
|
|
{'id': 6, 'name': 'toolset_exit', 'message': 'toolset_exit'},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': '{"my_ret":"1"}',
|
|
'gen_ai.aggregated_usage.input_tokens': 103,
|
|
'gen_ai.aggregated_usage.output_tokens': 12,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'my_ret',
|
|
'arguments': {'x': 0},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': IsStr(),
|
|
'name': 'my_ret',
|
|
'result': '1',
|
|
}
|
|
],
|
|
},
|
|
{'role': 'assistant', 'parts': [{'type': 'text', 'content': '{"my_ret":"1"}'}]},
|
|
]
|
|
)
|
|
),
|
|
'metadata': '{"env":"test"}',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'metadata': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
chat_span_attributes = next(
|
|
attrs for attrs in summary.attributes.values() if attrs.get('gen_ai.operation.name', None) == 'chat'
|
|
)
|
|
if hasattr(capfire, 'get_collected_metrics'): # pragma: no branch
|
|
assert capfire.get_collected_metrics() == snapshot(
|
|
[
|
|
{
|
|
'name': 'gen_ai.client.token.usage',
|
|
'description': 'Measures number of input and output tokens used',
|
|
'unit': '{token}',
|
|
'data': {
|
|
'data_points': [
|
|
{
|
|
'attributes': {
|
|
'gen_ai.provider.name': 'test',
|
|
'gen_ai.system': 'test',
|
|
'gen_ai.operation.name': 'chat',
|
|
'gen_ai.request.model': 'test',
|
|
'gen_ai.response.model': 'test',
|
|
'gen_ai.token.type': 'input',
|
|
},
|
|
'start_time_unix_nano': IsInt(),
|
|
'time_unix_nano': IsInt(),
|
|
'count': 2,
|
|
'sum': 103,
|
|
'scale': 12,
|
|
'zero_count': 0,
|
|
'positive': {
|
|
'offset': 23234,
|
|
'bucket_counts': IsList(length=...),
|
|
},
|
|
'negative': {'offset': 0, 'bucket_counts': [0]},
|
|
'flags': 0,
|
|
'min': 51,
|
|
'max': 52,
|
|
'exemplars': IsList(length=...),
|
|
},
|
|
{
|
|
'attributes': {
|
|
'gen_ai.provider.name': 'test',
|
|
'gen_ai.system': 'test',
|
|
'gen_ai.operation.name': 'chat',
|
|
'gen_ai.request.model': 'test',
|
|
'gen_ai.response.model': 'test',
|
|
'gen_ai.token.type': 'output',
|
|
},
|
|
'start_time_unix_nano': IsInt(),
|
|
'time_unix_nano': IsInt(),
|
|
'count': 2,
|
|
'sum': 12,
|
|
'scale': 7,
|
|
'zero_count': 0,
|
|
'positive': {
|
|
'offset': 255,
|
|
'bucket_counts': IsList(length=...),
|
|
},
|
|
'negative': {'offset': 0, 'bucket_counts': [0]},
|
|
'flags': 0,
|
|
'min': 4,
|
|
'max': 8,
|
|
'exemplars': IsList(length=...),
|
|
},
|
|
],
|
|
'aggregation_temporality': 1,
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
messages_attributes = {
|
|
k: chat_span_attributes.pop(k)
|
|
for k in ['events', 'gen_ai.input.messages', 'gen_ai.output.messages']
|
|
if k in chat_span_attributes
|
|
}
|
|
assert messages_attributes == snapshot(
|
|
{
|
|
'gen_ai.input.messages': IsJson(
|
|
snapshot([{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]}])
|
|
),
|
|
'gen_ai.output.messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'my_ret',
|
|
'arguments': {'x': 0},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
assert chat_span_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'chat',
|
|
'gen_ai.provider.name': 'test',
|
|
'gen_ai.system': 'test',
|
|
'gen_ai.request.model': 'test',
|
|
'model_request_parameters': IsJson(
|
|
snapshot(
|
|
{
|
|
'function_tools': [
|
|
{
|
|
'name': 'my_ret',
|
|
'description': None,
|
|
'parameters_json_schema': {
|
|
'additionalProperties': False,
|
|
'properties': {'x': {'type': 'integer'}},
|
|
'required': ['x'],
|
|
'type': 'object',
|
|
},
|
|
'outer_typed_dict_key': None,
|
|
'strict': None,
|
|
'sequential': False,
|
|
'kind': 'function',
|
|
'metadata': None,
|
|
'timeout': None,
|
|
'defer_loading': False,
|
|
'unless_native': None,
|
|
'with_native': None,
|
|
'tool_kind': None,
|
|
'return_schema': None,
|
|
'include_return_schema': None,
|
|
'capability_id': None,
|
|
}
|
|
],
|
|
'native_tools': [],
|
|
'output_mode': 'text',
|
|
'output_tools': [],
|
|
'output_object': None,
|
|
'prompted_output_template': None,
|
|
'allow_text_output': True,
|
|
'allow_image_output': False,
|
|
'instruction_parts': None,
|
|
'thinking': None,
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.definitions': '[{"type":"function","name":"my_ret","parameters":{"additionalProperties":false,"properties":{"x":{"type":"integer"}},"required":["x"],"type":"object"}}]',
|
|
'logfire.json_schema': IsJson(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'logfire.msg': 'chat test',
|
|
'gen_ai.response.model': 'test',
|
|
'gen_ai.usage.input_tokens': 51,
|
|
'gen_ai.usage.output_tokens': 4,
|
|
}
|
|
)
|
|
|
|
|
|
def _test_logfire_metadata_values_callable_dict(ctx: RunContext[Any]) -> dict[str, str]:
|
|
return {'model_name': ctx.model.model_name}
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
('metadata', 'expected'),
|
|
[
|
|
pytest.param({'env': 'test'}, '{"env":"test"}', id='dict'),
|
|
pytest.param(_test_logfire_metadata_values_callable_dict, '{"model_name":"test"}', id='callable-dict'),
|
|
],
|
|
)
|
|
def test_logfire_metadata_values(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
metadata: dict[str, Any] | Callable[[RunContext[Any]], dict[str, Any]],
|
|
expected: dict[str, Any],
|
|
) -> None:
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[Instrumentation(settings=deprecated_instrumentation_settings(version=2))],
|
|
metadata=metadata,
|
|
)
|
|
agent.run_sync('Hello')
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.attributes[0]['metadata'] == expected
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_logfire_metadata_override(get_logfire_summary: Callable[[], LogfireSummary]) -> None:
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[Instrumentation(settings=deprecated_instrumentation_settings(version=2))],
|
|
metadata={'env': 'base'},
|
|
)
|
|
with agent.override(metadata={'env': 'override'}):
|
|
agent.run_sync('Hello')
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.attributes[0]['metadata'] == '{"env":"override"}'
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.anyio
|
|
async def test_logfire_streaming_records_time_to_first_chunk(capfire: CaptureLogfire) -> None:
|
|
"""A streaming agent run records `gen_ai.client.operation.time_to_first_chunk` on the
|
|
model-request span and as a histogram metric (value is non-deterministic, so assert shape)."""
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings())],
|
|
)
|
|
async with agent.run_stream('Hello') as result:
|
|
async for _ in result.stream_text(delta=True):
|
|
pass
|
|
|
|
chat_spans = [
|
|
s for s in capfire.exporter.exported_spans_as_dict() if s['attributes'].get('gen_ai.operation.name') == 'chat'
|
|
]
|
|
assert chat_spans
|
|
for span in chat_spans:
|
|
ttft = span['attributes'].get('gen_ai.client.operation.time_to_first_chunk')
|
|
assert isinstance(ttft, float)
|
|
|
|
# Pin the histogram emission through the agent-flow path (capability handler -> req_ctx ->
|
|
# finish), not just the metric name, so a regression that drops the value between the handler
|
|
# and `finish` can't slip through.
|
|
ttft_metrics = [
|
|
m for m in capfire.get_collected_metrics() if m['name'] == 'gen_ai.client.operation.time_to_first_chunk'
|
|
]
|
|
assert len(ttft_metrics) == 1
|
|
assert ttft_metrics[0]['unit'] == 's'
|
|
assert len(ttft_metrics[0]['data']['data_points']) == 1
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'instrument',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
def test_instructions_with_structured_output(
|
|
get_logfire_summary: Callable[[], LogfireSummary], instrument: InstrumentationSettings
|
|
) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
my_agent = Agent(
|
|
model=TestModel(),
|
|
instructions='Here are some instructions',
|
|
capabilities=[Instrumentation(settings=instrument)],
|
|
)
|
|
|
|
result = my_agent.run_sync('Hello', output_type=MyOutput)
|
|
assert result.output == MyOutput(content='a')
|
|
|
|
summary = get_logfire_summary()
|
|
chat_span_attributes = summary.attributes[1]
|
|
if instrument.version == 2:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': '{"content":"a"}',
|
|
'gen_ai.aggregated_usage.input_tokens': 51,
|
|
'gen_ai.aggregated_usage.output_tokens': 5,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'arguments': {'content': 'a'},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'result': 'Final result processed.',
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
),
|
|
'gen_ai.system_instructions': '[{"type":"text","content":"Here are some instructions"}]',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'gen_ai.system_instructions': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
assert chat_span_attributes['gen_ai.input.messages'] == IsJson(
|
|
snapshot([{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]}])
|
|
)
|
|
assert chat_span_attributes['gen_ai.output.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'arguments': {'content': 'a'},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_instructions_with_structured_output_exclude_content(get_logfire_summary: Callable[[], LogfireSummary]) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
settings: InstrumentationSettings = InstrumentationSettings(include_content=False)
|
|
|
|
my_agent = Agent(
|
|
model=TestModel(), instructions='Here are some instructions', capabilities=[Instrumentation(settings=settings)]
|
|
)
|
|
|
|
result = my_agent.run_sync('Hello', output_type=MyOutput)
|
|
assert result.output == snapshot(MyOutput(content='a'))
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.aggregated_usage.input_tokens': 51,
|
|
'gen_ai.aggregated_usage.output_tokens': 5,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text'}]},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
chat_span_attributes = summary.attributes[1]
|
|
assert chat_span_attributes['gen_ai.input.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text'}]},
|
|
]
|
|
)
|
|
)
|
|
assert chat_span_attributes['gen_ai.output.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'settings',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
def test_prompted_output_schema_instructions_do_not_set_variable_instructions(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
settings: InstrumentationSettings,
|
|
) -> None:
|
|
class City(BaseModel):
|
|
name: str
|
|
population: int
|
|
|
|
my_agent = Agent(
|
|
model=TestModel(custom_output_text='{"name":"Paris","population":2148000}'),
|
|
instructions='Be helpful',
|
|
capabilities=[Instrumentation(settings=settings)],
|
|
output_type=PromptedOutput(City, template='Return JSON matching this schema:\n{schema}'),
|
|
)
|
|
|
|
result = my_agent.run_sync('Tell me about Paris')
|
|
assert result.output == snapshot(City(name='Paris', population=2148000))
|
|
|
|
summary = get_logfire_summary()
|
|
agent_run_attrs = summary.attributes[0]
|
|
assert 'Be helpful' in agent_run_attrs['gen_ai.system_instructions']
|
|
assert 'pydantic_ai.variable_instructions' not in agent_run_attrs
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'settings',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
def test_stable_instructions_across_tool_calls_do_not_set_variable_instructions(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
settings: InstrumentationSettings,
|
|
) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
my_agent = Agent(
|
|
model=TestModel(),
|
|
instructions='Be helpful',
|
|
capabilities=[Instrumentation(settings=settings)],
|
|
)
|
|
instruction_calls = 0
|
|
|
|
@my_agent.tool_plain
|
|
def my_tool() -> str:
|
|
nonlocal instruction_calls
|
|
instruction_calls += 1
|
|
return 'tool result'
|
|
|
|
result = my_agent.run_sync('Hello', output_type=MyOutput)
|
|
assert result.output == MyOutput(content='a')
|
|
# Ensure multi-step execution occurred so instructions were compared across requests
|
|
assert instruction_calls >= 1
|
|
|
|
summary = get_logfire_summary()
|
|
assert 'pydantic_ai.variable_instructions' not in summary.attributes[0]
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'settings',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
def test_dynamic_instructions_toggling_from_none_sets_variable_instructions(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
settings: InstrumentationSettings,
|
|
) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
my_agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[Instrumentation(settings=settings)],
|
|
)
|
|
instruction_calls = 0
|
|
|
|
@my_agent.instructions
|
|
def instructions(_: RunContext[object]) -> str | None:
|
|
nonlocal instruction_calls
|
|
instruction_calls += 1
|
|
if instruction_calls == 1:
|
|
return None
|
|
return 'This is a later step'
|
|
|
|
@my_agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'This is a tool call'
|
|
|
|
result = my_agent.run_sync('Hello', output_type=MyOutput)
|
|
assert result.output == MyOutput(content='a')
|
|
# Ensure multi-step execution occurred so instructions actually toggled
|
|
assert instruction_calls >= 2
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.attributes[0]['pydantic_ai.variable_instructions'] is True
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('version', [2, 3])
|
|
def test_instructions_with_structured_output_exclude_content_v2_v3(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
version: Literal[2, 3],
|
|
) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
settings: InstrumentationSettings = deprecated_instrumentation_settings(include_content=False, version=version)
|
|
|
|
my_agent = Agent(
|
|
model=TestModel(), instructions='Here are some instructions', capabilities=[Instrumentation(settings=settings)]
|
|
)
|
|
|
|
result = my_agent.run_sync('Hello', output_type=MyOutput)
|
|
assert result.output == MyOutput(content='a')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
if version == 2:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
# Version 2 and 3 have identical snapshots for this test case
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.aggregated_usage.input_tokens': 51,
|
|
'gen_ai.aggregated_usage.output_tokens': 5,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text'}]},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
chat_span_attributes = summary.attributes[1]
|
|
assert chat_span_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'chat',
|
|
'gen_ai.provider.name': 'test',
|
|
'gen_ai.system': 'test',
|
|
'gen_ai.request.model': 'test',
|
|
'model_request_parameters': IsJson(
|
|
snapshot(
|
|
{
|
|
'function_tools': [],
|
|
'native_tools': [],
|
|
'output_mode': 'tool',
|
|
'output_object': None,
|
|
'output_tools': [
|
|
{
|
|
'name': 'final_result',
|
|
'parameters_json_schema': {
|
|
'properties': {'content': {'type': 'string'}},
|
|
'required': ['content'],
|
|
'title': 'MyOutput',
|
|
'type': 'object',
|
|
},
|
|
'description': 'The final response which ends this conversation',
|
|
'outer_typed_dict_key': None,
|
|
'strict': None,
|
|
'sequential': False,
|
|
'kind': 'output',
|
|
'metadata': None,
|
|
'timeout': None,
|
|
'defer_loading': False,
|
|
'unless_native': None,
|
|
'with_native': None,
|
|
'tool_kind': None,
|
|
'return_schema': None,
|
|
'include_return_schema': None,
|
|
'capability_id': None,
|
|
}
|
|
],
|
|
'prompted_output_template': None,
|
|
'allow_text_output': False,
|
|
'allow_image_output': False,
|
|
'instruction_parts': [
|
|
{'content': 'Here are some instructions', 'dynamic': False, 'part_kind': 'instruction'}
|
|
],
|
|
'thinking': None,
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.definitions': '[{"type":"function","name":"final_result","description":"The final response which ends this conversation","parameters":{"properties":{"content":{"type":"string"}},"required":["content"],"title":"MyOutput","type":"object"}}]',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'chat test',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.input.messages': IsJson(snapshot([{'role': 'user', 'parts': [{'type': 'text'}]}])),
|
|
'gen_ai.output.messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.input.messages': {'type': 'array'},
|
|
'gen_ai.output.messages': {'type': 'array'},
|
|
'model_request_parameters': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.usage.input_tokens': 51,
|
|
'gen_ai.usage.output_tokens': 5,
|
|
'gen_ai.response.model': 'test',
|
|
}
|
|
)
|
|
|
|
|
|
def test_instrument_all():
|
|
agent = Agent()
|
|
|
|
def resolve():
|
|
return agent._resolve_instrumentation_settings() # type: ignore[reportPrivateUsage]
|
|
|
|
Agent.instrument_all(False)
|
|
assert resolve() is None
|
|
|
|
Agent.instrument_all()
|
|
settings = resolve()
|
|
assert settings is not None
|
|
assert settings.version == InstrumentationSettings().version
|
|
|
|
options = InstrumentationSettings(version=5)
|
|
Agent.instrument_all(options)
|
|
assert resolve() is options
|
|
|
|
Agent.instrument_all(False)
|
|
assert resolve() is None
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.anyio
|
|
async def test_aggregated_usage_attribute_names_default(capfire: CaptureLogfire) -> None:
|
|
"""Agent run spans use aggregated usage attribute names by default."""
|
|
|
|
def model_function(messages: list[ModelRequest | ModelResponse], info: AgentInfo) -> ModelResponse:
|
|
# Return a response with usage that includes extra details (cache tokens)
|
|
# to test that all gen_ai.usage.* attributes are translated
|
|
return ModelResponse(
|
|
parts=[TextPart('Hello!')],
|
|
usage=RequestUsage(input_tokens=10, output_tokens=5, cache_read_tokens=2),
|
|
)
|
|
|
|
settings = InstrumentationSettings()
|
|
agent = Agent(model=FunctionModel(model_function), capabilities=[Instrumentation(settings=settings)])
|
|
|
|
await agent.run('Hello')
|
|
|
|
spans = strip_logfire_metrics(capfire.exporter.exported_spans_as_dict(parse_json_attributes=True))
|
|
|
|
# Verify that agent run span uses aggregated_usage attribute names
|
|
agent_run_span = next(s for s in spans if s['name'] == 'invoke_agent agent')
|
|
assert agent_run_span['attributes'] == snapshot(
|
|
{
|
|
'model_name': 'function:model_function:',
|
|
'agent_name': 'agent',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': 'Hello!',
|
|
'gen_ai.aggregated_usage.input_tokens': 10,
|
|
'gen_ai.aggregated_usage.output_tokens': 5,
|
|
'gen_ai.aggregated_usage.cache_read.input_tokens': 2,
|
|
'gen_ai.aggregated_usage.details.cache_read_tokens': 2,
|
|
'pydantic_ai.all_messages': [
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'Hello!'}]},
|
|
],
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {'pydantic_ai.all_messages': {'type': 'array'}, 'final_result': {'type': 'object'}},
|
|
},
|
|
}
|
|
)
|
|
|
|
# Verify that model/chat span still uses standard attribute names
|
|
chat_span = next(s for s in spans if 'chat' in s['name'])
|
|
assert chat_span['attributes']['gen_ai.usage.input_tokens'] == 10
|
|
assert chat_span['attributes']['gen_ai.usage.output_tokens'] == 5
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.anyio
|
|
async def test_aggregated_usage_attribute_names_can_be_disabled(capfire: CaptureLogfire) -> None:
|
|
def model_function(messages: list[ModelRequest | ModelResponse], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(parts=[TextPart('Hello!')], usage=RequestUsage(input_tokens=10, output_tokens=5))
|
|
|
|
settings = InstrumentationSettings(use_aggregated_usage_attribute_names=False)
|
|
agent = Agent(model=FunctionModel(model_function), capabilities=[Instrumentation(settings=settings)])
|
|
|
|
await agent.run('Hello')
|
|
|
|
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
|
|
agent_run_span = next(s for s in spans if s['name'] == 'invoke_agent agent')
|
|
assert agent_run_span['attributes']['gen_ai.usage.input_tokens'] == 10
|
|
assert agent_run_span['attributes']['gen_ai.usage.output_tokens'] == 5
|
|
assert 'gen_ai.aggregated_usage.input_tokens' not in agent_run_span['attributes']
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.anyio
|
|
async def test_feedback(capfire: CaptureLogfire) -> None:
|
|
from logfire.experimental.annotations import record_feedback
|
|
|
|
my_agent = Agent(model=TestModel(), capabilities=[Instrumentation(settings=InstrumentationSettings())])
|
|
|
|
async with my_agent.iter('Hello') as agent_run:
|
|
async for _ in agent_run:
|
|
pass
|
|
result = agent_run.result
|
|
assert result
|
|
traceparent = get_traceparent(result)
|
|
assert traceparent == get_traceparent(agent_run)
|
|
assert traceparent == snapshot('00-00000000000000000000000000000001-0000000000000001-01')
|
|
record_feedback(traceparent, 'factuality', 0.1, comment='the agent lied', extra={'foo': 'bar'})
|
|
|
|
assert strip_logfire_metrics(capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)) == snapshot(
|
|
[
|
|
{
|
|
'name': 'chat test',
|
|
'context': {'trace_id': 1, 'span_id': 3, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'start_time': 2000000000,
|
|
'end_time': 3000000000,
|
|
'attributes': {
|
|
'gen_ai.operation.name': 'chat',
|
|
'gen_ai.provider.name': 'test',
|
|
'gen_ai.system': 'test',
|
|
'gen_ai.request.model': 'test',
|
|
'model_request_parameters': {
|
|
'function_tools': [],
|
|
'native_tools': [],
|
|
'output_mode': 'text',
|
|
'output_object': None,
|
|
'output_tools': [],
|
|
'prompted_output_template': None,
|
|
'allow_text_output': True,
|
|
'allow_image_output': False,
|
|
'instruction_parts': None,
|
|
'thinking': None,
|
|
},
|
|
'logfire.span_type': 'span',
|
|
'logfire.msg': 'chat test',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.usage.input_tokens': 51,
|
|
'gen_ai.usage.output_tokens': 4,
|
|
'gen_ai.response.model': 'test',
|
|
'gen_ai.input.messages': [
|
|
{
|
|
'parts': [
|
|
{
|
|
'type': 'text',
|
|
'content': 'Hello',
|
|
},
|
|
],
|
|
'role': 'user',
|
|
},
|
|
],
|
|
'gen_ai.output.messages': [
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [{'type': 'text', 'content': 'success (no tool calls)'}],
|
|
}
|
|
],
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.input.messages': {'type': 'array'},
|
|
'gen_ai.output.messages': {'type': 'array'},
|
|
'model_request_parameters': {'type': 'object'},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
'name': 'invoke_agent agent',
|
|
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'parent': None,
|
|
'start_time': 1000000000,
|
|
'end_time': 4000000000,
|
|
'attributes': {
|
|
'model_name': 'test',
|
|
'agent_name': 'agent',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': 'success (no tool calls)',
|
|
'gen_ai.aggregated_usage.input_tokens': 51,
|
|
'gen_ai.aggregated_usage.output_tokens': 4,
|
|
'pydantic_ai.all_messages': [
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'success (no tool calls)'}]},
|
|
],
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
'name': 'feedback: factuality',
|
|
'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': True},
|
|
'start_time': 5000000000,
|
|
'end_time': 5000000000,
|
|
'attributes': {
|
|
'logfire.span_type': 'annotation',
|
|
'logfire.level_num': 9,
|
|
'logfire.msg_template': 'feedback: factuality',
|
|
'logfire.msg': 'feedback: factuality = 0.1',
|
|
'code.filepath': 'test_logfire.py',
|
|
'code.function': 'test_feedback',
|
|
'code.lineno': 123,
|
|
'logfire.feedback.name': 'factuality',
|
|
'factuality': 0.1,
|
|
'foo': 'bar',
|
|
'logfire.feedback.comment': 'the agent lied',
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'logfire.feedback.name': {},
|
|
'factuality': {},
|
|
'foo': {},
|
|
'logfire.feedback.comment': {},
|
|
'logfire.span_type': {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content,tool_error', [(True, False), (True, True), (False, False), (False, True)])
|
|
def test_include_tool_args_span_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
tool_error: bool,
|
|
) -> None:
|
|
"""Test that tool arguments are included/excluded in span attributes based on instrumentation settings."""
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
test_model = TestModel(seed=42)
|
|
my_agent = Agent(model=test_model, capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
@my_agent.tool_plain
|
|
async def add_numbers(x: int, y: int) -> int:
|
|
"""Add two numbers together."""
|
|
if tool_error:
|
|
raise ModelRetry('Tool error')
|
|
return x + y
|
|
|
|
try:
|
|
result = my_agent.run_sync('Add 42 and 42')
|
|
assert result.output == snapshot('{"add_numbers":84}')
|
|
except UnexpectedModelBehavior:
|
|
if not tool_error:
|
|
raise # pragma: no cover
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
tool_attributes = next(
|
|
attributes for attributes in summary.attributes.values() if attributes.get('gen_ai.tool.name') == 'add_numbers'
|
|
)
|
|
|
|
if include_content:
|
|
if tool_error:
|
|
assert tool_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'add_numbers',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.tool.call.arguments': '{"x":42,"y":42}',
|
|
'logfire.msg': 'running tool: add_numbers',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.tool.call.result': """\
|
|
Tool error
|
|
|
|
Fix the errors and try again.\
|
|
""",
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'logfire.level_num': 17,
|
|
}
|
|
)
|
|
else:
|
|
assert tool_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'add_numbers',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.tool.call.arguments': '{"x":42,"y":42}',
|
|
'logfire.msg': 'running tool: add_numbers',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.tool.call.result': '84',
|
|
}
|
|
)
|
|
else:
|
|
if tool_error:
|
|
assert tool_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'add_numbers',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running tool: add_numbers',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'logfire.level_num': 17,
|
|
}
|
|
)
|
|
else:
|
|
assert tool_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'add_numbers',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running tool: add_numbers',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
class WeatherInfo(BaseModel):
|
|
temperature: float
|
|
description: str
|
|
|
|
|
|
def get_weather_info(city: str) -> WeatherInfo:
|
|
return WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'instrument',
|
|
[
|
|
True,
|
|
False,
|
|
deprecated_instrumentation_settings(version=2),
|
|
deprecated_instrumentation_settings(version=3),
|
|
],
|
|
)
|
|
def test_logfire_output_function_v2_v3(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
instrument: InstrumentationSettings | bool,
|
|
) -> None:
|
|
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
args_json = '{"city": "Mexico City"}'
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
capabilities = (
|
|
[
|
|
Instrumentation(
|
|
settings=instrument if isinstance(instrument, InstrumentationSettings) else InstrumentationSettings()
|
|
)
|
|
]
|
|
if instrument
|
|
else []
|
|
)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=capabilities)
|
|
result = my_agent.run_sync('Mexico City', output_type=get_weather_info)
|
|
assert result.output == WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
if isinstance(instrument, InstrumentationSettings) and instrument.version == 2:
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'chat function:call_tool:', 'message': 'chat function:call_tool:'},
|
|
{
|
|
'id': 2,
|
|
'name': 'running output function',
|
|
'message': 'running output function: final_result',
|
|
},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'tool_arguments': {'type': 'object'},
|
|
'tool_response': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'tool_arguments': '"Mexico City"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'tool_response': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
|
|
elif instrument is True or isinstance(instrument, InstrumentationSettings) and instrument.version == 3:
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'chat function:call_tool:', 'message': 'chat function:call_tool:'},
|
|
{
|
|
'id': 2,
|
|
'name': 'execute_tool final_result',
|
|
'message': 'running output function: final_result',
|
|
},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot([])
|
|
assert summary.attributes == snapshot({})
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_function_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
args_json = '{"city": "Mexico City"}'
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('Mexico City', output_type=get_weather_info)
|
|
assert result.output == WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the output function span attributes
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.name":{},"gen_ai.tool.call.id":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_function_with_run_context_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
def get_weather_with_ctx(ctx: RunContext, city: str) -> WeatherInfo:
|
|
assert ctx is not None
|
|
return WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
args_json = '{"city": "Mexico City"}'
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('Mexico City', output_type=get_weather_with_ctx)
|
|
assert result.output == WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the output function span attributes
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.name":{},"gen_ai.tool.call.id":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_function_with_retry_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
def get_weather_with_retry(city: str) -> WeatherInfo:
|
|
if city != 'Mexico City':
|
|
from pydantic_ai import ModelRetry
|
|
|
|
raise ModelRetry('City not found, I only know Mexico City')
|
|
return WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
|
|
if len(messages) == 1:
|
|
args_json = '{"city": "New York City"}'
|
|
else:
|
|
args_json = '{"city": "Mexico City"}'
|
|
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('New York City', output_type=get_weather_with_retry)
|
|
assert result.output == WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
output_function_attributes = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
[
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.tool.call.arguments': '"New York City"',
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.call.arguments":{"type":"object"},"gen_ai.tool.call.result":{"type":"object"},"gen_ai.tool.name":{},"gen_ai.tool.call.id":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'logfire.level_num': 17,
|
|
},
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.call.arguments":{"type":"object"},"gen_ai.tool.call.result":{"type":"object"},"gen_ai.tool.name":{},"gen_ai.tool.call.id":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
},
|
|
]
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
[
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.name":{},"gen_ai.tool.call.id":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'logfire.level_num': 17,
|
|
},
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.name":{},"gen_ai.tool.call.id":{}}}',
|
|
'logfire.span_type': 'span',
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_function_with_custom_tool_name_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
args_json = '{"city": "Mexico City"}'
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
from pydantic_ai.output import ToolOutput
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('Mexico City', output_type=ToolOutput(get_weather_info, name='get_weather'))
|
|
assert result.output == WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the output function span attributes with custom tool name
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'get_weather'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'get_weather',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: get_weather',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'get_weather',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running output function: get_weather',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
|
|
),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_bound_instance_method_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
class Weather(BaseModel):
|
|
temperature: float
|
|
description: str
|
|
|
|
def get_weather(self, city: str):
|
|
return self
|
|
|
|
weather = Weather(temperature=28.7, description='sunny')
|
|
|
|
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
args_json = '{"city": "Mexico City"}'
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('Mexico City', output_type=weather.get_weather)
|
|
assert result.output == Weather(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the output function span attributes
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
|
|
),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_bound_instance_method_with_run_context_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
class Weather(BaseModel):
|
|
temperature: float
|
|
description: str
|
|
|
|
def get_weather(self, ctx: RunContext, city: str):
|
|
assert ctx is not None
|
|
return self
|
|
|
|
weather = Weather(temperature=28.7, description='sunny')
|
|
|
|
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
args_json = '{"city": "Mexico City"}'
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('Mexico City', output_type=weather.get_weather)
|
|
assert result.output == Weather(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the output function span attributes
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
|
|
),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_async_function_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
"""Test logfire attributes for async output function types."""
|
|
|
|
async def get_weather_async(city: str) -> WeatherInfo:
|
|
return WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
args_json = '{"city": "Mexico City"}'
|
|
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('Mexico City', output_type=get_weather_async)
|
|
assert result.output == WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the output function span attributes
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('gen_ai.tool.name') == 'final_result'
|
|
and 'output function' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
}
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'final_result',
|
|
'gen_ai.tool.call.id': IsStr(),
|
|
'logfire.msg': 'running output function: final_result',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
|
|
),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
def upcase_text(text: str) -> str:
|
|
"""Convert text to uppercase."""
|
|
return text.upper()
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_text_output_function_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
"""Test logfire attributes for TextOutput functions (TextOutputProcessor)."""
|
|
|
|
def call_text_response(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
# Return a plain text response (not a tool call)
|
|
from pydantic_ai import TextPart
|
|
|
|
return ModelResponse(parts=[TextPart(content='hello world')])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(
|
|
model=FunctionModel(call_text_response), capabilities=[Instrumentation(settings=instrumentation_settings)]
|
|
)
|
|
|
|
result = my_agent.run_sync('Say hello', output_type=TextOutput(upcase_text))
|
|
assert result.output == 'HELLO WORLD'
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the text output function span attributes
|
|
[text_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if 'running output function: upcase_text' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert text_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'upcase_text',
|
|
'logfire.msg': 'running output function: upcase_text',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"hello world"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': 'HELLO WORLD',
|
|
}
|
|
)
|
|
else:
|
|
assert text_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'upcase_text',
|
|
'logfire.msg': 'running output function: upcase_text',
|
|
'logfire.json_schema': IsJson(snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}}})),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_prompted_output_function_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
"""Test that spans are created for PromptedOutput functions with appropriate attributes."""
|
|
|
|
def upcase_text(text: str) -> str:
|
|
return text.upper()
|
|
|
|
call_count = 0
|
|
|
|
def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
# Simulate the model returning JSON that will be parsed and used to call the function
|
|
return ModelResponse(parts=[TextPart(content='{"text": "hello world"}')])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
agent = Agent(
|
|
model=FunctionModel(call_tool),
|
|
capabilities=[Instrumentation(settings=instrumentation_settings)],
|
|
output_type=PromptedOutput(upcase_text),
|
|
)
|
|
|
|
result = agent.run_sync('test')
|
|
|
|
# Check that the function was called and returned the expected result
|
|
assert result.output == 'HELLO WORLD'
|
|
assert call_count == 1
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
# Find the output function span attributes
|
|
[output_function_attributes] = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if attributes.get('logfire.msg', '').startswith('running output function: upcase_text')
|
|
]
|
|
|
|
if include_content:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'upcase_text',
|
|
'logfire.msg': 'running output function: upcase_text',
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
'gen_ai.tool.call.arguments': '"hello world"',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.tool.call.result': 'HELLO WORLD',
|
|
}
|
|
)
|
|
else:
|
|
assert output_function_attributes == snapshot(
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'upcase_text',
|
|
'logfire.msg': 'running output function: upcase_text',
|
|
'logfire.json_schema': IsJson(snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}}})),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize('include_content', [True, False])
|
|
def test_output_type_text_output_function_with_retry_logfire_attributes(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
include_content: bool,
|
|
) -> None:
|
|
def get_weather_with_retry(ctx: RunContext, city: str) -> WeatherInfo:
|
|
assert ctx is not None
|
|
if city != 'Mexico City':
|
|
from pydantic_ai import ModelRetry
|
|
|
|
raise ModelRetry('City not found, I only know Mexico City')
|
|
return WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.output_tools is not None
|
|
|
|
if len(messages) == 1:
|
|
city = 'New York City'
|
|
else:
|
|
city = 'Mexico City'
|
|
|
|
return ModelResponse(parts=[TextPart(content=city)])
|
|
|
|
instrumentation_settings = InstrumentationSettings(include_content=include_content)
|
|
my_agent = Agent(model=FunctionModel(call_tool), capabilities=[Instrumentation(settings=instrumentation_settings)])
|
|
|
|
result = my_agent.run_sync('New York City', output_type=TextOutput(get_weather_with_retry))
|
|
assert result.output == WeatherInfo(temperature=28.7, description='sunny')
|
|
|
|
summary = get_logfire_summary()
|
|
|
|
text_function_attributes = [
|
|
attributes
|
|
for attributes in summary.attributes.values()
|
|
if 'running output function: get_weather_with_retry' in attributes.get('logfire.msg', '')
|
|
]
|
|
|
|
if include_content:
|
|
assert text_function_attributes == snapshot(
|
|
[
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'get_weather_with_retry',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: get_weather_with_retry',
|
|
'gen_ai.tool.call.arguments': '"New York City"',
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.call.arguments":{"type":"object"},"gen_ai.tool.call.result":{"type":"object"},"gen_ai.tool.name":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'logfire.level_num': 17,
|
|
},
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'get_weather_with_retry',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: get_weather_with_retry',
|
|
'gen_ai.tool.call.arguments': '"Mexico City"',
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.call.arguments":{"type":"object"},"gen_ai.tool.call.result":{"type":"object"},"gen_ai.tool.name":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.tool.call.result': '{"temperature":28.7,"description":"sunny"}',
|
|
},
|
|
]
|
|
)
|
|
else:
|
|
assert text_function_attributes == snapshot(
|
|
[
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'get_weather_with_retry',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: get_weather_with_retry',
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.name":{}}}',
|
|
'logfire.span_type': 'span',
|
|
'logfire.level_num': 17,
|
|
},
|
|
{
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'get_weather_with_retry',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.msg': 'running output function: get_weather_with_retry',
|
|
'logfire.json_schema': '{"type":"object","properties":{"gen_ai.tool.name":{}}}',
|
|
'logfire.span_type': 'span',
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'instrument',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
def test_static_function_instructions_in_agent_run_span(
|
|
get_logfire_summary: Callable[[], LogfireSummary], instrument: InstrumentationSettings
|
|
) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
my_agent = Agent(model=TestModel(), capabilities=[Instrumentation(settings=instrument)])
|
|
|
|
@my_agent.instructions
|
|
def instructions():
|
|
return 'Here are some instructions'
|
|
|
|
result = my_agent.run_sync('Hello', output_type=MyOutput)
|
|
assert result.output == MyOutput(content='a')
|
|
|
|
summary = get_logfire_summary()
|
|
chat_span_attributes = summary.attributes[1]
|
|
if instrument.version == 2:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': '{"content":"a"}',
|
|
'gen_ai.aggregated_usage.input_tokens': 51,
|
|
'gen_ai.aggregated_usage.output_tokens': 5,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'arguments': {'content': 'a'},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'result': 'Final result processed.',
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
),
|
|
'gen_ai.system_instructions': '[{"type":"text","content":"Here are some instructions"}]',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'gen_ai.system_instructions': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
assert chat_span_attributes['gen_ai.input.messages'] == IsJson(
|
|
snapshot([{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]}])
|
|
)
|
|
assert chat_span_attributes['gen_ai.output.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'arguments': {'content': 'a'},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_instructions_from_history_when_model_request_fails_before_instrumentation(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
) -> None:
|
|
class FailBeforeModelRequest(AbstractCapability[Any]):
|
|
async def before_model_request(self, ctx: RunContext[Any], request_context: Any) -> Any:
|
|
raise RuntimeError('boom')
|
|
|
|
my_agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings()), FailBeforeModelRequest()],
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match='boom'):
|
|
my_agent.run_sync(
|
|
'Hello',
|
|
message_history=[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='Hi')],
|
|
instructions='Instructions from history',
|
|
timestamp=IsDatetime(),
|
|
),
|
|
ModelResponse(parts=[TextPart(content='Hello')]),
|
|
],
|
|
)
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.attributes[0]['gen_ai.system_instructions'] == snapshot(
|
|
'[{"type":"text","content":"Instructions from history"}]'
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'instrument',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
def test_dynamic_function_instructions_in_agent_run_span(
|
|
get_logfire_summary: Callable[[], LogfireSummary], instrument: InstrumentationSettings
|
|
) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
my_agent = Agent(model=TestModel(), capabilities=[Instrumentation(settings=instrument)])
|
|
|
|
@my_agent.instructions
|
|
def instructions(ctx: RunContext):
|
|
return f'This is step {ctx.run_step}'
|
|
|
|
@my_agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'This is a tool call'
|
|
|
|
result = my_agent.run_sync('Hello', output_type=MyOutput)
|
|
assert result.output == MyOutput(content='a')
|
|
|
|
summary = get_logfire_summary()
|
|
chat_span_attributes = summary.attributes[1]
|
|
if instrument.version == 2:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'chat test', 'message': 'chat test'},
|
|
{'id': 2, 'name': 'running tool', 'message': 'running tool: my_tool'},
|
|
{'id': 3, 'name': 'chat test', 'message': 'chat test'},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [
|
|
{'id': 1, 'name': 'chat test', 'message': 'chat test'},
|
|
{'id': 2, 'name': 'execute_tool my_tool', 'message': 'running tool: my_tool'},
|
|
{'id': 3, 'name': 'chat test', 'message': 'chat test'},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': '{"content":"a"}',
|
|
'gen_ai.aggregated_usage.input_tokens': 107,
|
|
'gen_ai.aggregated_usage.output_tokens': 9,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': 'pyd_ai_tool_call_id__my_tool',
|
|
'name': 'my_tool',
|
|
'arguments': {},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': 'pyd_ai_tool_call_id__my_tool',
|
|
'name': 'my_tool',
|
|
'result': 'This is a tool call',
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'arguments': {'content': 'a'},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'result': 'Final result processed.',
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
),
|
|
'gen_ai.system_instructions': '[{"type":"text","content":"This is step 2"}]',
|
|
'pydantic_ai.variable_instructions': True,
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'gen_ai.system_instructions': {'type': 'array'},
|
|
'pydantic_ai.variable_instructions': {},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
assert chat_span_attributes['gen_ai.input.messages'] == IsJson(
|
|
snapshot([{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]}])
|
|
)
|
|
assert chat_span_attributes['gen_ai.output.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'my_tool',
|
|
'arguments': {},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'instrument',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
def test_function_instructions_with_history_in_agent_run_span(
|
|
get_logfire_summary: Callable[[], LogfireSummary], instrument: InstrumentationSettings
|
|
) -> None:
|
|
@dataclass
|
|
class MyOutput:
|
|
content: str
|
|
|
|
my_agent = Agent(model=TestModel(), capabilities=[Instrumentation(settings=instrument)])
|
|
|
|
@my_agent.instructions
|
|
def instructions(ctx: RunContext):
|
|
return 'Instructions for the current agent run'
|
|
|
|
result = my_agent.run_sync(
|
|
'Hello',
|
|
message_history=[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='Hi')],
|
|
instructions='Instructions from a previous agent run',
|
|
timestamp=IsDatetime(),
|
|
),
|
|
ModelResponse(parts=[TextPart(content='Hello')]),
|
|
],
|
|
output_type=MyOutput,
|
|
)
|
|
assert result.output == MyOutput(content='a')
|
|
|
|
summary = get_logfire_summary()
|
|
chat_span_attributes = summary.attributes[1]
|
|
if instrument.version == 2:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': '{"content":"a"}',
|
|
'gen_ai.aggregated_usage.input_tokens': 52,
|
|
'gen_ai.aggregated_usage.output_tokens': 6,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hi'}]},
|
|
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'arguments': {'content': 'a'},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call_response',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'result': 'Final result processed.',
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
),
|
|
'pydantic_ai.new_message_index': 2,
|
|
'gen_ai.system_instructions': '[{"type":"text","content":"Instructions for the current agent run"}]',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'pydantic_ai.new_message_index': {},
|
|
'gen_ai.system_instructions': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
assert chat_span_attributes['gen_ai.input.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hi'}]},
|
|
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
]
|
|
)
|
|
)
|
|
assert chat_span_attributes['gen_ai.output.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [
|
|
{
|
|
'type': 'tool_call',
|
|
'id': IsStr(),
|
|
'name': 'final_result',
|
|
'arguments': {'content': 'a'},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.parametrize(
|
|
'instrument',
|
|
[deprecated_instrumentation_settings(version=2), deprecated_instrumentation_settings(version=3)],
|
|
)
|
|
async def test_run_stream(
|
|
get_logfire_summary: Callable[[], LogfireSummary], instrument: InstrumentationSettings
|
|
) -> None:
|
|
my_agent = Agent(model=TestModel(), capabilities=[Instrumentation(settings=instrument)])
|
|
|
|
@my_agent.instructions
|
|
def instructions(ctx: RunContext):
|
|
return 'Instructions for the current agent run'
|
|
|
|
async with my_agent.run_stream('Hello') as stream:
|
|
async for _ in stream.stream_output():
|
|
pass
|
|
|
|
summary = get_logfire_summary()
|
|
chat_span_attributes = summary.attributes[1]
|
|
if instrument.version == 2:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'agent run',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
else:
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': 'success (no tool calls)',
|
|
'gen_ai.aggregated_usage.input_tokens': 51,
|
|
'gen_ai.aggregated_usage.output_tokens': 4,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'success (no tool calls)'}]},
|
|
]
|
|
)
|
|
),
|
|
'gen_ai.system_instructions': '[{"type":"text","content":"Instructions for the current agent run"}]',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'gen_ai.system_instructions': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
assert chat_span_attributes['gen_ai.input.messages'] == IsJson(
|
|
snapshot([{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]}])
|
|
)
|
|
assert chat_span_attributes['gen_ai.output.messages'] == IsJson(
|
|
snapshot(
|
|
[
|
|
{
|
|
'role': 'assistant',
|
|
'parts': [{'type': 'text', 'content': 'success (no tool calls)'}],
|
|
}
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_run_stream_sync(get_logfire_summary: Callable[[], LogfireSummary]) -> None:
|
|
my_agent = Agent(model=TestModel(), capabilities=[Instrumentation()])
|
|
|
|
@my_agent.instructions
|
|
def instructions(ctx: RunContext):
|
|
return 'Instructions for the current agent run'
|
|
|
|
with my_agent.run_stream_sync('Hello') as stream:
|
|
for _ in stream.stream_output():
|
|
pass
|
|
|
|
summary = get_logfire_summary()
|
|
# The `chat` span is correctly nested under the agent run span (not orphaned).
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent my_agent',
|
|
'message': 'my_agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
# The agent run span's end attributes (all_messages, final_result, usage) are all populated.
|
|
assert summary.attributes[0] == snapshot(
|
|
{
|
|
'model_name': 'test',
|
|
'agent_name': 'my_agent',
|
|
'gen_ai.agent.name': 'my_agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'gen_ai.operation.name': 'invoke_agent',
|
|
'logfire.msg': 'my_agent run',
|
|
'logfire.span_type': 'span',
|
|
'final_result': 'success (no tool calls)',
|
|
'gen_ai.aggregated_usage.input_tokens': 51,
|
|
'gen_ai.aggregated_usage.output_tokens': 4,
|
|
'pydantic_ai.all_messages': IsJson(
|
|
snapshot(
|
|
[
|
|
{'role': 'user', 'parts': [{'type': 'text', 'content': 'Hello'}]},
|
|
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'success (no tool calls)'}]},
|
|
]
|
|
)
|
|
),
|
|
'gen_ai.system_instructions': '[{"type":"text","content":"Instructions for the current agent run"}]',
|
|
'logfire.json_schema': IsJson(
|
|
snapshot(
|
|
{
|
|
'type': 'object',
|
|
'properties': {
|
|
'pydantic_ai.all_messages': {'type': 'array'},
|
|
'gen_ai.system_instructions': {'type': 'array'},
|
|
'final_result': {'type': 'object'},
|
|
},
|
|
}
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def _get_tool_span(capfire: CaptureLogfire) -> dict[str, Any]:
|
|
"""Get the completed tool span from exported spans."""
|
|
spans = strip_logfire_metrics(capfire.exporter.exported_spans_as_dict(parse_json_attributes=True))
|
|
tool_span = next(
|
|
s for s in spans if s['attributes'].get('logfire.span_type') == 'span' and 'tool' in s['name'].lower()
|
|
)
|
|
return tool_span
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_call_deferred_v2(capfire: CaptureLogfire) -> None:
|
|
"""Test that CallDeferred on v2 marks span as ERROR with deferral attributes."""
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[Instrumentation(settings=deprecated_instrumentation_settings(version=2))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> int:
|
|
raise CallDeferred(metadata={'task_id': 'task-123'})
|
|
|
|
agent.run_sync('Hello')
|
|
|
|
assert _get_tool_span(capfire) == snapshot(
|
|
{
|
|
'name': 'running tool',
|
|
'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'start_time': 4000000000,
|
|
'end_time': 6000000000,
|
|
'attributes': {
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'my_tool',
|
|
'gen_ai.tool.call.id': 'pyd_ai_tool_call_id__my_tool',
|
|
'tool_arguments': {'x': 0},
|
|
'logfire.msg': 'running tool: my_tool',
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'tool_arguments': {'type': 'object'},
|
|
'tool_response': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
},
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'pydantic_ai.tool.deferral.name': 'CallDeferred',
|
|
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
|
|
'logfire.level_num': 17,
|
|
},
|
|
'events': [
|
|
{
|
|
'name': 'exception',
|
|
'timestamp': 5000000000,
|
|
'attributes': {
|
|
'exception.type': 'pydantic_ai.exceptions.CallDeferred',
|
|
'exception.message': '',
|
|
'exception.stacktrace': 'pydantic_ai.exceptions.CallDeferred',
|
|
'exception.escaped': 'True',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_approval_required_v2(capfire: CaptureLogfire) -> None:
|
|
"""Test that ApprovalRequired on v2 marks span as ERROR with deferral attributes."""
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[Instrumentation(settings=deprecated_instrumentation_settings(version=2))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> int:
|
|
raise ApprovalRequired(metadata={'task_id': 'task-123'})
|
|
|
|
agent.run_sync('Hello')
|
|
|
|
assert _get_tool_span(capfire) == snapshot(
|
|
{
|
|
'name': 'running tool',
|
|
'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'start_time': 4000000000,
|
|
'end_time': 6000000000,
|
|
'attributes': {
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'my_tool',
|
|
'gen_ai.tool.call.id': 'pyd_ai_tool_call_id__my_tool',
|
|
'tool_arguments': {'x': 0},
|
|
'logfire.msg': 'running tool: my_tool',
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'tool_arguments': {'type': 'object'},
|
|
'tool_response': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
},
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'pydantic_ai.tool.deferral.name': 'ApprovalRequired',
|
|
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
|
|
'logfire.level_num': 17,
|
|
},
|
|
'events': [
|
|
{
|
|
'name': 'exception',
|
|
'timestamp': 5000000000,
|
|
'attributes': {
|
|
'exception.type': 'pydantic_ai.exceptions.ApprovalRequired',
|
|
'exception.message': '',
|
|
'exception.stacktrace': 'pydantic_ai.exceptions.ApprovalRequired',
|
|
'exception.escaped': 'True',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_call_deferred_v5(capfire: CaptureLogfire) -> None:
|
|
"""Test that CallDeferred on v5 leaves span as UNSET with deferral attributes."""
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings(version=5))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> int:
|
|
raise CallDeferred(metadata={'task_id': 'task-123'})
|
|
|
|
agent.run_sync('Hello')
|
|
|
|
assert _get_tool_span(capfire) == snapshot(
|
|
{
|
|
'name': 'execute_tool my_tool',
|
|
'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'start_time': 4000000000,
|
|
'end_time': 5000000000,
|
|
'attributes': {
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'my_tool',
|
|
'gen_ai.tool.call.id': 'pyd_ai_tool_call_id__my_tool',
|
|
'gen_ai.tool.call.arguments': {'x': 0},
|
|
'logfire.msg': 'running tool: my_tool',
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
},
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'pydantic_ai.tool.deferral.name': 'CallDeferred',
|
|
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_approval_required_v5(capfire: CaptureLogfire) -> None:
|
|
"""Test that ApprovalRequired on v5 leaves span as UNSET with deferral attributes."""
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings(version=5))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> int:
|
|
raise ApprovalRequired(metadata={'task_id': 'task-123'})
|
|
|
|
agent.run_sync('Hello')
|
|
|
|
assert _get_tool_span(capfire) == snapshot(
|
|
{
|
|
'name': 'execute_tool my_tool',
|
|
'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'start_time': 4000000000,
|
|
'end_time': 5000000000,
|
|
'attributes': {
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'my_tool',
|
|
'gen_ai.tool.call.id': 'pyd_ai_tool_call_id__my_tool',
|
|
'gen_ai.tool.call.arguments': {'x': 0},
|
|
'logfire.msg': 'running tool: my_tool',
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
},
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'pydantic_ai.tool.deferral.name': 'ApprovalRequired',
|
|
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_no_metadata(capfire: CaptureLogfire) -> None:
|
|
"""Test that deferral without metadata doesn't set the metadata attribute."""
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings(version=5))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> int:
|
|
raise CallDeferred()
|
|
|
|
agent.run_sync('Hello')
|
|
|
|
tool_span = _get_tool_span(capfire)
|
|
|
|
assert tool_span == snapshot(
|
|
{
|
|
'name': 'execute_tool my_tool',
|
|
'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'start_time': 4000000000,
|
|
'end_time': 5000000000,
|
|
'attributes': {
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'my_tool',
|
|
'gen_ai.tool.call.id': 'pyd_ai_tool_call_id__my_tool',
|
|
'gen_ai.tool.call.arguments': {'x': 0},
|
|
'logfire.msg': 'running tool: my_tool',
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
},
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'pydantic_ai.tool.deferral.name': 'CallDeferred',
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_non_serializable_metadata(capfire: CaptureLogfire) -> None:
|
|
"""Test that non-JSON-serializable metadata falls back to repr() representation."""
|
|
|
|
class CustomObj:
|
|
def __repr__(self) -> str:
|
|
return '<CustomObj>'
|
|
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings(version=5))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> int:
|
|
raise CallDeferred(metadata={'obj': CustomObj()})
|
|
|
|
agent.run_sync('Hello')
|
|
|
|
tool_span = _get_tool_span(capfire)
|
|
|
|
assert tool_span == snapshot(
|
|
{
|
|
'name': 'execute_tool my_tool',
|
|
'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False},
|
|
'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
|
|
'start_time': 4000000000,
|
|
'end_time': 5000000000,
|
|
'attributes': {
|
|
'gen_ai.operation.name': 'execute_tool',
|
|
'gen_ai.tool.name': 'my_tool',
|
|
'gen_ai.tool.call.id': 'pyd_ai_tool_call_id__my_tool',
|
|
'gen_ai.tool.call.arguments': {'x': 0},
|
|
'logfire.msg': 'running tool: my_tool',
|
|
'logfire.json_schema': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'gen_ai.tool.call.arguments': {'type': 'object'},
|
|
'gen_ai.tool.call.result': {'type': 'object'},
|
|
'gen_ai.tool.name': {},
|
|
'gen_ai.tool.call.id': {},
|
|
},
|
|
},
|
|
'gen_ai.conversation.id': IsStr(),
|
|
'logfire.span_type': 'span',
|
|
'gen_ai.agent.name': 'agent',
|
|
'gen_ai.agent.call.id': IsStr(),
|
|
'pydantic_ai.tool.deferral.name': 'CallDeferred',
|
|
'pydantic_ai.tool.deferral.metadata': "{'obj': <CustomObj>}",
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_model_retry_still_errors_v5(capfire: CaptureLogfire) -> None:
|
|
"""Test that ModelRetry on v5 still records the span as an error.
|
|
|
|
The deferral fix (CallDeferred/ApprovalRequired → UNSET on v5) must not affect
|
|
ModelRetry, which wraps as ToolRetryError and should always be an error span.
|
|
"""
|
|
agent = Agent(
|
|
TestModel(),
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings(version=5))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> str:
|
|
raise ModelRetry('please try again with different input')
|
|
|
|
with pytest.raises(UnexpectedModelBehavior):
|
|
agent.run_sync('Hello')
|
|
|
|
tool_span = _get_tool_span(capfire)
|
|
|
|
# ToolRetryError should still be recorded as an error on v5 — only deferrals get UNSET
|
|
assert tool_span['attributes'].get('logfire.level_num') == 17
|
|
# No deferral attributes should be set — this is a retry, not a deferral
|
|
assert 'pydantic_ai.tool.deferral.name' not in tool_span['attributes']
|
|
assert 'pydantic_ai.tool.deferral.metadata' not in tool_span['attributes']
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_deferral_unexpected_exception_still_errors_v5(capfire: CaptureLogfire) -> None:
|
|
"""Test that unexpected exceptions on v5 still record the span as an error.
|
|
|
|
The deferral fix must not affect general exception handling — only
|
|
CallDeferred and ApprovalRequired get UNSET status on v5.
|
|
"""
|
|
agent = Agent(
|
|
TestModel(),
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings(version=5))],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> str:
|
|
raise ValueError('something went wrong')
|
|
|
|
with pytest.raises(ValueError, match='something went wrong'):
|
|
agent.run_sync('Hello')
|
|
|
|
tool_span = _get_tool_span(capfire)
|
|
|
|
# ValueError path should still record error regardless of instrumentation version
|
|
assert tool_span['attributes'].get('logfire.level_num') == 17
|
|
# No deferral attributes should be set
|
|
assert 'pydantic_ai.tool.deferral.name' not in tool_span['attributes']
|
|
assert 'pydantic_ai.tool.deferral.metadata' not in tool_span['attributes']
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.anyio
|
|
async def test_agent_description(capfire: CaptureLogfire) -> None:
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
name='my_agent',
|
|
description='An agent that greets users',
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings())],
|
|
)
|
|
assert agent.description == 'An agent that greets users'
|
|
|
|
await agent.run('Hello')
|
|
|
|
spans = capfire.exporter.exported_spans_as_dict()
|
|
agent_run_span = next(s for s in spans if s['name'] == 'invoke_agent my_agent')
|
|
assert agent_run_span['attributes']['gen_ai.agent.description'] == 'An agent that greets users'
|
|
|
|
agent.description = 'Updated description'
|
|
assert agent.description == 'Updated description'
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
@pytest.mark.anyio
|
|
async def test_agent_description_absent_when_none(capfire: CaptureLogfire) -> None:
|
|
agent = Agent(
|
|
model=TestModel(), name='my_agent', capabilities=[Instrumentation(settings=InstrumentationSettings())]
|
|
)
|
|
assert agent.description is None
|
|
|
|
await agent.run('Hello')
|
|
|
|
spans = capfire.exporter.exported_spans_as_dict()
|
|
agent_run_span = next(s for s in spans if s['name'] == 'invoke_agent my_agent')
|
|
assert 'gen_ai.agent.description' not in agent_run_span['attributes']
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_instrumentation_capability_with_model_settings(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
) -> None:
|
|
"""Test that Instrumentation capability correctly records model settings like temperature."""
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
model_settings={'temperature': 0.5, 'max_tokens': 100},
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings())],
|
|
)
|
|
agent.run_sync('Hello')
|
|
|
|
summary = get_logfire_summary()
|
|
chat_attrs = summary.attributes[1]
|
|
assert chat_attrs['gen_ai.request.temperature'] == 0.5
|
|
assert chat_attrs['gen_ai.request.max_tokens'] == 100
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_instrumentation_capability_serialization() -> None:
|
|
"""`Instrumentation` is constructible from a YAML/JSON spec via the serializable subset of
|
|
`InstrumentationSettings` kwargs; non-serializable OTel providers fall back to the globals."""
|
|
from pydantic_ai.capabilities.instrumentation import Instrumentation
|
|
from pydantic_ai.models.instrumented import InstrumentationSettings
|
|
|
|
assert Instrumentation.get_serialization_name() == 'Instrumentation'
|
|
|
|
with pytest.warns(
|
|
PydanticAIDeprecationWarning, match=r'Instrumentation format versions 2, 3, and 4 are deprecated'
|
|
):
|
|
cap = Instrumentation.from_spec(version=2, include_content=False)
|
|
assert isinstance(cap, Instrumentation)
|
|
assert isinstance(cap.settings, InstrumentationSettings)
|
|
assert cap.settings.version == 2
|
|
assert cap.settings.include_content is False
|
|
|
|
# Empty kwargs form: `Instrumentation: {}` in YAML.
|
|
cap_default = Instrumentation.from_spec()
|
|
assert cap_default.settings.version == InstrumentationSettings().version
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_instrumentation_capability_explicit(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
) -> None:
|
|
"""Test using Instrumentation as an explicit capability (not via instrument=True)."""
|
|
from pydantic_ai.capabilities.instrumentation import Instrumentation
|
|
|
|
instrumentation = Instrumentation(settings=InstrumentationSettings())
|
|
agent = Agent(model=TestModel(), capabilities=[instrumentation])
|
|
|
|
result = agent.run_sync('Hello')
|
|
assert result.output == snapshot('success (no tool calls)')
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent agent',
|
|
'message': 'agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_instrument_all_skipped_when_capability_already_present(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
) -> None:
|
|
"""`Agent.instrument_all(...)` must not stack a second `Instrumentation` capability when the
|
|
user already added one via `capabilities=[...]` — otherwise spans would be emitted twice.
|
|
|
|
Guarded by the `has_capability_type(..., InstrumentationCap)` check in `Agent.iter`.
|
|
"""
|
|
from pydantic_ai.capabilities.instrumentation import Instrumentation
|
|
|
|
Agent.instrument_all(True)
|
|
try:
|
|
agent = Agent(model=TestModel(), capabilities=[Instrumentation(settings=InstrumentationSettings())])
|
|
result = agent.run_sync('Hello')
|
|
assert result.output == snapshot('success (no tool calls)')
|
|
|
|
summary = get_logfire_summary()
|
|
# One `agent run` span, not two.
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent agent',
|
|
'message': 'agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
finally:
|
|
Agent.instrument_all(False)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_agent_with_user_provided_instrumented_model(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
) -> None:
|
|
"""If a user explicitly hands an `InstrumentedModel` to `Agent(model=...)`, its
|
|
settings drive the agent's instrumentation — the model is unwrapped and the
|
|
`Instrumentation` capability emits the spans.
|
|
"""
|
|
from pydantic_ai.models.instrumented import InstrumentedModel
|
|
|
|
settings = InstrumentationSettings()
|
|
agent = Agent(model=InstrumentedModel(TestModel(), settings))
|
|
|
|
result = agent.run_sync('Hello')
|
|
assert result.output == snapshot('success (no tool calls)')
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent agent',
|
|
'message': 'agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_agent_instrument_setter(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
) -> None:
|
|
"""`agent.instrument = settings` configures instrumentation post-construction.
|
|
|
|
This is the path `logfire.instrument_pydantic_ai(agent)` uses on its `Agent` branch.
|
|
"""
|
|
agent = Agent(model=TestModel())
|
|
assert agent.instrument is None
|
|
settings = InstrumentationSettings()
|
|
agent.instrument = settings
|
|
assert agent.instrument is settings
|
|
|
|
result = agent.run_sync('Hello')
|
|
assert result.output == snapshot('success (no tool calls)')
|
|
|
|
summary = get_logfire_summary()
|
|
assert summary.traces == snapshot(
|
|
[
|
|
{
|
|
'id': 0,
|
|
'name': 'invoke_agent agent',
|
|
'message': 'agent run',
|
|
'children': [{'id': 1, 'name': 'chat test', 'message': 'chat test'}],
|
|
}
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_instrumentation_capability_template_description(
|
|
capfire: CaptureLogfire,
|
|
) -> None:
|
|
"""Test that TemplateStr descriptions are rendered in agent run spans."""
|
|
# `TemplateStr` rendering requires the `[spec]` extra (pydantic-handlebars); not in default v2 deps.
|
|
pytest.importorskip('pydantic_handlebars')
|
|
from dataclasses import dataclass
|
|
|
|
from pydantic_ai._template import TemplateStr
|
|
from pydantic_ai.capabilities.instrumentation import Instrumentation
|
|
|
|
@dataclass
|
|
class MyDeps:
|
|
name: str
|
|
|
|
instrumentation = Instrumentation(settings=InstrumentationSettings())
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[instrumentation],
|
|
description=TemplateStr('Agent for {{name}}'),
|
|
deps_type=MyDeps,
|
|
)
|
|
|
|
result = agent.run_sync('Hello', deps=MyDeps(name='testing'))
|
|
assert result.output == snapshot('success (no tool calls)')
|
|
|
|
spans = strip_logfire_metrics(capfire.exporter.exported_spans_as_dict(parse_json_attributes=True))
|
|
agent_span = spans[-1] # outermost span is the agent run
|
|
assert agent_span['attributes']['gen_ai.agent.description'] == snapshot('Agent for testing')
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
async def test_instrumentation_capability_with_noop_tracer() -> None:
|
|
"""When the configured tracer provider is a no-op, model-request spans skip
|
|
attribute population entirely. Regression coverage for the non-recording
|
|
branch in `Instrumentation.wrap_model_request`."""
|
|
from opentelemetry.trace import NoOpTracerProvider
|
|
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[Instrumentation(settings=InstrumentationSettings(tracer_provider=NoOpTracerProvider()))],
|
|
)
|
|
result = await agent.run('hello')
|
|
assert result.output == snapshot('success (no tool calls)')
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
async def test_instrument_combines_with_outermost_and_innermost_capabilities() -> None:
|
|
"""Auto-prepending `Instrumentation` must not wrap a `CombinedCapability` that
|
|
already contains both an outermost and innermost cap — the wrap would force
|
|
`_effective_ordering` to merge those positions and raise "Conflicting positions".
|
|
"""
|
|
from pydantic_ai.capabilities import AbstractCapability, CapabilityOrdering
|
|
|
|
class OutermostCap(AbstractCapability[Any]):
|
|
def get_ordering(self) -> CapabilityOrdering:
|
|
return CapabilityOrdering(position='outermost')
|
|
|
|
async def before_run(self, ctx: RunContext[Any]) -> None:
|
|
pass
|
|
|
|
class InnermostCap(AbstractCapability[Any]):
|
|
def get_ordering(self) -> CapabilityOrdering:
|
|
return CapabilityOrdering(position='innermost')
|
|
|
|
async def before_run(self, ctx: RunContext[Any]) -> None:
|
|
pass
|
|
|
|
agent = Agent(
|
|
model=TestModel(),
|
|
capabilities=[OutermostCap(), InnermostCap(), Instrumentation(settings=InstrumentationSettings())],
|
|
)
|
|
result = await agent.run('hello')
|
|
assert result.output == snapshot('success (no tool calls)')
|
|
|
|
|
|
@pytest.mark.skipif(not logfire_installed, reason='logfire not installed')
|
|
def test_output_function_call_deferred_recorded_as_error(
|
|
get_logfire_summary: Callable[[], LogfireSummary],
|
|
) -> None:
|
|
"""An output function raising `CallDeferred` is recorded as a regular error on the
|
|
`wrap_output_process` span — that hook reserves the deferral-attribute path for
|
|
real tool executions (`wrap_tool_execute`)."""
|
|
|
|
def defer_text(text: str) -> str:
|
|
from pydantic_ai.exceptions import CallDeferred
|
|
|
|
raise CallDeferred()
|
|
|
|
def call_text_response(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(parts=[TextPart(content='hi')])
|
|
|
|
from pydantic_ai.exceptions import CallDeferred
|
|
|
|
my_agent = Agent(
|
|
model=FunctionModel(call_text_response), capabilities=[Instrumentation(settings=InstrumentationSettings())]
|
|
)
|
|
with pytest.raises(CallDeferred):
|
|
my_agent.run_sync('anything', output_type=TextOutput(defer_text))
|
|
|
|
summary = get_logfire_summary()
|
|
[span_attrs] = [attrs for attrs in summary.attributes.values() if attrs.get('gen_ai.tool.name') == 'defer_text']
|
|
# The span was recorded with ERROR status — the standard exception path,
|
|
# not the deferral-attribute path that `wrap_tool_execute` uses.
|
|
assert span_attrs.get('logfire.level_num', 0) >= 17 # error level
|
|
assert 'pydantic_ai.tool.deferral.name' not in span_attrs
|