-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Expand file tree
/
Copy pathspec.py
More file actions
348 lines (291 loc) · 13.8 KB
/
spec.py
File metadata and controls
348 lines (291 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
"""Agent specification for constructing agents from YAML/JSON/dict specs."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from contextvars import ContextVar
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union, cast
from pydantic import BaseModel, Field, model_serializer
from pydantic_core import from_json, to_json
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._template import TemplateStr
if TYPE_CHECKING:
from pydantic_ai.capabilities.abstract import AbstractCapability
CapabilitySpec = NamedSpec
"""The specification of a capability to be constructed.
Supports the same short forms as `EvaluatorSpec`:
* `'MyCapability'` — no arguments
* `{'MyCapability': single_arg}` — a single positional argument
* `{'MyCapability': {k1: v1, k2: v2}}` — keyword arguments
"""
DEFAULT_SCHEMA_PATH_TEMPLATE = './{stem}_schema.json'
"""Default template for schema file paths, where {stem} is replaced with the spec filename stem."""
_YAML_SCHEMA_LINE_PREFIX = '# yaml-language-server: $schema='
class AgentSpec(BaseModel):
"""Specification for constructing an Agent from a dict/YAML/JSON."""
# $schema is included to avoid validation fails from the `$schema` key, see `_add_json_schema` below for context
json_schema_path: str | None = Field(default=None, alias='$schema')
model: str | None = None
name: str | None = None
description: TemplateStr[Any] | str | None = None
instructions: TemplateStr[Any] | str | list[TemplateStr[Any] | str] | None = None
deps_schema: dict[str, Any] | None = None
output_schema: dict[str, Any] | None = None
model_settings: dict[str, Any] | None = None
retries: int = 1
output_retries: int | None = None
end_strategy: EndStrategy = 'early'
tool_timeout: float | None = None
instrument: bool | None = None
metadata: dict[str, Any] | None = None
capabilities: list[CapabilitySpec] = []
@classmethod
def from_file(
cls,
path: Path | str,
fmt: Literal['yaml', 'json'] | None = None,
) -> AgentSpec:
"""Load an agent spec from a YAML or JSON file.
Args:
path: Path to the file to load.
fmt: Format of the file. If None, inferred from file extension.
Returns:
A new AgentSpec instance.
"""
path = Path(path)
fmt = _infer_fmt(path, fmt)
content = path.read_text(encoding='utf-8')
return cls.from_text(content, fmt=fmt)
@classmethod
def from_text(
cls,
text: str,
fmt: Literal['yaml', 'json'] = 'yaml',
) -> AgentSpec:
"""Parse YAML or JSON text into an AgentSpec.
Args:
text: The string content to parse.
fmt: Format of the content. Must be either 'yaml' or 'json'.
Returns:
A new AgentSpec instance.
"""
if fmt == 'json':
data = from_json(text)
else:
try:
import yaml
except ImportError: # pragma: no cover — requires PyYAML to not be installed
raise ImportError(
'PyYAML is required to load YAML agent specs. Install it with: pip install "pydantic-ai-slim[spec]"'
) from None
data = yaml.safe_load(text)
return cls.from_dict(data)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AgentSpec:
"""Validate a dictionary into an AgentSpec.
Args:
data: Dictionary representation of the agent spec.
Returns:
A new AgentSpec instance.
"""
return cls.model_validate(data)
def to_file(
self,
path: Path | str,
fmt: Literal['yaml', 'json'] | None = None,
schema_path: Path | str | None = DEFAULT_SCHEMA_PATH_TEMPLATE,
custom_capability_types: Sequence[type[AbstractCapability[Any]]] = (),
) -> None:
"""Save the agent spec to a YAML or JSON file.
Args:
path: Path to save the spec to.
fmt: Format to use. If None, inferred from file extension.
schema_path: Path to save the JSON schema to. If None, no schema will be saved.
Can be a string template with {stem} which will be replaced with the spec filename stem.
custom_capability_types: Custom capability classes to include in the schema.
"""
path = Path(path)
fmt = _infer_fmt(path, fmt)
schema_ref: str | None = None
if schema_path is not None:
if isinstance(schema_path, str):
schema_path = Path(schema_path.format(stem=path.stem))
if not schema_path.is_absolute():
schema_ref = str(schema_path)
schema_path = path.parent / schema_path
else: # pragma: no cover
schema_ref = str(schema_path)
self._save_schema(schema_path, custom_capability_types)
context: dict[str, Any] = {'use_short_form': True}
if fmt == 'yaml':
try:
import yaml
except ImportError: # pragma: no cover — requires PyYAML to not be installed
raise ImportError(
'PyYAML is required to save YAML agent specs. Install it with: pip install "pydantic-ai-slim[spec]"'
) from None
dumped_data = self.model_dump(mode='json', by_alias=True, context=context, exclude_defaults=True)
content = yaml.dump(dumped_data, sort_keys=False)
if schema_ref:
content = f'{_YAML_SCHEMA_LINE_PREFIX}{schema_ref}\n{content}'
path.write_text(content, encoding='utf-8')
else:
context['$schema'] = schema_ref
json_data = self.model_dump_json(indent=2, by_alias=True, context=context, exclude_defaults=True)
path.write_text(json_data + '\n', encoding='utf-8')
@model_serializer(mode='wrap')
def _add_json_schema(self, nxt: SerializerFunctionWrapHandler, info: SerializationInfo) -> dict[str, Any]:
"""Add the JSON schema path to the serialized output when provided via context."""
context = cast(dict[str, Any] | None, info.context)
if isinstance(context, dict) and (schema := context.get('$schema')):
return {'$schema': schema} | nxt(self)
return nxt(self)
@classmethod
def model_json_schema_with_capabilities(
cls,
custom_capability_types: Sequence[type[AbstractCapability[Any]]] = (),
) -> dict[str, Any]:
"""Generate a JSON schema for this agent spec type, including capability details.
This is useful for generating a schema that can be used to validate YAML-format agent spec files.
Args:
custom_capability_types: Custom capability classes to include in the schema.
Returns:
A dictionary representing the JSON schema.
"""
capability_schema_types = _build_capability_schema_types(get_capability_registry(custom_capability_types))
# Build a schema-only model with the resolved capability union.
# NOTE: This duplicates the field list from AgentSpec above. We can't inherit from
# AgentSpec because the types intentionally differ for schema generation:
# - TemplateStr is replaced with plain str (templates are just strings in YAML/JSON)
# - capabilities uses a resolved Union of typed schema models instead of CapabilitySpec
# - extra='forbid' enables strict validation in the generated schema
# When adding or removing fields on AgentSpec, update this class to match.
class _AgentSpecSchema(BaseModel, extra='forbid'):
model: str | None = None
name: str | None = None
description: str | None = None
instructions: str | list[str] | None = None
deps_schema: dict[str, Any] | None = None
output_schema: dict[str, Any] | None = None
model_settings: dict[str, Any] | None = None
retries: int = 1
output_retries: int | None = None
end_strategy: EndStrategy = 'early'
tool_timeout: float | None = None
instrument: bool | None = None
metadata: dict[str, Any] | None = None
if capability_schema_types: # pragma: no branch
capabilities: list[Union[tuple(capability_schema_types)]] = [] # pyright: ignore # noqa: UP007
json_schema = _AgentSpecSchema.model_json_schema()
json_schema['properties']['$schema'] = {'type': 'string'}
return json_schema
@classmethod
def _save_schema(
cls,
path: Path | str,
custom_capability_types: Sequence[type[AbstractCapability[Any]]] = (),
) -> None:
"""Save the JSON schema for this agent spec type to a file.
Args:
path: Path to save the schema to.
custom_capability_types: Custom capability classes to include in the schema.
"""
path = Path(path)
json_schema = cls.model_json_schema_with_capabilities(custom_capability_types)
schema_content = to_json(json_schema, indent=2).decode() + '\n'
if not path.exists() or path.read_text(encoding='utf-8') != schema_content:
path.write_text(schema_content, encoding='utf-8')
def _infer_fmt(path: Path, fmt: Literal['yaml', 'json'] | None) -> Literal['yaml', 'json']:
"""Infer the format to use for a file based on its extension."""
if fmt is not None:
return fmt
suffix = path.suffix.lower()
if suffix in {'.yaml', '.yml'}:
return 'yaml'
elif suffix == '.json':
return 'json'
raise ValueError(
f'Could not infer format for filename {path.name!r}. Use the `fmt` argument to specify the format.'
)
def get_capability_registry(
custom_types: Sequence[type[AbstractCapability[Any]]] = (),
) -> Mapping[str, type[AbstractCapability[Any]]]:
"""Create a registry of capability types from default and custom types."""
from pydantic_ai.capabilities import CAPABILITY_TYPES
from pydantic_ai.capabilities.abstract import AbstractCapability
def _validate_capability(cls: type[AbstractCapability[Any]]) -> None:
if not issubclass(cls, AbstractCapability):
raise ValueError(
f'All custom capability classes must be subclasses of AbstractCapability, but {cls} is not'
)
if '__dataclass_fields__' not in cls.__dict__:
raise ValueError(f'All custom capability classes must be decorated with `@dataclass`, but {cls} is not')
return build_registry(
custom_types=custom_types,
defaults=tuple(CAPABILITY_TYPES.values()),
get_name=lambda cls: cls.get_serialization_name(),
label='capability',
validate=_validate_capability,
)
class CapabilitySpecContext:
"""Holds the registry and instantiation callback for the current spec-loading scope."""
__slots__ = ('registry', 'instantiate')
def __init__(
self,
registry: Mapping[str, type[AbstractCapability[Any]]],
instantiate: Callable[
[type[AbstractCapability[Any]], tuple[Any, ...], dict[str, Any]], AbstractCapability[Any]
],
) -> None:
self.registry = registry
self.instantiate = instantiate
capability_spec_context: ContextVar[CapabilitySpecContext | None] = ContextVar('capability_spec_context', default=None)
def load_capability_from_nested_spec(spec: dict[str, Any] | str) -> AbstractCapability[Any]:
"""Load a capability from a nested spec, reusing the current spec-loading context.
When called inside `Agent.from_spec()` or `Agent._resolve_spec()`, this uses the same
registry (including custom capability types) and template context as the outer loading.
When called outside a spec-loading context, falls back to the default registry.
This is intended for use in `from_spec()` methods of wrapper capabilities like
[`PrefixTools`][pydantic_ai.capabilities.PrefixTools] that need to instantiate
a nested capability from a spec argument.
"""
from pydantic_ai._spec import NamedSpec, load_from_registry
cap_spec = NamedSpec.model_validate(spec)
ctx = capability_spec_context.get()
if ctx is not None:
return load_from_registry(
ctx.registry,
cap_spec,
label='capability',
custom_types_param='custom_capability_types',
instantiate=ctx.instantiate,
)
else:
return load_from_registry(
get_capability_registry(),
cap_spec,
label='capability',
custom_types_param='custom_capability_types',
instantiate=lambda cap_cls, args, kwargs: cap_cls.from_spec(*args, **kwargs),
)
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
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:
pass
return cls.from_spec
import pydantic_ai._spec
return build_schema_types(
registry,
get_schema_target=_get_schema_target,
filter_type_hint=pydantic_ai._spec.filter_serializable_type,
)