Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
57 changes: 56 additions & 1 deletion pydantic_ai_slim/pydantic_ai/_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,17 +224,61 @@ 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.
"""
return _filter_serializable_type(tp)


def _filter_serializable_type(tp: object) -> object | None:
import types
import typing
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.

Imports at the top please


# 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: # pragma: no cover — requires union of only non-serializable types
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is testable: a Union[Callable[..., Any], TypeVar('T')] (or any union composed entirely of non-serializable types) would hit this branch. A small test for this edge case would be more reliable than a pragma and consistent with the test added for CapabilityWithCallbackParam.

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 not all(_filter_serializable_type(a) is not 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 +288,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
19 changes: 18 additions & 1 deletion pydantic_ai_slim/pydantic_ai/agent/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,24 @@ 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."""
from pydantic_ai._utils import get_function_type_hints
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.

Move imports to the top; this one and the one below


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 Exception:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bare except Exception is too broad here. The intent is to catch failures from unresolvable type hints (e.g. TYPE_CHECKING-guarded imports), which would raise NameError. Consider catching (NameError, TypeError) or at most (NameError, TypeError, AttributeError) to avoid silently swallowing unexpected errors like RuntimeError or KeyboardInterrupt (the latter is a BaseException, but the principle of catching specific exceptions still applies).

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.


import pydantic_ai._spec

return build_schema_types(
registry,
get_schema_target=lambda cls: cls.from_spec,
get_schema_target=_get_schema_target,
filter_type_hint=pydantic_ai._spec.filter_serializable_type,
)
24 changes: 24 additions & 0 deletions pydantic_ai_slim/pydantic_ai/capabilities/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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
Loading
Loading