Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
210 changes: 206 additions & 4 deletions pydantic_ai_slim/pydantic_ai/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@
NoneType = type(None)


@dataclasses.dataclass
class _ResolvedSpec:
"""Result of resolving an AgentSpec for use at run/override time."""

spec: AgentSpec
capability: CombinedCapability[Any] | None
instructions: list[Any]
Copy link
Contributor

Choose a reason for hiding this comment

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

instructions: list[Any] is too loose — the type should match what _instructions.normalize_instructions() returns. Per the coding guidelines, avoid Any type annotations; use the actual type for precision.

model: str | None
model_settings: ModelSettings | None
metadata: dict[str, Any] | None
name: str | None


@dataclasses.dataclass(init=False)
class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
"""Class for defining "agents" - a way to have a specific type of "conversation" with an LLM.
Expand Down Expand Up @@ -472,6 +485,12 @@ def __init__(
self._override_model_settings: ContextVar[_utils.Option[AgentModelSettings[AgentDepsT]]] = ContextVar(
'_override_model_settings', default=None
)
self._override_root_capability: ContextVar[_utils.Option[CombinedCapability[AgentDepsT]]] = ContextVar(
'_override_root_capability', default=None
)
self._override_builtin_tools: ContextVar[
_utils.Option[list[AbstractBuiltinTool | _utils.Callable[..., Any]]]
] = ContextVar('_override_builtin_tools', default=None)

self._enter_lock = Lock()
self._entered_count = 0
Expand Down Expand Up @@ -674,8 +693,14 @@ def _instantiate_cap(
if capabilities:
all_capabilities.extend(capabilities)

effective_model = model or validated_spec.model
if effective_model is None:
raise exceptions.UserError(
'`model` must be provided either in the spec or as a keyword argument to `from_spec()`.'
)

return Agent(
model=model or validated_spec.model,
model=effective_model,
output_type=effective_output_type,
instructions=merged_instructions or None,
system_prompt=system_prompt,
Expand Down Expand Up @@ -881,6 +906,7 @@ def iter(
infer_name: bool = True,
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
builtin_tools: Sequence[AbstractBuiltinTool | BuiltinToolFunc[AgentDepsT]] | None = None,
spec: dict[str, Any] | AgentSpec | None = None,
) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, OutputDataT]]: ...

@overload
Expand All @@ -901,6 +927,7 @@ def iter(
infer_name: bool = True,
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
builtin_tools: Sequence[AbstractBuiltinTool | BuiltinToolFunc[AgentDepsT]] | None = None,
spec: dict[str, Any] | AgentSpec | None = None,
) -> AbstractAsyncContextManager[AgentRun[AgentDepsT, RunOutputDataT]]: ...

@asynccontextmanager
Expand All @@ -921,6 +948,7 @@ async def iter( # noqa: C901
infer_name: bool = True,
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
builtin_tools: Sequence[AbstractBuiltinTool | BuiltinToolFunc[AgentDepsT]] | None = None,
spec: dict[str, Any] | AgentSpec | None = None,
) -> AsyncIterator[AgentRun[AgentDepsT, Any]]:
"""A contextmanager which can be used to iterate over the agent graph's nodes as they are executed.

Expand Down Expand Up @@ -995,13 +1023,49 @@ async def main():
infer_name: Whether to try to infer the agent name from the call frame if it's not set.
toolsets: Optional additional toolsets for this run.
builtin_tools: Optional additional builtin tools for this run.
spec: Optional agent spec to apply for this run. At run time, spec values are additive.

