Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9f4b902
Fix capability schema generation to include full parameter types
DouweM Mar 26, 2026
257a750
Move WebSearch.from_spec after __init__, add spec-loading tests for c…
DouweM Mar 26, 2026
995e23f
Skip MCP spec test when mcp package is not installed
DouweM Mar 26, 2026
762df2f
Auto-filter non-serializable types from __init__ for schema generation
DouweM Mar 26, 2026
3094803
Auto-filter non-serializable types from __init__ for schema generation
DouweM Mar 26, 2026
1f3fa6d
Address review: precise builtin types, imports, schema title, dedup r…
DouweM Mar 27, 2026
f474a64
Address bot review: narrow exception, simplify condition, test empty …
DouweM Mar 27, 2026
1312568
Address review: always filter types, remove MCP from_spec, clean up
DouweM Mar 27, 2026
86c59d7
Make CapabilitySpec a distinct subclass for schema-aware nested capab…
DouweM Mar 27, 2026
5b221d3
Skip schema snapshot test when mcp package is not installed
DouweM Mar 27, 2026
e734712
Use ModelSettings TypedDict for model_settings in schema
DouweM Mar 27, 2026
6b638e7
Merge main into agentspec-capabilities-schema
DouweM Mar 27, 2026
60156b3
Ignore coolname codecs.open() DeprecationWarning on Python 3.14
DouweM Mar 27, 2026
983ddfd
Fix deserialized CapabilitySpec items being NamedSpec instances
DouweM Mar 27, 2026
46e25b1
Add pragma: no cover to mcp optional import fallback
DouweM Mar 27, 2026
e8ec85b
Export AgentSpec and ModelRequestContext from top-level, fix doc imports
DouweM Mar 27, 2026
ff2d812
Fix pragma: use lax no cover for optional mcp import fallback
DouweM Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion pydantic_ai_slim/pydantic_ai/_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from __future__ import annotations

import inspect
import types # used at runtime in filter_serializable_type
import typing # used at runtime in filter_serializable_type
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need these comments

from collections.abc import Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast

