Skip to content

Commit cd97892

Browse files
committed
ref(pydantic-ai): Resolve version divergence in _compat and restructure modules
Introduce _compat.py, which resolves the installed pydantic-ai version and every version-dependent decision once at import time: the hooks-vs-graph-nodes model backend, the ToolManager tool-call method name, and the message part classes (now resolved individually so one upstream rename degrades only the paths needing that class). The using_request_hooks class flag and the circular-import workarounds are gone; setup_once is three composed calls with deferred imports, and importing the package loads nothing but __init__. Restructure to the target layout: _spans.py absorbs utils.py and the spans/ package, _wrap_agent.py and _wrap_model.py (both model backends behind one install_model_backend()) and _wrap_tools.py (the two duplicated tool wrappers unified into one) replace patches/. Review-driven fixes folded in: unknown ToolManager method names and a missing _agent_graph module now degrade gracefully instead of crashing sentry_sdk.init(), agent_run_scope removes exactly its own run from the stack so non-LIFO streaming exits cannot corrupt it, the streaming wrapper propagates exception suppression from the wrapped context manager, the after_model_request span close is guarded, and chat spans extract model info once instead of twice.
1 parent 349fddb commit cd97892

18 files changed

Lines changed: 928 additions & 1029 deletions

File tree

Lines changed: 17 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,30 @@
1-
import functools
2-
31
from sentry_sdk.integrations import DidNotEnable, Integration
4-
from sentry_sdk.utils import capture_internal_exceptions, parse_version
52

63
try:
74
import pydantic_ai # noqa: F401
8-
from pydantic_ai import Agent
95
except ImportError:
106
raise DidNotEnable("pydantic-ai not installed")
117

128