Returns:
The result of the run.
"""
if infer_name and self.name is None:
self._infer_name(inspect.currentframe())

# Resolve spec contributions (additive at run time)
resolved = self._resolve_spec(spec)
if resolved is not None:
# Model: spec as fallback (run param > spec > agent)
if model is None and resolved.model is not None:
model = resolved.model
# Instructions: spec instructions are additional
if resolved.instructions:
extra = resolved.instructions
if instructions is not None:
existing = _instructions.normalize_instructions(instructions)
existing.extend(extra)
instructions = existing
else:
instructions = extra
# Model settings: merge spec settings under run settings (only static dicts)
if resolved.model_settings is not None:
if model_settings is None or not callable(model_settings):
model_settings = merge_model_settings(resolved.model_settings, model_settings)
# If model_settings is a callable, spec model_settings are handled via the capability layer
# Metadata: merge spec metadata under run metadata
if resolved.metadata is not None:
if metadata is not None:
if callable(metadata):
_spec_meta = resolved.metadata

def _merged_meta(ctx: RunContext[AgentDepsT]) -> dict[str, Any]:
return {**(_spec_meta or {}), **metadata(ctx)} # type: ignore[operator]

metadata = _merged_meta
Copy link
Contributor

Choose a reason for hiding this comment

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

Devin correctly identified this: _merged_meta captures metadata by reference, and then metadata = _merged_meta on this line means the closure now recursively calls itself. The fix is to bind the original callable to a local variable before defining the closure:

_orig_metadata = metadata

def _merged_meta(ctx: RunContext[AgentDepsT]) -> dict[str, Any]:
    return {**(_spec_meta or {}), **_orig_metadata(ctx)}  # type: ignore[operator]

metadata = _merged_meta

else:
metadata = {**resolved.metadata, **metadata}
else:
metadata = resolved.metadata

model_used = self._get_model(model)
del model

Expand Down Expand Up @@ -1061,15 +1125,32 @@ async def main():
run_step=0,
)

# Determine root capability: override > agent default
override_cap = self._override_root_capability.get()
base_capability = override_cap.value if override_cap is not None else self._root_capability

# Merge spec capability additively with base capability
if resolved is not None and resolved.capability is not None:
effective_capability = CombinedCapability([base_capability, resolved.capability])
else:
effective_capability = base_capability

# Per-run capability: re-extract get_*() if for_run returns a different instance
run_capability = await self._root_capability.for_run(initial_ctx)
run_capability = await effective_capability.for_run(initial_ctx)
cap_toolsets: list[AgentToolset[AgentDepsT]] | None
if run_capability is not self._root_capability:
if run_capability is not effective_capability:
cap_instructions = _instructions.normalize_instructions(run_capability.get_instructions())
cap_builtin_tools = list(run_capability.get_builtin_tools())
cap_model_settings = run_capability.get_model_settings()
cap_ts = run_capability.get_toolset()
cap_toolsets = [cap_ts] if cap_ts is not None else []
elif override_cap is not None or (resolved is not None and resolved.capability is not None):
# Re-extract from effective_capability since it differs from self._root_capability
cap_instructions = _instructions.normalize_instructions(effective_capability.get_instructions())
cap_builtin_tools = list(effective_capability.get_builtin_tools())
cap_model_settings = effective_capability.get_model_settings()
cap_ts = effective_capability.get_toolset()
cap_toolsets = [cap_ts] if cap_ts is not None else []
else:
cap_instructions = None # use init-time defaults
cap_builtin_tools = self._cap_builtin_tools
Expand Down Expand Up @@ -1144,7 +1225,12 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None:
output_validators=output_validators,
validation_context=self._validation_context,
root_capability=run_capability,
builtin_tools=[*self._builtin_tools, *cap_builtin_tools, *(builtin_tools or [])],
builtin_tools=[
*self._builtin_tools,
*cap_builtin_tools,
*(override_bt.value if (override_bt := self._override_builtin_tools.get()) is not None else []),
Copy link
Contributor

Choose a reason for hiding this comment

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

When override(spec=...) provides capabilities, the builtin tools from those capabilities will be included twice:

  1. cap_builtin_tools at line 1230: extracted from effective_capability at line 1150, which is the override capability (since override_cap is not None)
  2. override_bt.value at line 1231: set from the same override capability's get_builtin_tools() at line 1677

The _override_builtin_tools context var seems redundant with the existing cap_builtin_tools extraction that already handles the override case at lines 1147-1153. The simplest fix would be to remove _override_builtin_tools entirely and rely on the capability-based extraction.

*(builtin_tools or []),
],
tool_manager=tool_manager,
tracer=tracer,
get_instructions=get_instructions,
Expand Down Expand Up @@ -1410,6 +1496,92 @@ def _run_span_end_attributes(
),
}

def _resolve_spec(
self,
spec: dict[str, Any] | AgentSpec | None,
custom_capability_types: Sequence[type[AbstractCapability[Any]]] = (),
) -> _ResolvedSpec | None:
Copy link
Contributor

Choose a reason for hiding this comment

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

There's significant code duplication between _resolve_spec() and from_spec() — specifically the registry building, capability instantiation, and template context setup. The coding guidelines call for extracting duplicated logic into shared helpers after 2+ occurrences. Consider extracting the shared parts (registry building + _instantiate_cap + capability loading loop) into a helper that both from_spec() and _resolve_spec() can call.

"""Validate and instantiate capabilities from a spec, returning contributions.