Expand Down Expand Up @@ -224,17 +226,54 @@ def load_from_registry(
raise ValueError(f'Failed to instantiate {label} {spec.name!r}{detail}: {e}') from e


def filter_serializable_type(tp: Any) -> Any | None:
"""Filter a type to only include members that can be represented in JSON schema.

For Union types, removes non-serializable members (TypeVars, Callables).
Returns None if the type is entirely non-serializable.
"""
# TypeVar is not serializable
if isinstance(tp, TypeVar):
return None

origin = typing.get_origin(tp)

# Callable is not serializable
if origin is Callable:
return None

# Union: filter members
if origin is typing.Union or isinstance(tp, types.UnionType):
args = typing.get_args(tp)
filtered = [fa for a in args if (fa := filter_serializable_type(a)) is not None]
if not filtered:
return None
if len(filtered) == 1:
return filtered[0]
return typing.Union[tuple(filtered)] # noqa: UP007

# Other generics (list[X], dict[X, Y]): all args must be serializable
args = typing.get_args(tp)
if args and any(filter_serializable_type(a) is None for a in args):
return None

return tp


def build_schema_types(
registry: Mapping[str, type[Any]],
*,
get_schema_target: Callable[[type[Any]], Any] | None = None,
filter_type_hint: Callable[[Any], Any | None] | None = None,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need an arg? I think we could just always do this -- we know that if it's not serializable, we'd fail here right?

) -> list[Any]:
"""Build a list of schema types from a registry for JSON schema generation.

Args:
registry: Mapping from names to classes.
get_schema_target: Optional callback to get the schema target (e.g. `from_spec` method)
from a class. Default: use the class itself.
filter_type_hint: Optional callback to filter type hints. Called on each resolved type hint;
return the (possibly modified) type, or None to exclude the parameter.

Returns:
A list of types suitable for use in a Union for JSON schema generation.
Expand All @@ -244,13 +283,24 @@ def build_schema_types(
target = get_schema_target(cls) if get_schema_target is not None else cls
type_hints = get_function_type_hints(target)
type_hints.pop('return', None)

# Apply type filtering if provided
if filter_type_hint is not None:
type_hints = {k: fv for k, v in type_hints.items() if (fv := filter_type_hint(v)) is not None}

required_type_hints: dict[str, Any] = {}

for p in inspect.signature(target).parameters.values():
# Skip *args and **kwargs — they can't be represented as typed dict fields
# Skip self/cls (unbound instance/class methods) and *args/**kwargs
if p.name in ('self', 'cls') and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
type_hints.pop(p.name, None)
continue
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
type_hints.pop(p.name, None)
continue
# When filtering, skip params whose type was entirely filtered out
if filter_type_hint is not None and p.name not in type_hints:
continue
type_hints.setdefault(p.name, Any)
if p.default is not p.empty:
type_hints[p.name] = NotRequired[type_hints[p.name]]
Expand Down
20 changes: 18 additions & 2 deletions pydantic_ai_slim/pydantic_ai/agent/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
from pydantic_core.core_schema import SerializationInfo, SerializerFunctionWrapHandler

from pydantic_ai._agent_graph import EndStrategy
from pydantic_ai._spec import NamedSpec, build_registry, build_schema_types
from pydantic_ai._spec import NamedSpec, build_registry, build_schema_types, filter_serializable_type
from pydantic_ai._template import TemplateStr
from pydantic_ai._utils import get_function_type_hints

if TYPE_CHECKING:
from pydantic_ai.capabilities.abstract import AbstractCapability
Expand Down Expand Up @@ -211,6 +212,7 @@ class _AgentSpecSchema(BaseModel, extra='forbid'):
capabilities: list[Union[tuple(capability_schema_types)]] = [] # pyright: ignore # noqa: UP007

json_schema = _AgentSpecSchema.model_json_schema()
json_schema['title'] = 'AgentSpec'
json_schema['properties']['$schema'] = {'type': 'string'}
return json_schema

Expand Down Expand Up @@ -325,7 +327,21 @@ def load_capability_from_nested_spec(spec: dict[str, Any] | str) -> AbstractCapa

def _build_capability_schema_types(registry: Mapping[str, type[Any]]) -> list[Any]:
"""Build a list of schema types for capabilities from a registry."""

def _get_schema_target(cls: type[Any]) -> Any:
# When from_spec is not overridden, it delegates to cls(*args, **kwargs).
# Use __init__ directly so build_schema_types sees the actual parameter types.
# Fall back to from_spec if __init__ hints can't be resolved (e.g. TYPE_CHECKING imports).
if 'from_spec' not in cls.__dict__:
try:
get_function_type_hints(cls.__init__)
return cls.__init__
except (NameError, TypeError, AttributeError):
pass
return cls.from_spec
Comment on lines +338 to +348
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Schema precision varies by Python version and MCP installation status

The combination of _get_schema_target fallback logic (pydantic_ai_slim/pydantic_ai/agent/spec.py:331-341) and the MCP try/except import (pydantic_ai_slim/pydantic_ai/capabilities/mcp.py:15-21) means the generated JSON schema for MCP capabilities will differ depending on the environment:

  • MCP installed (any Python): Full schema with all typed parameters from __init__
  • MCP not installed, Python 3.11+: Schema with Any-typed MCP-specific params (degraded but functional)
  • MCP not installed, Python 3.10: Falls back to from_spec → only literal name form in schema (minimal)

This is by design but worth documenting, as users generating schemas in different environments could get inconsistent results.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return build_schema_types(
registry,
get_schema_target=lambda cls: cls.from_spec,
get_schema_target=_get_schema_target,
filter_type_hint=filter_serializable_type,
)
8 changes: 5 additions & 3 deletions pydantic_ai_slim/pydantic_ai/capabilities/image_generation.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Literal

from pydantic_ai.builtin_tools import ImageGenerationTool
from pydantic_ai.tools import AgentBuiltinTool, AgentDepsT, Tool
from pydantic_ai.tools import AgentDepsT, RunContext, Tool

from .builtin_or_local import BuiltinOrLocalTool

Expand All @@ -21,7 +21,9 @@ class ImageGeneration(BuiltinOrLocalTool[AgentDepsT]):
def __init__(
self,
*,
builtin: ImageGenerationTool | AgentBuiltinTool[AgentDepsT] | bool = True,
builtin: ImageGenerationTool
| Callable[[RunContext[AgentDepsT]], Awaitable[ImageGenerationTool | None] | ImageGenerationTool | None]
| bool = True,
local: Tool[AgentDepsT] | Callable[..., Any] | Literal[False] | None = None,
) -> None:
self.builtin = builtin
Expand Down
32 changes: 29 additions & 3 deletions pydantic_ai_slim/pydantic_ai/capabilities/mcp.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urlparse

from pydantic_ai.builtin_tools import MCPServerTool
from pydantic_ai.tools import AgentBuiltinTool, AgentDepsT, Tool
from pydantic_ai.tools import AgentDepsT, RunContext, Tool
from pydantic_ai.toolsets import AbstractToolset

from .builtin_or_local import BuiltinOrLocalTool
Expand Down Expand Up @@ -47,7 +47,9 @@ def __init__(
self,
url: str,
*,
builtin: MCPServerTool | AgentBuiltinTool[AgentDepsT] | bool = True,
builtin: MCPServerTool
| Callable[[RunContext[AgentDepsT]], Awaitable[MCPServerTool | None] | MCPServerTool | None]
| bool = True,
local: MCPServer | FastMCPToolset[AgentDepsT] | Callable[..., Any] | Literal[False] | None = None,
id: str | None = None,
authorization_token: str | None = None,
Expand All @@ -65,6 +67,30 @@ def __init__(
self.description = description
self.__post_init__()

@classmethod
def from_spec(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this method? Now that we handle unserializable type hint scorrectly

cls,
url: str,
*,
builtin: MCPServerTool | bool = True,
local: Literal[False] | None = None,
id: str | None = None,
authorization_token: str | None = None,
headers: dict[str, str] | None = None,
allowed_tools: list[str] | None = None,
description: str | None = None,
) -> MCP[Any]:
return cls(
url=url,
builtin=builtin,
local=local,
id=id,
authorization_token=authorization_token,
headers=headers,
allowed_tools=allowed_tools,
description=description,
)

@cached_property
def _resolved_id(self) -> str:
if self.id:
Expand Down
8 changes: 5 additions & 3 deletions pydantic_ai_slim/pydantic_ai/capabilities/web_fetch.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Literal

from pydantic_ai.builtin_tools import WebFetchTool
from pydantic_ai.tools import AgentBuiltinTool, AgentDepsT, Tool
from pydantic_ai.tools import AgentDepsT, RunContext, Tool

from .builtin_or_local import BuiltinOrLocalTool

Expand Down Expand Up @@ -36,7 +36,9 @@ class WebFetch(BuiltinOrLocalTool[AgentDepsT]):
def __init__(
self,
*,
builtin: WebFetchTool | AgentBuiltinTool[AgentDepsT] | bool = True,
builtin: WebFetchTool
| Callable[[RunContext[AgentDepsT]], Awaitable[WebFetchTool | None] | WebFetchTool | None]
| bool = True,
local: Tool[AgentDepsT] | Callable[..., Any] | Literal[False] | None = None,
allowed_domains: list[str] | None = None,
blocked_domains: list[str] | None = None,
Expand Down
8 changes: 5 additions & 3 deletions pydantic_ai_slim/pydantic_ai/capabilities/web_search.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Literal

from pydantic_ai.builtin_tools import WebSearchTool, WebSearchUserLocation
from pydantic_ai.tools import AgentBuiltinTool, AgentDepsT, Tool
from pydantic_ai.tools import AgentDepsT, RunContext, Tool
from pydantic_ai.toolsets import AbstractToolset

from .builtin_or_local import BuiltinOrLocalTool
Expand Down Expand Up @@ -37,7 +37,9 @@ class WebSearch(BuiltinOrLocalTool[AgentDepsT]):
def __init__(
self,
*,
builtin: WebSearchTool | AgentBuiltinTool[AgentDepsT] | bool = True,
builtin: WebSearchTool
| Callable[[RunContext[AgentDepsT]], Awaitable[WebSearchTool | None] | WebSearchTool | None]
| bool = True,
local: Tool[AgentDepsT] | Callable[..., Any] | Literal[False] | None = None,
search_context_size: Literal['low', 'medium', 'high'] | None = None,
user_location: WebSearchUserLocation | None = None,
Expand Down
Loading
Loading