13-
from importlib.metadata import PackageNotFoundError, version
14-
from typing import TYPE_CHECKING
15-
16-
from .patches import (
17-
_patch_agent_run,
18-
_patch_graph_nodes,
19-
_patch_tool_execution,
20-
)
21-
from .spans.ai_client import ai_client_span, update_ai_client_span
22-
23-
if TYPE_CHECKING:
24-
from typing import Any
25-
26-
from pydantic_ai import ModelRequestContext, RunContext
27-
from pydantic_ai.capabilities import Hooks
28-
from pydantic_ai.messages import ModelResponse
29-
30-
31-
def register_hooks(hooks: "Hooks") -> None:
32-
"""
33-
Creates hooks for chat model calls and register the hooks by adding the hooks to the `capabilities` argument passed to `Agent.__init__()`.
34-
35-
The chat span opened in on_request is stored in the run's `RunContext.metadata`
36-
dict, which pydantic-ai shares by reference between the hooks of one run. This
37-
keeps span pairing correct per run (even for overlapping runs in one task) and
38-
covers every entry point that fires request hooks (including `Agent.iter()`,
39-
which the Agent.run/run_stream wrappers never see). It requires seeding a
40-
metadata dict in `patched_init` below when the user did not provide one.
41-
"""
42-
43-
@hooks.on.before_model_request
44-
async def on_request(
45-
ctx: "RunContext[None]", request_context: "ModelRequestContext"
46-
) -> "ModelRequestContext":
47-
run_context_metadata = ctx.metadata
48-
if not isinstance(run_context_metadata, dict):
49-
return request_context
50-
51-
span = None
52-
with capture_internal_exceptions():
53-
span = ai_client_span(
54-
messages=request_context.messages,
55-
agent=None,
56-
model=request_context.model,
57-
model_settings=request_context.model_settings,
58-
)
59-
60-
if span is None:
61-
return request_context
62-
63-
run_context_metadata["_sentry_span"] = span
64-
span.__enter__()
65-
66-
return request_context
67-
68-
@hooks.on.after_model_request
69-
async def on_response(
70-
ctx: "RunContext[None]",
71-
*,
72-
request_context: "ModelRequestContext",
73-
response: "ModelResponse",
74-
) -> "ModelResponse":
75-
run_context_metadata = ctx.metadata
76-
if not isinstance(run_context_metadata, dict):
77-
return response
78-
79-
span = run_context_metadata.pop("_sentry_span", None)
80-
if span is None:
81-
return response
82-
83-
with capture_internal_exceptions():
84-
update_ai_client_span(span, response)
85-
span.__exit__(None, None, None)
86-
87-
return response
88-
89-
@hooks.on.model_request_error
90-
async def on_error(
91-
ctx: "RunContext[None]",
92-
*,
93-
request_context: "ModelRequestContext",
94-
error: "Exception",
95-
) -> "ModelResponse":
96-
run_context_metadata = ctx.metadata
97-
98-
if not isinstance(run_context_metadata, dict):
99-
raise error
100-
101-
span = run_context_metadata.pop("_sentry_span", None)
102-
if span is None:
103-
raise error
104-
105-
with capture_internal_exceptions():
106-
span.__exit__(type(error), error, error.__traceback__)
107-
108-
raise error
109-
110-
original_init = Agent.__init__
111-
112-
@functools.wraps(original_init)
113-
def patched_init(self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any") -> None:
114-
caps = list(kwargs.get("capabilities") or [])
115-
caps.append(hooks)
116-
kwargs["capabilities"] = caps
117-
118-
metadata = kwargs.get("metadata")
119-
if metadata is None:
120-
kwargs["metadata"] = {} # Used as shared reference between hooks
121-
122-
return original_init(self, *args, **kwargs)
123-
124-
Agent.__init__ = patched_init # type: ignore[method-assign]
125-
126-
1279
class PydanticAIIntegration(Integration):
12810
"""
12911
Typical interaction with the library:
13012
1. The user creates an Agent instance with configuration, including system instructions sent to every model call.
13113
2. The user calls `Agent.run()` or `Agent.run_stream()` to start an agent run. The latter can be used to incrementally receive progress.
13214
3. In a loop, the agent repeatedly calls the model, maintaining a conversation history that includes previous messages and tool results, which is passed to each call.
13315
134-
Internally, Pydantic AI maintains an execution graph in which ModelRequestNode are responsible for model calls, including retries.
135-
Hooks using the decorators provided by `pydantic_ai.capabilities` create and manage spans for model calls when these hooks are available (newer library versions);
136-
older versions are instrumented by patching the graph nodes directly (see patches/graph_nodes.py).
137-
138-
The wrappers around `Agent.run()` and `Agent.run_stream()` track each in-flight run on a contextvar stack (see _run_context.py); the tool patches and span
139-
helpers read the current agent from there. The request hooks pair each chat span with its model request through the run's `RunContext.metadata` dict
140-
(see register_hooks), which stays correct per run and also covers entry points the wrappers don't instrument, such as `Agent.iter()`.
16+
How the integration is put together:
17+
- _compat.py resolves the installed pydantic-ai version and every version-dependent decision, once, at import time.
18+
- _extract.py is the only module that reads pydantic-ai object internals; it returns plain data structures.
19+
- _spans.py creates spans and writes extracted data onto them.
20+
- _run_context.py tracks each in-flight run on a contextvar stack; the tool wrapper and span helpers read the current agent from there.
21+
- _wrap_agent.py instruments Agent.run / Agent.run_stream (invoke_agent spans, isolation scopes, run tracking).
22+
- _wrap_model.py emits chat spans for model requests via one of two backends chosen in _compat: request hooks (>= 1.73), paired per run through RunContext.metadata, or graph-node patching (older versions).
23+
- _wrap_tools.py instruments the single ToolManager method all tool calls flow through (execute_tool spans).
14124
"""
14225

14326
identifier = "pydantic_ai"
14427
origin = f"auto.ai.{identifier}"
145-
using_request_hooks = False
14628

14729
def __init__(
14830
self, include_prompts: bool = True, handled_tool_call_exceptions: bool = True
@@ -169,32 +51,14 @@ def setup_once() -> None:
16951
- Model requests (AI client calls)
17052
- Tool executions
17153
"""
54+
# Deferred imports keep `import sentry_sdk.integrations.pydantic_ai`
55+
# cheap when the integration is never enabled; they are the only
56+
# intra-package imports in this module, keeping the import graph
57+
# acyclic.
58+
from ._wrap_agent import _patch_agent_run
59+
from ._wrap_model import install_model_backend
60+
from ._wrap_tools import _patch_tool_execution
61+
17262
_patch_agent_run()
17363
_patch_tool_execution()
174-
175-
PydanticAIIntegration.using_request_hooks = False
176-
try:
177-
PYDANTIC_AI_VERSION = version("pydantic-ai-slim")
178-
except PackageNotFoundError:
179-
return
180-
181-
PYDANTIC_AI_VERSION = parse_version(PYDANTIC_AI_VERSION)
182-
if PYDANTIC_AI_VERSION is None:
183-
return
184-
185-
# ModelRequestContext.model added in https://github.com/pydantic/pydantic-ai/commit/f1260dfe09907f17688eee1646daf898fc428d4c
186-
if PYDANTIC_AI_VERSION < (
187-
1,
188-
73,
189-
):
190-
_patch_graph_nodes()
191-
return
192-
193-
try:
194-
from pydantic_ai.capabilities import Hooks
195-
except ImportError:
196-
return
197-
198-
PydanticAIIntegration.using_request_hooks = True
199-
hooks = Hooks()
200-
register_hooks(hooks)
64+
install_model_backend()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Version detection and version-dependent imports for pydantic-ai.
2+
3+
Everything here is resolved once at import time. The rest of the integration
4+
consumes the resulting constants instead of probing versions or attributes at
5+
call time, so "what runs on version X" is answered entirely by this module.
6+
"""
7+
8+
from typing import TYPE_CHECKING
9+
10+
from sentry_sdk.integrations import DidNotEnable
11+
from sentry_sdk.utils import package_version
12+
13+
try:
14+
from pydantic_ai import messages as _messages
15+
from pydantic_ai.agent import Agent # noqa: F401
16+
from pydantic_ai.exceptions import ToolRetryError # noqa: F401
17+
18+
try:
19+
from pydantic_ai.tool_manager import ToolManager
20+
except ImportError:
21+
# older versions
22+
from pydantic_ai._tool_manager import ToolManager # type: ignore
23+
except ImportError:
24+
raise DidNotEnable("pydantic-ai not installed")
25+
26+
if TYPE_CHECKING:
27+
from typing import Optional
28+
29+
# Message part classes are resolved individually so that a single upstream
30+
# rename degrades only the extraction paths that need that class, instead of
31+
# silently disabling all of them at once.
32+
BaseToolCallPart = getattr(_messages, "BaseToolCallPart", None)
33+
BaseToolReturnPart = getattr(_messages, "BaseToolReturnPart", None)
34+
BinaryContent = getattr(_messages, "BinaryContent", None)
35+
ImageUrl = getattr(_messages, "ImageUrl", None)
36+
SystemPromptPart = getattr(_messages, "SystemPromptPart", None)
37+
TextPart = getattr(_messages, "TextPart", None)
38+
ThinkingPart = getattr(_messages, "ThinkingPart", None)
39+
40+
PYDANTIC_AI_VERSION = package_version("pydantic-ai-slim")
41+
42+
# The ToolManager method through which all tool calls flow; renamed from
43+
# _call_tool to execute_tool_call in newer versions. None means the method
44+
# could not be found and tool instrumentation is skipped.
45+
TOOL_CALL_METHOD: "Optional[str]" = None
46+
if hasattr(ToolManager, "execute_tool_call"):
47+
TOOL_CALL_METHOD = "execute_tool_call"
48+
elif hasattr(ToolManager, "_call_tool"):
49+
TOOL_CALL_METHOD = "_call_tool"
50+
51+
# Request hooks (pydantic_ai.capabilities) are usable from 1.73 on, when
52+
# ModelRequestContext.model was added:
53+
# https://github.com/pydantic/pydantic-ai/commit/f1260dfe09907f17688eee1646daf898fc428d4c
54+
USES_REQUEST_HOOKS = False
55+
if PYDANTIC_AI_VERSION is not None and PYDANTIC_AI_VERSION >= (1, 73):
56+
try:
57+
from pydantic_ai.capabilities import Hooks # noqa: F401
58+
59+
USES_REQUEST_HOOKS = True
60+
except ImportError:
61+
USES_REQUEST_HOOKS = False
62+
63+
# Which mechanism emits chat spans for model requests: request hooks on new
64+
# versions, graph-node patching on old ones. None (unknown version, or hooks
65+
# unavailable on a new version) means only agent and tool spans are emitted.
66+
MODEL_BACKEND: "Optional[str]" = None
67+
if USES_REQUEST_HOOKS:
68+
MODEL_BACKEND = "hooks"
69+
elif PYDANTIC_AI_VERSION is not None and PYDANTIC_AI_VERSION < (1, 73):
70+
MODEL_BACKEND = "graph_nodes"

sentry_sdk/integrations/pydantic_ai/_extract.py

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
private attributes and version-dependent shapes) so that upstream library
55
changes are absorbed here rather than throughout the integration. The one
66
exception is control-flow state read at the patch points themselves (e.g.
7-
ModelRequestNode._did_stream in patches/graph_nodes.py and Tool.tool_def in
8-
patches/tools.py); everything else consumes the plain data structures
7+
ModelRequestNode._did_stream in _wrap_model.py and Tool.tool_def in
8+
_wrap_tools.py); everything else consumes the plain data structures
99
returned here.
1010
"""
1111

@@ -18,25 +18,15 @@
1818
from sentry_sdk.consts import SPANDATA
1919
from sentry_sdk.utils import safe_serialize
2020

21-
try:
22-
from pydantic_ai.messages import (
23-
BaseToolCallPart,
24-
BaseToolReturnPart,
25-
BinaryContent,
26-
ImageUrl,
27-
SystemPromptPart,
28-
TextPart,
29-
ThinkingPart,
30-
)
31-
except ImportError:
32-
# Fallback if these classes are not available
33-
BaseToolCallPart = None # type: ignore[misc,assignment]
34-
BaseToolReturnPart = None # type: ignore[misc,assignment]
35-
BinaryContent = None # type: ignore[misc,assignment]
36-
ImageUrl = None # type: ignore[misc,assignment]
37-
SystemPromptPart = None # type: ignore[misc,assignment]
38-
TextPart = None # type: ignore[misc,assignment]
39-
ThinkingPart = None # type: ignore[misc,assignment]
21+
from ._compat import (
22+
BaseToolCallPart,
23+
BaseToolReturnPart,
24+
BinaryContent,
25+
ImageUrl,
26+
SystemPromptPart,
27+
TextPart,
28+
ThinkingPart,
29+
)
4030

4131
if TYPE_CHECKING:
4232
from typing import Any, Dict, List, Optional

sentry_sdk/integrations/pydantic_ai/_run_context.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,18 @@ def get_is_streaming() -> bool:
4646
@contextmanager
4747
def agent_run_scope(agent: "Any", is_streaming: bool = False) -> "Iterator[AgentRun]":
4848
"""Track an agent run on the contextvar stack for the duration of the
49-
with block."""
49+
with block.
50+
51+
On exit, exactly this run is removed from the stack (by identity, not a
52+
token reset), so streaming runs that exit out of LIFO order or in a
53+
different asyncio task never erase other still-active runs.
54+
"""
5055
run = AgentRun(agent=agent, is_streaming=is_streaming)
51-
token = _agent_run_stack.set(_agent_run_stack.get() + (run,))
56+
_agent_run_stack.set(_agent_run_stack.get() + (run,))
5257
try:
5358
yield run
5459
finally:
55-
try:
56-
_agent_run_stack.reset(token)
57-
except (LookupError, ValueError):
58-
# A streaming run's context manager can be exited in a different
59-
# asyncio task (and therefore a different Context) than it was
60-
# entered in, in which case the token cannot be reset. The stack
61-
# entry only lives in the entering task's context copy, so there
62-
# is nothing to clean up.
63-
pass
60+
stack = _agent_run_stack.get()
61+
new_stack = tuple(r for r in stack if r is not run)
62+
if len(new_stack) != len(stack):
63+
_agent_run_stack.set(new_stack)

0 commit comments

Comments
 (0)