Returns None if spec is None.
"""
if spec is None:
return None

from pydantic_ai._spec import build_registry, load_from_registry
from pydantic_ai._template import validate_from_spec_args
from pydantic_ai.agent.spec import AgentSpec as _AgentSpecModel
from pydantic_ai.capabilities import DEFAULT_CAPABILITY_TYPES

template_context: dict[str, Any] = {
'deps_type': self._deps_type if self._deps_type is not type(None) else None,
}
if isinstance(spec, dict):
validated_spec = _AgentSpecModel.model_validate(spec, context=template_context)
else:
validated_spec = spec
template_context['deps_schema'] = validated_spec.deps_schema

registry = build_registry(
custom_types=custom_capability_types,
defaults=DEFAULT_CAPABILITY_TYPES,
get_name=lambda c: c.get_serialization_name(),
label='capability',
)

def _instantiate_cap(
cap_cls: type[AbstractCapability[Any]],
args: tuple[Any, ...],
kwargs: dict[str, Any],
) -> AbstractCapability[Any]:
args, kwargs = validate_from_spec_args(cap_cls, args, kwargs, template_context)
return cap_cls.from_spec(*args, **kwargs)

capabilities: list[AbstractCapability[Any]] = []
for cap_spec in validated_spec.capabilities:
capability = load_from_registry(
registry,
cap_spec,
label='capability',
custom_types_param='custom_capability_types',
instantiate=_instantiate_cap,
)
capabilities.append(capability)

combined = CombinedCapability(capabilities) if capabilities else None

# Warn for unsupported fields with non-default values
_unsupported_fields = {
'end_strategy': 'early',
'retries': 1,
'output_retries': None,
'tool_timeout': None,
'output_schema': None,
'deps_schema': None,
}
Comment on lines +1496 to +1503
Copy link
Contributor

Choose a reason for hiding this comment

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

🚩 Spec fields description and instrument are silently ignored at run/override time

The _resolve_spec() method (pydantic_ai_slim/pydantic_ai/agent/__init__.py:1468-1525) captures model, name, instructions, model_settings, metadata, and capability from the spec into _ResolvedSpec. However, the description and instrument fields from AgentSpec are neither captured nor included in the _unsupported_fields warning dict (lines 1496-1503). If a user passes spec={'description': 'foo', 'instrument': True} at run/override time, these values are silently ignored without any warning. Other unsupported fields like end_strategy, retries, etc. correctly produce UserWarning. This inconsistency may confuse users.

Open in Devin Review

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

for field_name, default_val in _unsupported_fields.items():
val = getattr(validated_spec, field_name, default_val)
if val != default_val:
warnings.warn(
f'AgentSpec field {field_name!r} is not supported at run/override time and will be ignored',
UserWarning,
stacklevel=3,
)

return _ResolvedSpec(
spec=validated_spec,
capability=combined,
instructions=_instructions.normalize_instructions(validated_spec.instructions)
if validated_spec.instructions
else [],
model=validated_spec.model,
model_settings=cast(ModelSettings, validated_spec.model_settings)
if validated_spec.model_settings
else None,
metadata=validated_spec.metadata,
name=validated_spec.name,
)

@contextmanager
def override( # noqa: C901
self,
Expand All @@ -1422,6 +1594,7 @@ def override( # noqa: C901
instructions: _instructions.AgentInstructions[AgentDepsT] | _utils.Unset = _utils.UNSET,
metadata: AgentMetadata[AgentDepsT] | _utils.Unset = _utils.UNSET,
model_settings: AgentModelSettings[AgentDepsT] | _utils.Unset = _utils.UNSET,
spec: dict[str, Any] | AgentSpec | None = None,
) -> Iterator[None]:
"""Context manager to temporarily override agent name, dependencies, model, toolsets, tools, or instructions.

