Make run ID time-sortable; add agent name and run ID span attrs to all agent run child spans (#4876)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Douwe Maan <[email protected]>
This commit is contained in:
Adrian Garcia Badaracco
2026-03-30 16:37:58 -06:00
committed by GitHub
co-authored by Claude Opus 4.6 Douwe Maan
parent f426ed4511
commit a7f38e586c
10 changed files with 458 additions and 174 deletions
+2 -2
View File
@@ -3,7 +3,6 @@ from __future__ import annotations as _annotations
import asyncio
import dataclasses
import inspect
import uuid
from asyncio import Task
from collections import defaultdict, deque
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence
@@ -20,6 +19,7 @@ from pydantic_ai._history_processor import HistoryProcessor
from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION
from pydantic_ai._tool_manager import ToolManager, ValidatedToolCall
from pydantic_ai._utils import dataclasses_no_defaults_repr, get_union_args, now_utc
from pydantic_ai._uuid import uuid7
from pydantic_ai.builtin_tools import AbstractBuiltinTool
from pydantic_ai.capabilities.abstract import AbstractCapability
from pydantic_ai.models import ModelRequestContext
@@ -77,7 +77,7 @@ class GraphAgentState:
usage: _usage.RunUsage = dataclasses.field(default_factory=_usage.RunUsage)
retries: int = 0
run_step: int = 0
run_id: str = dataclasses.field(default_factory=lambda: str(uuid.uuid4()))
run_id: str = dataclasses.field(default_factory=lambda: str(uuid7()))
metadata: dict[str, Any] | None = None
last_max_tokens: int | None = None
"""Last-resolved `max_tokens` from model settings, used only in error messages."""
@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar
from typing import TYPE_CHECKING, Any, ClassVar
from opentelemetry.baggage import get_baggage
if TYPE_CHECKING:
from typing_extensions import Self
@@ -9,6 +11,21 @@ if TYPE_CHECKING:
DEFAULT_INSTRUMENTATION_VERSION = 2
"""Default instrumentation version for `InstrumentationSettings`."""
AGENT_NAME_BAGGAGE_KEY = 'gen_ai.agent.name'
RUN_ID_BAGGAGE_KEY = 'pydantic_ai.run_id'
def get_agent_run_baggage_attributes() -> dict[str, Any]:
"""Read agent name and run ID from OTel baggage and return as span attributes."""
attrs: dict[str, Any] = {}
agent_name = get_baggage(AGENT_NAME_BAGGAGE_KEY)
if agent_name is not None:
attrs[AGENT_NAME_BAGGAGE_KEY] = agent_name
run_id = get_baggage(RUN_ID_BAGGAGE_KEY)
if run_id is not None:
attrs[RUN_ID_BAGGAGE_KEY] = run_id
return attrs
@dataclass(frozen=True)
class InstrumentationNames:
+2 -1
View File
@@ -11,7 +11,7 @@ from pydantic import Json, TypeAdapter, ValidationError
from pydantic_core import SchemaValidator, to_json
from typing_extensions import Self, TypedDict, TypeVar
from pydantic_ai._instrumentation import InstrumentationNames
from pydantic_ai._instrumentation import InstrumentationNames, get_agent_run_baggage_attributes
from pydantic_ai._utils import get_function_type_hints
from . import _function_schema, _utils, messages as _messages
@@ -102,6 +102,7 @@ async def execute_traced_output_function(
tool_name = run_context.tool_name or getattr(function_schema.function, '__name__', 'output_function')
attributes = {
'gen_ai.tool.name': tool_name,
**get_agent_run_baggage_attributes(),
'logfire.msg': f'running output function: {tool_name}',
}
if run_context.tool_call_id:
@@ -13,7 +13,7 @@ from pydantic import ValidationError
from typing_extensions import deprecated
from . import messages as _messages
from ._instrumentation import InstrumentationNames
from ._instrumentation import InstrumentationNames, get_agent_run_baggage_attributes
from ._run_context import AgentDepsT, RunContext
from .exceptions import (
ApprovalRequired,
@@ -528,6 +528,7 @@ class ToolManager(Generic[AgentDepsT]):
# NOTE: this means `gen_ai.tool.call.id` will be included even if it was generated by pydantic-ai
'gen_ai.tool.call.id': call.tool_call_id,
**({instrumentation_names.tool_arguments_attr: call.args_as_json_str()} if include_content else {}),
**get_agent_run_baggage_attributes(),
'logfire.msg': f'running tool: {call.tool_name}',
# add the JSON schema so these attributes are formatted nicely in Logfire
'logfire.json_schema': json.dumps(
+87
View File
@@ -0,0 +1,87 @@
"""UUIDv7 polyfill for Python < 3.14.
Matches the CPython 3.14 implementation:
https://github.com/python/cpython/blob/main/Lib/uuid.py
Replace with `uuid.uuid7()` once Python 3.14 is the minimum supported version.
"""
from __future__ import annotations
import os
import threading
import time
import uuid
# Global state for sub-millisecond monotonicity (Method 1, RFC 9562 §6.2).
_last_timestamp_v7: int | None = None
_last_counter_v7: int = 0 # 42-bit counter
_lock_v7 = threading.Lock()
# version = 0b0111, variant = 0b10
_RFC_4122_VERSION_7_FLAGS = 0x0000_0000_0000_7000_8000_0000_0000_0000
def _uuid7_get_counter_and_tail() -> tuple[int, int]:
rand = int.from_bytes(os.urandom(10), 'big')
# 42-bit counter with MSB set to 0
counter = (rand >> 32) & 0x1FF_FFFF_FFFF
# 32-bit random data
tail = rand & 0xFFFF_FFFF
return counter, tail
def uuid7() -> uuid.UUID:
"""Generate a UUIDv7 (time-sortable UUID) per RFC 9562.
UUIDv7 objects feature monotonicity within a millisecond.
"""
# --- 48 --- -- 4 -- --- 12 --- -- 2 -- --- 30 --- - 32 -
# unix_ts_ms | version | counter_hi | variant | counter_lo | random
#
# 'counter = counter_hi | counter_lo' is a 42-bit counter constructed
# with Method 1 of RFC 9562, §6.2, and its MSB is set to 0.
#
# 'random' is a 32-bit random value regenerated for every new UUID.
#
# If multiple UUIDs are generated within the same millisecond, the LSB
# of 'counter' is incremented by 1. When overflowing, the timestamp is
# advanced and the counter is reset to a random 42-bit integer with MSB
# set to 0.
global _last_timestamp_v7
global _last_counter_v7
nanoseconds = time.time_ns()
timestamp_ms = nanoseconds // 1_000_000
with _lock_v7:
if _last_timestamp_v7 is None or timestamp_ms > _last_timestamp_v7:
counter, tail = _uuid7_get_counter_and_tail()
else:
if timestamp_ms < _last_timestamp_v7:
timestamp_ms = _last_timestamp_v7 + 1
# advance the 42-bit counter
counter = _last_counter_v7 + 1
if counter > 0x3FF_FFFF_FFFF:
# advance the 48-bit timestamp
timestamp_ms += 1
counter, tail = _uuid7_get_counter_and_tail()
else:
# 32-bit random data
tail = int.from_bytes(os.urandom(4), 'big')
_last_timestamp_v7 = timestamp_ms
_last_counter_v7 = counter
unix_ts_ms = timestamp_ms & 0xFFFF_FFFF_FFFF
counter_hi = (counter >> 30) & 0x0FFF
counter_lo = counter & 0x3FFF_FFFF
int_uuid_7 = unix_ts_ms << 80
int_uuid_7 |= counter_hi << 64
int_uuid_7 |= counter_lo << 32
int_uuid_7 |= tail & 0xFFFF_FFFF
int_uuid_7 |= _RFC_4122_VERSION_7_FLAGS
return uuid.UUID(int=int_uuid_7)
+178 -168
View File
@@ -13,6 +13,8 @@ from contextvars import ContextVar
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload
from opentelemetry.baggage import set_baggage as _otel_set_baggage
from opentelemetry.context import attach as _otel_attach, detach as _otel_detach
from opentelemetry.trace import NoOpTracer, use_span
from pydantic.json_schema import GenerateJsonSchema
from typing_extensions import Self, TypeVar, deprecated
@@ -98,21 +100,21 @@ if TYPE_CHECKING:
from ..ui._web import ModelsParam
__all__ = (
'AbstractAgent',
'Agent',
'AgentModelSettings',
'AgentRun',
'AgentRunResult',
'capture_run_messages',
'EndStrategy',
'CallToolsNode',
'ModelRequestNode',
'UserPromptNode',
'InstrumentationSettings',
'ParallelExecutionMode',
'WrapperAgent',
'AbstractAgent',
'EventStreamHandler',
'AgentModelSettings',
'BuiltinToolFunc',
'CallToolsNode',
'EndStrategy',
'EventStreamHandler',
'InstrumentationSettings',
'ModelRequestNode',
'ParallelExecutionMode',
'UserPromptNode',
'WrapperAgent',
'capture_run_messages',
)
@@ -1271,6 +1273,7 @@ class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
'model_name': model_used.model_name if model_used else 'no-model',
'agent_name': agent_name,
'gen_ai.agent.name': agent_name,
'pydantic_ai.run_id': state.run_id,
'logfire.msg': f'{agent_name} run',
}
if self._description is not None:
@@ -1283,176 +1286,183 @@ class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
instrumentation_names.get_agent_run_span_name(agent_name),
attributes=span_attributes,
)
run_metadata: dict[str, Any] | None = None
try:
async with (
_concurrency.get_concurrency_context(self._concurrency_limiter, f'agent:{agent_name}'),
graph.iter(
inputs=user_prompt_node,
state=state,
deps=graph_deps,
span=use_span(run_span) if run_span.is_recording() else None,
infer_name=False,
) as graph_run,
):
async with toolset:
agent_run = AgentRun(graph_run)
run_metadata = self._resolve_and_store_metadata(agent_run.ctx, metadata)
async with AsyncExitStack() as stack:
if run_span.is_recording():
ctx = _otel_set_baggage('gen_ai.agent.name', agent_name)
ctx = _otel_set_baggage('pydantic_ai.run_id', state.run_id, context=ctx)
token = _otel_attach(ctx)
stack.callback(_otel_detach, token)
await stack.enter_async_context(
_concurrency.get_concurrency_context(self._concurrency_limiter, f'agent:{agent_name}')
)
graph_run = await stack.enter_async_context(
graph.iter(
inputs=user_prompt_node,
state=state,
deps=graph_deps,
span=use_span(run_span) if run_span.is_recording() else None,
infer_name=False,
)
)
await stack.enter_async_context(toolset)
agent_run = AgentRun(graph_run)
run_metadata = self._resolve_and_store_metadata(agent_run.ctx, metadata)
# Build RunContext for run lifecycle hooks
run_ctx = _agent_graph.build_run_context(agent_run.ctx)
# Build RunContext for run lifecycle hooks
run_ctx = _agent_graph.build_run_context(agent_run.ctx)
# wrap_run cooperative hand-off protocol:
#
# 1. _do_run() calls before_run, sets _run_ready, then awaits _run_done.
# 2. wrap_run wraps _do_run via the capability middleware chain.
# 3. We await either _run_ready (handler started) or _wrap_task completion
# (short-circuit: wrap_run returned without calling handler).
# 4. We yield agent_run to the caller for iteration.
# 5. When the caller finishes (or an error occurs), we set _run_done.
# 6. _do_run resumes: returns the result (success) or re-raises the error.
# 7. If wrap_run catches the error and returns a recovery result, we use it.
# Otherwise the original error propagates.
_run_ready = asyncio.Event()
_run_done = asyncio.Event()
_run_error: BaseException | None = None
_wrap_context: list[tuple[ContextVar[Any], Any]] | None = None
# wrap_run cooperative hand-off protocol:
#
# 1. _do_run() calls before_run, sets _run_ready, then awaits _run_done.
# 2. wrap_run wraps _do_run via the capability middleware chain.
# 3. We await either _run_ready (handler started) or _wrap_task completion
# (short-circuit: wrap_run returned without calling handler).
# 4. We yield agent_run to the caller for iteration.
# 5. When the caller finishes (or an error occurs), we set _run_done.
# 6. _do_run resumes: returns the result (success) or re-raises the error.
# 7. If wrap_run catches the error and returns a recovery result, we use it.
# Otherwise the original error propagates.
_run_ready = asyncio.Event()
_run_done = asyncio.Event()
_run_error: BaseException | None = None
_wrap_context: list[tuple[ContextVar[Any], Any]] | None = None
async def _do_run() -> AgentRunResult[Any]:
nonlocal _wrap_context
await run_capability.before_run(run_ctx)
# Capture context vars set by wrap_run/before_run so
# they can be propagated to the outer task where
# agent_run.next() (and therefore node hooks) execute.
_current_ctx = contextvars.copy_context()
_wrap_context = [
(var, _current_ctx[var])
for var in _current_ctx
if var not in _outer_context or _outer_context[var] is not _current_ctx[var]
]
_run_ready.set()
await _run_done.wait()
if _run_error is not None:
# Raise the original node error, not the potentially
# transformed version from context manager __aexit__ chains.
raise agent_run._node_error or _run_error # pyright: ignore[reportPrivateUsage]
r = agent_run.result
assert r is not None
return r
async def _do_run() -> AgentRunResult[Any]:
nonlocal _wrap_context
await run_capability.before_run(run_ctx)
# Capture context vars set by wrap_run/before_run so
# they can be propagated to the outer task where
# agent_run.next() (and therefore node hooks) execute.
_current_ctx = contextvars.copy_context()
_wrap_context = [
(var, _current_ctx[var])
for var in _current_ctx
if var not in _outer_context or _outer_context[var] is not _current_ctx[var]
]
_run_ready.set()
await _run_done.wait()
if _run_error is not None:
# Raise the original node error, not the potentially
# transformed version from context manager __aexit__ chains.
raise agent_run._node_error or _run_error # pyright: ignore[reportPrivateUsage]
r = agent_run.result
assert r is not None
return r
_outer_context = contextvars.copy_context()
_wrap_task = asyncio.create_task(run_capability.wrap_run(run_ctx, handler=_do_run))
_outer_context = contextvars.copy_context()
_wrap_task = asyncio.create_task(run_capability.wrap_run(run_ctx, handler=_do_run))
# Wait for handler to start or wrap_run to complete (short-circuit)
_ready_waiter = asyncio.create_task(_run_ready.wait())
await asyncio.wait({_ready_waiter, _wrap_task}, return_when=asyncio.FIRST_COMPLETED)
_ready_waiter.cancel()
# Wait for handler to start or wrap_run to complete (short-circuit)
_ready_waiter = asyncio.create_task(_run_ready.wait())
await asyncio.wait({_ready_waiter, _wrap_task}, return_when=asyncio.FIRST_COMPLETED)
_ready_waiter.cancel()
# Propagate context vars set by wrap_run/before_run to
# the outer task so that agent_run.next() (and therefore
# node hooks) can see them.
_context_tokens: list[tuple[ContextVar[Any], contextvars.Token[Any]]] = []
# Note: indexing instead of tuple unpacking because pyright
# can't resolve types through nonlocal + Optional unpacking.
for _cv_pair in _wrap_context or ():
_context_tokens.append((_cv_pair[0], _cv_pair[0].set(_cv_pair[1])))
# Propagate context vars set by wrap_run/before_run to
# the outer task so that agent_run.next() (and therefore
# node hooks) can see them.
_context_tokens: list[tuple[ContextVar[Any], contextvars.Token[Any]]] = []
# Note: indexing instead of tuple unpacking because pyright
# can't resolve types through nonlocal + Optional unpacking.
for _cv_pair in _wrap_context or ():
_context_tokens.append((_cv_pair[0], _cv_pair[0].set(_cv_pair[1])))
async def _finalize_result(r: AgentRunResult[Any]) -> None:
"""Call after_run, store the result override, and clear any pending error."""
nonlocal _run_error
r = await run_capability.after_run(run_ctx, result=r)
agent_run._result_override = r # pyright: ignore[reportPrivateUsage]
_run_error = None
async def _finalize_result(r: AgentRunResult[Any]) -> None:
"""Call after_run, store the result override, and clear any pending error."""
nonlocal _run_error
r = await run_capability.after_run(run_ctx, result=r)
agent_run._result_override = r # pyright: ignore[reportPrivateUsage]
_run_error = None
try:
_short_circuited = _wrap_task.done() and not _run_ready.is_set()
if _short_circuited:
await _finalize_result(_wrap_task.result())
try:
_short_circuited = _wrap_task.done() and not _run_ready.is_set()
if _short_circuited:
await _finalize_result(_wrap_task.result())
try:
yield agent_run
except BaseException as _exc:
# Use the original node error if available, since context manager
# __aexit__ chains (GraphRun → anyio TaskGroup) may transform
# the exception (e.g. into CancelledError or ExceptionGroup).
_run_error = agent_run._node_error or _exc # pyright: ignore[reportPrivateUsage]
# Don't attempt recovery for GeneratorExit/KeyboardInterrupt —
# awaiting _wrap_task during cleanup could delay shutdown.
if isinstance(_run_error, (GeneratorExit, KeyboardInterrupt)):
raise
# Don't re-raise yet — give wrap_run a chance to recover.
# If wrap_run catches the error from handler() and returns
# a recovery result, the exception will be suppressed.
finally:
if agent_run.result is not None:
run_metadata = self._resolve_and_store_metadata(agent_run.ctx, metadata)
else:
run_metadata = graph_run.state.metadata
if not _short_circuited:
_run_done.set()
if _run_error is None and agent_run.result is not None:
await _finalize_result(await _wrap_task)
elif _run_error is not None:
# Error path: await wrap_run to see if it recovers.
# _do_run() re-raises _run_error; if wrap_run catches
# it and returns a result, recovery succeeds.
try:
await _finalize_result(await _wrap_task)
except BaseException as _wrap_exc:
# Attach wrap_run's own errors as context so they're
# visible in tracebacks (but don't mask the original).
# Skip CancelledError: it's expected cancellation propagation,
# and setting __context__ on it causes hangs on Python 3.10.
if (
not isinstance(_wrap_exc, asyncio.CancelledError)
and _wrap_exc is not _run_error
):
_run_error.__context__ = _wrap_exc # pragma: no cover — only fires for bugs in wrap_run implementations
elif (
not _wrap_task.done()
): # pragma: no branch — _run_done.set() can't complete _wrap_task synchronously
_wrap_task.cancel()
try:
await _wrap_task
except (asyncio.CancelledError, BaseException):
pass
# If wrap_run didn't recover, give on_run_error a chance.
if _run_error is not None:
try:
_result = await run_capability.on_run_error(run_ctx, error=_run_error)
except BaseException as _on_error_exc:
_run_error = _on_error_exc
else:
await _finalize_result(_result)
# If on_run_error didn't recover either, re-raise.
# In an @asynccontextmanager, not re-raising suppresses the exception.
if _run_error is not None:
raise _run_error
yield agent_run
except BaseException as _exc:
# Use the original node error if available, since context manager
# __aexit__ chains (GraphRun → anyio TaskGroup) may transform
# the exception (e.g. into CancelledError or ExceptionGroup).
_run_error = agent_run._node_error or _exc # pyright: ignore[reportPrivateUsage]
# Don't attempt recovery for GeneratorExit/KeyboardInterrupt —
# awaiting _wrap_task during cleanup could delay shutdown.
if isinstance(_run_error, (GeneratorExit, KeyboardInterrupt)):
raise
# Don't re-raise yet — give wrap_run a chance to recover.
# If wrap_run catches the error from handler() and returns
# a recovery result, the exception will be suppressed.
finally:
# Always restore context vars, even on
# GeneratorExit/KeyboardInterrupt.
for _var, _token in _context_tokens:
_var.reset(_token)
if agent_run.result is not None:
run_metadata = self._resolve_and_store_metadata(agent_run.ctx, metadata)
else:
run_metadata = graph_run.state.metadata
final_result = agent_run.result
if (
instrumentation_settings
and instrumentation_settings.include_content
and run_span.is_recording()
and final_result is not None
):
run_span.set_attribute(
'final_result',
(
final_result.output
if isinstance(final_result.output, str)
else json.dumps(InstrumentedModel.serialize_any(final_result.output))
),
)
if not _short_circuited:
_run_done.set()
if _run_error is None and agent_run.result is not None:
await _finalize_result(await _wrap_task)
elif _run_error is not None:
# Error path: await wrap_run to see if it recovers.
# _do_run() re-raises _run_error; if wrap_run catches
# it and returns a result, recovery succeeds.
try:
await _finalize_result(await _wrap_task)
except BaseException as _wrap_exc:
# Attach wrap_run's own errors as context so they're
# visible in tracebacks (but don't mask the original).
# Skip CancelledError: it's expected cancellation propagation,
# and setting __context__ on it causes hangs on Python 3.10.
if (
not isinstance(_wrap_exc, asyncio.CancelledError)
and _wrap_exc is not _run_error
):
_run_error.__context__ = _wrap_exc # pragma: no cover — only fires for bugs in wrap_run implementations
elif (
not _wrap_task.done()
): # pragma: no branch — _run_done.set() can't complete _wrap_task synchronously
_wrap_task.cancel()
try:
await _wrap_task
except (asyncio.CancelledError, BaseException):
pass
# If wrap_run didn't recover, give on_run_error a chance.
if _run_error is not None:
try:
_result = await run_capability.on_run_error(run_ctx, error=_run_error)
except BaseException as _on_error_exc:
_run_error = _on_error_exc
else:
await _finalize_result(_result)
# If on_run_error didn't recover either, re-raise.
# In an @asynccontextmanager, not re-raising suppresses the exception.
if _run_error is not None:
raise _run_error
finally:
# Always restore context vars, even on
# GeneratorExit/KeyboardInterrupt.
for _var, _token in _context_tokens:
_var.reset(_token)
final_result = agent_run.result
if (
instrumentation_settings
and instrumentation_settings.include_content
and run_span.is_recording()
and final_result is not None
):
run_span.set_attribute(
'final_result',
(
final_result.output
if isinstance(final_result.output, str)
else json.dumps(InstrumentedModel.serialize_any(final_result.output))
),
)
finally:
try:
if instrumentation_settings and run_span.is_recording():
@@ -21,7 +21,7 @@ from opentelemetry.trace import Span, SpanKind, Tracer, TracerProvider, get_trac
from opentelemetry.util.types import AttributeValue
from pydantic import TypeAdapter
from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION
from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION, get_agent_run_baggage_attributes
from .. import _otel_messages
from .._run_context import RunContext
@@ -456,6 +456,7 @@ class InstrumentedModel(WrapperModel):
'gen_ai.operation.name': operation,
**self.model_attributes(self.wrapped),
**self.model_request_parameters_attributes(model_request_parameters),
**get_agent_run_baggage_attributes(),
'logfire.json_schema': json.dumps(
{
'type': 'object',
+12
View File
@@ -172,6 +172,8 @@ def test_first_failed_instrumented(capfire: CaptureLogfire) -> None:
'thinking': None,
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'gen_ai.provider.name': 'function',
'logfire.msg': 'chat fallback:function:failure_response:,function:success_response:',
'gen_ai.system': 'function',
@@ -203,6 +205,7 @@ def test_first_failed_instrumented(capfire: CaptureLogfire) -> None:
'model_name': 'fallback:function:failure_response:,function:success_response:',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
@@ -283,6 +286,8 @@ async def test_first_failed_instrumented_stream(capfire: CaptureLogfire) -> None
'thinking': None,
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'gen_ai.provider.name': 'function',
'logfire.msg': 'chat fallback:function::failure_response_stream,function::success_response_stream',
'gen_ai.system': 'function',
@@ -314,6 +319,7 @@ async def test_first_failed_instrumented_stream(capfire: CaptureLogfire) -> None
'model_name': 'fallback:function::failure_response_stream,function::success_response_stream',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'final_result': 'hello world',
@@ -401,6 +407,8 @@ def test_all_failed_instrumented(capfire: CaptureLogfire) -> None:
},
'logfire.span_type': 'span',
'logfire.msg': 'chat fallback:function:failure_response:,function:failure_response:',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'logfire.level_num': 17,
'gen_ai.response.model': 'fallback:function:failure_response:,function:failure_response:',
},
@@ -427,6 +435,7 @@ def test_all_failed_instrumented(capfire: CaptureLogfire) -> None:
'model_name': 'fallback:function:failure_response:,function:failure_response:',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000',
@@ -894,6 +903,8 @@ Don't include any text or Markdown fencing before or after.
'thinking': None,
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'gen_ai.provider.name': 'function',
'logfire.msg': 'chat fallback:function:tool_output_func:,function:prompted_output_func:',
'gen_ai.system': 'function',
@@ -927,6 +938,7 @@ Don't include any text or Markdown fencing before or after.
'model_name': 'fallback:function:tool_output_func:,function:prompted_output_func:',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
+94
View File
@@ -237,6 +237,7 @@ def test_logfire(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'final_result': '{"my_ret":"1"}',
@@ -293,6 +294,7 @@ def test_logfire(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 103,
@@ -530,6 +532,8 @@ def test_logfire(
'gen_ai.tool.definitions': '[{"type": "function", "name": "my_ret", "parameters": {"additionalProperties": false, "properties": {"x": {"type": "integer"}}, "required": ["x"], "type": "object"}}]',
'logfire.json_schema': IsJson(),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'chat test',
'gen_ai.response.model': 'test',
'gen_ai.usage.input_tokens': 51,
@@ -597,6 +601,7 @@ def test_instructions_with_structured_output(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
@@ -716,6 +721,7 @@ def test_instructions_with_structured_output(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'final_result': '{"content": "a"}',
@@ -807,6 +813,7 @@ def test_instructions_with_structured_output_exclude_content(get_logfire_summary
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
@@ -925,6 +932,7 @@ def test_instructions_with_structured_output_exclude_content_v2_v3(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
@@ -1012,6 +1020,8 @@ def test_instructions_with_structured_output_exclude_content_v2_v3(
'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',
'logfire.msg': 'chat test',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'gen_ai.input.messages': IsJson(snapshot([{'role': 'user', 'parts': [{'type': 'text'}]}])),
'gen_ai.output.messages': IsJson(
snapshot(
@@ -1102,6 +1112,7 @@ async def test_aggregated_usage_attribute_names(capfire: CaptureLogfire) -> None
'model_name': 'function:model_function:',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'final_result': 'Hello!',
@@ -1168,6 +1179,8 @@ async def test_feedback(capfire: CaptureLogfire) -> None:
},
'logfire.span_type': 'span',
'logfire.msg': 'chat test',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'gen_ai.usage.input_tokens': 51,
'gen_ai.usage.output_tokens': 4,
'gen_ai.response.model': 'test',
@@ -1208,6 +1221,7 @@ async def test_feedback(capfire: CaptureLogfire) -> None:
'model_name': 'test',
'agent_name': 'agent',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
@@ -1315,6 +1329,8 @@ def test_include_tool_args_span_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': """\
Tool error
@@ -1344,6 +1360,8 @@ Fix the errors and try again.\
}
)
),
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.span_type': 'span',
}
)
@@ -1366,6 +1384,8 @@ Fix the errors and try again.\
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.level_num': 17,
}
)
@@ -1387,6 +1407,8 @@ Fix the errors and try again.\
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -1468,6 +1490,8 @@ def test_logfire_output_function_v2_v3(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -1515,6 +1539,8 @@ def test_logfire_output_function_v2_v3(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'gen_ai.tool.call.result': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -1568,6 +1594,8 @@ def test_output_type_function_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -1579,6 +1607,8 @@ def test_output_type_function_logfire_attributes(
'logfire.msg': 'running output function: final_result',
'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',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -1632,6 +1662,8 @@ def test_output_type_function_with_run_context_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -1643,6 +1675,8 @@ def test_output_type_function_with_run_context_logfire_attributes(
'logfire.msg': 'running output function: final_result',
'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',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -1704,6 +1738,8 @@ def test_output_type_function_with_retry_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.level_num': 17,
},
{
@@ -1725,6 +1761,8 @@ def test_output_type_function_with_retry_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
},
]
@@ -1740,6 +1778,8 @@ def test_output_type_function_with_retry_logfire_attributes(
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.level_num': 17,
},
{
@@ -1750,6 +1790,8 @@ def test_output_type_function_with_retry_logfire_attributes(
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
},
]
)
@@ -1802,6 +1844,8 @@ def test_output_type_function_with_custom_tool_name_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -1815,6 +1859,8 @@ def test_output_type_function_with_custom_tool_name_logfire_attributes(
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -1873,6 +1919,8 @@ def test_output_type_bound_instance_method_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -1886,6 +1934,8 @@ def test_output_type_bound_instance_method_logfire_attributes(
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -1945,6 +1995,8 @@ def test_output_type_bound_instance_method_with_run_context_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -1958,6 +2010,8 @@ def test_output_type_bound_instance_method_with_run_context_logfire_attributes(
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -2012,6 +2066,8 @@ def test_output_type_async_function_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
}
)
@@ -2025,6 +2081,8 @@ def test_output_type_async_function_logfire_attributes(
snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}, 'gen_ai.tool.call.id': {}}})
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -2082,6 +2140,8 @@ def test_text_output_function_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': 'HELLO WORLD',
}
)
@@ -2092,6 +2152,8 @@ def test_text_output_function_logfire_attributes(
'logfire.msg': 'running output function: upcase_text',
'logfire.json_schema': IsJson(snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}}})),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -2154,6 +2216,8 @@ def test_prompted_output_function_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': 'HELLO WORLD',
}
)
@@ -2164,6 +2228,8 @@ def test_prompted_output_function_logfire_attributes(
'logfire.msg': 'running output function: upcase_text',
'logfire.json_schema': IsJson(snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}}})),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
}
)
@@ -2226,6 +2292,8 @@ def test_output_type_text_output_function_with_retry_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.level_num': 17,
},
{
@@ -2245,6 +2313,8 @@ def test_output_type_text_output_function_with_retry_logfire_attributes(
)
),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'tool_response': '{"temperature": 28.7, "description": "sunny"}',
},
]
@@ -2257,6 +2327,8 @@ def test_output_type_text_output_function_with_retry_logfire_attributes(
'logfire.msg': 'running output function: get_weather_with_retry',
'logfire.json_schema': IsJson(snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}}})),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.level_num': 17,
},
{
@@ -2264,6 +2336,8 @@ def test_output_type_text_output_function_with_retry_logfire_attributes(
'logfire.msg': 'running output function: get_weather_with_retry',
'logfire.json_schema': IsJson(snapshot({'type': 'object', 'properties': {'gen_ai.tool.name': {}}})),
'logfire.span_type': 'span',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
},
]
)
@@ -2298,6 +2372,7 @@ def test_static_function_instructions_in_agent_run_span(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 51,
@@ -2417,6 +2492,7 @@ def test_static_function_instructions_in_agent_run_span(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'final_result': '{"content": "a"}',
@@ -2522,6 +2598,7 @@ def test_dynamic_function_instructions_in_agent_run_span(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 107,
@@ -2669,6 +2746,7 @@ def test_dynamic_function_instructions_in_agent_run_span(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'final_result': '{"content": "a"}',
@@ -2805,6 +2883,7 @@ def test_function_instructions_with_history_in_agent_run_span(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'gen_ai.usage.input_tokens': 52,
@@ -2950,6 +3029,7 @@ def test_function_instructions_with_history_in_agent_run_span(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'final_result': '{"content": "a"}',
@@ -3058,6 +3138,7 @@ async def test_run_stream(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'final_result': 'success (no tool calls)',
@@ -3154,6 +3235,7 @@ async def test_run_stream(
'model_name': 'test',
'agent_name': 'my_agent',
'gen_ai.agent.name': 'my_agent',
'pydantic_ai.run_id': IsStr(),
'logfire.msg': 'my_agent run',
'logfire.span_type': 'span',
'final_result': 'success (no tool calls)',
@@ -3244,6 +3326,8 @@ def test_deferral_call_deferred_v2(capfire: CaptureLogfire) -> None:
},
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'pydantic_ai.tool.deferral.name': 'CallDeferred',
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
'logfire.level_num': 17,
@@ -3301,6 +3385,8 @@ def test_deferral_approval_required_v2(capfire: CaptureLogfire) -> None:
},
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'pydantic_ai.tool.deferral.name': 'ApprovalRequired',
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
'logfire.level_num': 17,
@@ -3358,6 +3444,8 @@ def test_deferral_call_deferred_v5(capfire: CaptureLogfire) -> None:
},
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'pydantic_ai.tool.deferral.name': 'CallDeferred',
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
},
@@ -3402,6 +3490,8 @@ def test_deferral_approval_required_v5(capfire: CaptureLogfire) -> None:
},
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'pydantic_ai.tool.deferral.name': 'ApprovalRequired',
'pydantic_ai.tool.deferral.metadata': {'task_id': 'task-123'},
},
@@ -3448,6 +3538,8 @@ def test_deferral_no_metadata(capfire: CaptureLogfire) -> None:
},
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'pydantic_ai.tool.deferral.name': 'CallDeferred',
},
}
@@ -3498,6 +3590,8 @@ def test_deferral_non_serializable_metadata(capfire: CaptureLogfire) -> None:
},
},
'logfire.span_type': 'span',
'gen_ai.agent.name': 'agent',
'pydantic_ai.run_id': IsStr(),
'pydantic_ai.tool.deferral.name': 'CallDeferred',
'pydantic_ai.tool.deferral.metadata': "{'obj': <CustomObj>}",
},
+61
View File
@@ -0,0 +1,61 @@
"""Tests for the UUIDv7 polyfill."""
from unittest.mock import patch
import pydantic_ai._uuid as uuid_mod
from pydantic_ai._uuid import uuid7
def test_uuid7_basic() -> None:
u = uuid7()
assert u.version == 7
assert u.variant == 'specified in RFC 4122'
assert len(str(u)) == 36
def test_uuid7_monotonic_within_millisecond() -> None:
"""Multiple uuid7() calls in the same millisecond produce monotonically increasing values."""
ids = [uuid7() for _ in range(100)]
for a, b in zip(ids, ids[1:]):
assert a < b
def test_uuid7_clock_regression() -> None:
"""uuid7() handles system clock going backward (e.g. NTP adjustment)."""
saved_ts = uuid_mod._last_timestamp_v7 # pyright: ignore[reportPrivateUsage]
saved_counter = uuid_mod._last_counter_v7 # pyright: ignore[reportPrivateUsage]
try:
# Generate a UUID at a known timestamp
with patch.object(uuid_mod.time, 'time_ns', return_value=2_000 * 1_000_000_000):
u1 = uuid7()
# Clock goes backward
with patch.object(uuid_mod.time, 'time_ns', return_value=1_000 * 1_000_000_000):
u2 = uuid7()
assert u2 > u1
assert u2.version == 7
finally:
uuid_mod._last_timestamp_v7 = saved_ts # pyright: ignore[reportPrivateUsage]
uuid_mod._last_counter_v7 = saved_counter # pyright: ignore[reportPrivateUsage]
def test_uuid7_counter_overflow() -> None:
"""uuid7() advances the timestamp when the 42-bit counter overflows."""
saved_ts = uuid_mod._last_timestamp_v7 # pyright: ignore[reportPrivateUsage]
saved_counter = uuid_mod._last_counter_v7 # pyright: ignore[reportPrivateUsage]
try:
# Set counter to max so the next same-ms call overflows
with patch.object(uuid_mod.time, 'time_ns', return_value=3_000 * 1_000_000_000):
u1 = uuid7()
uuid_mod._last_counter_v7 = 0x3FF_FFFF_FFFF # pyright: ignore[reportPrivateUsage]
with patch.object(uuid_mod.time, 'time_ns', return_value=3_000 * 1_000_000_000):
u2 = uuid7()
assert u2 > u1
assert u2.version == 7
finally:
uuid_mod._last_timestamp_v7 = saved_ts # pyright: ignore[reportPrivateUsage]
uuid_mod._last_counter_v7 = saved_counter # pyright: ignore[reportPrivateUsage]