Expand All @@ -1439,7 +1612,23 @@ def override( # noqa: C901
per-run `metadata` argument is ignored.
model_settings: The model settings to use instead of the model settings passed to the agent constructor.
When set, any per-run `model_settings` argument is ignored.
spec: Optional agent spec providing defaults for override. Explicit params take precedence over spec values.
"""
resolved = self._resolve_spec(spec)

# Apply spec values as defaults where explicit params are not set
if resolved is not None:
if not _utils.is_set(name) and resolved.name is not None:
name = resolved.name
if not _utils.is_set(model) and resolved.model is not None:
model = resolved.model
if not _utils.is_set(instructions) and resolved.instructions:
instructions = resolved.instructions
if not _utils.is_set(model_settings) and resolved.model_settings is not None:
model_settings = resolved.model_settings
if not _utils.is_set(metadata) and resolved.metadata is not None:
metadata = resolved.metadata
Comment on lines +1561 to +1572
Copy link
Contributor

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

Choose a reason for hiding this comment

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

🚩 Semantic difference between iter(spec=...) and override(spec=...)

There's a notable design asymmetry in how spec is handled:

  • In iter() (pydantic_ai_slim/pydantic_ai/agent/__init__.py:1041-1048): instructions from spec are additive — they extend the existing instructions.
  • In override() (pydantic_ai_slim/pydantic_ai/agent/__init__.py:1625-1626): instructions from spec replace the agent's instructions (they're only applied if the explicit instructions param is unset, but when applied, they become the sole instructions override).

This is a meaningful behavioral difference that could confuse users. The same asymmetry applies to model settings and metadata. This may be intentional (override = replace, run = extend), but it's worth documenting explicitly.

Open in Devin Review

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


if _utils.is_set(name):
name_token = self._override_name.set(_utils.Some(name))
else:
Expand Down Expand Up @@ -1481,6 +1670,15 @@ def override( # noqa: C901
else:
model_settings_token = None

# Set capability and builtin_tools from spec
if resolved is not None and resolved.capability is not None:
cap_token = self._override_root_capability.set(_utils.Some(resolved.capability))
Copy link
Contributor

Choose a reason for hiding this comment

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

When override(spec=...) provides capabilities, _override_root_capability is set to ONLY the spec's CombinedCapability, which completely replaces the agent's original _root_capability (see line 1130: base_capability = override_cap.value if override_cap is not None else self._root_capability). The PR description says override uses "defaults" semantics, but for capabilities this is a full replacement — an agent constructed with capabilities=[Thinking()] loses Thinking when override(spec={'capabilities': ['Instructions']}) is used.

By contrast, the iter() path at lines 1133-1134 merges additively via CombinedCapability([base_capability, resolved.capability]). The two code paths should have consistent semantics — probably the override should also combine the agent's base capability with the spec capability, rather than replacing.

@DouweM is the replacement behavior intentional for override(), or should this be additive like at run time?

builtin_tools_from_cap = list(resolved.capability.get_builtin_tools())
bt_token = self._override_builtin_tools.set(_utils.Some(builtin_tools_from_cap))
else:
cap_token = None
bt_token = None

try:
yield
finally:
Expand All @@ -1500,6 +1698,10 @@ def override( # noqa: C901
self._override_metadata.reset(metadata_token)
if model_settings_token is not None:
self._override_model_settings.reset(model_settings_token)
if cap_token is not None:
self._override_root_capability.reset(cap_token)
if bt_token is not None:
self._override_builtin_tools.reset(bt_token)

@overload
def instructions(
Expand Down
Loading
Loading