agentenv-framework-protocol 0.1.269__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentenv_framework_protocol-0.1.269.dist-info/METADATA +599 -0
- agentenv_framework_protocol-0.1.269.dist-info/RECORD +20 -0
- agentenv_framework_protocol-0.1.269.dist-info/WHEEL +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/LICENSE +202 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/NOTICE +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/THIRD_PARTY_NOTICES.md +1701 -0
- agentenv_protocol/__init__.py +121 -0
- agentenv_protocol/a2a_agent/__init__.py +204 -0
- agentenv_protocol/a2a_agent/_triggers.py +489 -0
- agentenv_protocol/a2a_agent/extensions.py +1151 -0
- agentenv_protocol/a2a_agent/framework.py +1283 -0
- agentenv_protocol/a2a_agent/registry.py +449 -0
- agentenv_protocol/a2a_agent/tasks/__init__.py +39 -0
- agentenv_protocol/a2a_agent/tasks/v1.py +408 -0
- agentenv_protocol/agent_env_environment.py +653 -0
- agentenv_protocol/client.py +185 -0
- agentenv_protocol/manifest.py +203 -0
- agentenv_protocol/preflight.py +81 -0
- agentenv_protocol/transfers.py +554 -0
- agentenv_protocol/types.py +165 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
"""Canonical AgentEnv A2A extension definitions and handler decorators.
|
|
2
|
+
|
|
3
|
+
Extension contracts live here once. Agent classes bind runtime behavior to a
|
|
4
|
+
known operation or request variant; they never repeat URIs, routes, or schemas.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import Annotated, Any
|
|
14
|
+
|
|
15
|
+
from pydantic import (
|
|
16
|
+
BaseModel,
|
|
17
|
+
ConfigDict,
|
|
18
|
+
Field,
|
|
19
|
+
PositiveInt,
|
|
20
|
+
ValidationError,
|
|
21
|
+
model_validator,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ._triggers import MAX_SOLVER_MESSAGE_LENGTH
|
|
25
|
+
from .tasks.v1 import AgentConfig
|
|
26
|
+
from ..transfers import (
|
|
27
|
+
ReadObject,
|
|
28
|
+
RelativePath,
|
|
29
|
+
Uploaded,
|
|
30
|
+
WriteNamespaceGrant,
|
|
31
|
+
WriteObject,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ImplementationOwner(str, Enum):
|
|
36
|
+
SDK = "sdk"
|
|
37
|
+
RUNTIME = "runtime"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExtensionRequest(BaseModel):
|
|
41
|
+
"""Base class for validated extension request bodies."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExtensionResponse(BaseModel):
|
|
47
|
+
"""Base class for validated extension response bodies."""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SkillName = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class McpAddRequest(ExtensionRequest):
|
|
56
|
+
url: str
|
|
57
|
+
headers: dict[str, str] | None = None
|
|
58
|
+
name: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InlineSkillRequest(ExtensionRequest):
|
|
62
|
+
name: _SkillName
|
|
63
|
+
description: str
|
|
64
|
+
skill_md: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SkillBundleFile(ExtensionRequest):
|
|
68
|
+
path: RelativePath
|
|
69
|
+
object: ReadObject
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SkillBundle(ExtensionRequest):
|
|
73
|
+
max_total_bytes: PositiveInt
|
|
74
|
+
files: list[SkillBundleFile]
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def _valid_bundle(self) -> SkillBundle:
|
|
78
|
+
paths = [item.path for item in self.files]
|
|
79
|
+
if len(paths) != len(set(paths)):
|
|
80
|
+
raise ValueError("skill bundle paths must be unique")
|
|
81
|
+
if "SKILL.md" not in paths:
|
|
82
|
+
raise ValueError("skill bundle must contain root SKILL.md")
|
|
83
|
+
maximum_size = sum(item.object.max_bytes for item in self.files)
|
|
84
|
+
if maximum_size > self.max_total_bytes:
|
|
85
|
+
raise ValueError("skill bundle file limits exceed max_total_bytes")
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class BundleSkillRequest(ExtensionRequest):
|
|
90
|
+
name: _SkillName
|
|
91
|
+
description: str
|
|
92
|
+
skill_bundle: SkillBundle
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SkillAddResponse(ExtensionResponse):
|
|
96
|
+
name: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TaskTrajectoryRequest(ExtensionRequest):
|
|
100
|
+
task_id: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ContextTrajectoryRequest(ExtensionRequest):
|
|
104
|
+
context_id: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TrajectoryWriteObjects(ExtensionRequest):
|
|
108
|
+
trajectory: WriteObject
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="after")
|
|
111
|
+
def _json_trajectory(self) -> TrajectoryWriteObjects:
|
|
112
|
+
if self.trajectory.media_type != "application/json":
|
|
113
|
+
raise ValueError("trajectory object media_type must be application/json")
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TaskObjectTrajectoryRequest(ExtensionRequest):
|
|
118
|
+
task_id: str
|
|
119
|
+
objects: TrajectoryWriteObjects
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ContextObjectTrajectoryRequest(ExtensionRequest):
|
|
123
|
+
context_id: str
|
|
124
|
+
objects: TrajectoryWriteObjects
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TrajectoryUploadedObjects(ExtensionResponse):
|
|
128
|
+
trajectory: Uploaded
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TrajectoryObjectsResponse(ExtensionResponse):
|
|
132
|
+
objects: TrajectoryUploadedObjects
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _opaque_snapshot_objects(
|
|
136
|
+
objects: SnapshotWriteObjects | SnapshotReadObjects,
|
|
137
|
+
) -> SnapshotWriteObjects | SnapshotReadObjects:
|
|
138
|
+
for name in ("trajectory", "workspace"):
|
|
139
|
+
descriptor = getattr(objects, name)
|
|
140
|
+
if descriptor is not None and descriptor.media_type != "application/octet-stream":
|
|
141
|
+
raise ValueError(f"snapshot {name} media_type must be application/octet-stream")
|
|
142
|
+
return objects
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SnapshotWriteObjects(ExtensionRequest):
|
|
146
|
+
trajectory: WriteObject
|
|
147
|
+
workspace: WriteObject | None = None
|
|
148
|
+
|
|
149
|
+
_opaque_media_types = model_validator(mode="after")(_opaque_snapshot_objects)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ObjectSnapshotSaveRequest(ExtensionRequest):
|
|
153
|
+
context_id: str
|
|
154
|
+
objects: SnapshotWriteObjects
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SnapshotReadObjects(ExtensionRequest):
|
|
158
|
+
trajectory: ReadObject
|
|
159
|
+
workspace: ReadObject | None = None
|
|
160
|
+
|
|
161
|
+
_opaque_media_types = model_validator(mode="after")(_opaque_snapshot_objects)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ObjectSnapshotLoadRequest(ExtensionRequest):
|
|
165
|
+
objects: SnapshotReadObjects
|
|
166
|
+
target_context_id: str | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SnapshotUploadedObjects(ExtensionResponse):
|
|
170
|
+
trajectory: Uploaded
|
|
171
|
+
workspace: Uploaded | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ObjectSnapshotSaveResponse(ExtensionResponse):
|
|
175
|
+
context_id: str
|
|
176
|
+
objects: SnapshotUploadedObjects
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ObjectSnapshotLoadResponse(ExtensionResponse):
|
|
180
|
+
context_id: str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class NamespaceChangelogEnableRequest(ExtensionRequest):
|
|
184
|
+
write_namespace: WriteNamespaceGrant
|
|
185
|
+
roots: list[str] | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class NamespaceChangelogEnableResponse(ExtensionResponse):
|
|
189
|
+
roots: list[str]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ChangelogIncrement(ExtensionRequest):
|
|
193
|
+
sequence: int = Field(ge=0)
|
|
194
|
+
object: ReadObject
|
|
195
|
+
|
|
196
|
+
@model_validator(mode="after")
|
|
197
|
+
def _opaque_media_type(self) -> ChangelogIncrement:
|
|
198
|
+
if self.object.media_type != "application/octet-stream":
|
|
199
|
+
raise ValueError(
|
|
200
|
+
"changelog increment media_type must be application/octet-stream"
|
|
201
|
+
)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ObjectChangelogApplyRequest(ExtensionRequest):
|
|
206
|
+
increments: list[ChangelogIncrement]
|
|
207
|
+
resume_conversation: bool = False
|
|
208
|
+
target_context_id: str | None = None
|
|
209
|
+
|
|
210
|
+
@model_validator(mode="after")
|
|
211
|
+
def _ordered_unique_sequences(self) -> ObjectChangelogApplyRequest:
|
|
212
|
+
sequences = [increment.sequence for increment in self.increments]
|
|
213
|
+
if sequences != sorted(set(sequences)):
|
|
214
|
+
raise ValueError("changelog increment sequences must be strictly increasing")
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ObjectChangelogApplyResponse(ExtensionResponse):
|
|
219
|
+
count: int = Field(ge=0)
|
|
220
|
+
context_id: str | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class PeerAgent(ExtensionRequest):
|
|
224
|
+
name: str
|
|
225
|
+
url: str
|
|
226
|
+
card: dict[str, Any] = Field(default_factory=dict)
|
|
227
|
+
description: str | None = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class PeerAgentsSetRequest(ExtensionRequest):
|
|
231
|
+
peers: list[PeerAgent]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TriggerRegisterRequest(ExtensionRequest):
|
|
235
|
+
triggers: list[dict[str, Any]]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class TriggerDecideRequest(ExtensionRequest):
|
|
239
|
+
turn: int
|
|
240
|
+
solver_message: str = Field(default="", max_length=MAX_SOLVER_MESSAGE_LENGTH)
|
|
241
|
+
context_id: str = "default"
|
|
242
|
+
env_triggers: dict[str, dict[str, Any]] | None = None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _tuple(values: Iterable[str]) -> tuple[str, ...]:
|
|
246
|
+
return tuple(dict.fromkeys(values))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True, slots=True)
|
|
250
|
+
class FieldSchema:
|
|
251
|
+
required: tuple[str, ...] = ()
|
|
252
|
+
optional: tuple[str, ...] = ()
|
|
253
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
254
|
+
|
|
255
|
+
def __post_init__(self) -> None:
|
|
256
|
+
object.__setattr__(self, "required", _tuple(self.required))
|
|
257
|
+
object.__setattr__(self, "optional", _tuple(self.optional))
|
|
258
|
+
overlap = set(self.required) & set(self.optional)
|
|
259
|
+
if overlap:
|
|
260
|
+
raise ValueError(
|
|
261
|
+
f"fields cannot be both required and optional: {sorted(overlap)}"
|
|
262
|
+
)
|
|
263
|
+
object.__setattr__(self, "extra", MappingProxyType(dict(self.extra)))
|
|
264
|
+
|
|
265
|
+
def to_card(self) -> dict[str, Any]:
|
|
266
|
+
result = dict(self.extra)
|
|
267
|
+
if self.required:
|
|
268
|
+
result["required"] = list(self.required)
|
|
269
|
+
if self.optional:
|
|
270
|
+
result["optional"] = list(self.optional)
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True, slots=True)
|
|
275
|
+
class RequestVariant:
|
|
276
|
+
name: str
|
|
277
|
+
model: type[BaseModel]
|
|
278
|
+
support_required: bool = True
|
|
279
|
+
implementation: ImplementationOwner | None = None
|
|
280
|
+
|
|
281
|
+
def __post_init__(self) -> None:
|
|
282
|
+
if not isinstance(self.model, type) or not issubclass(self.model, BaseModel):
|
|
283
|
+
raise TypeError(
|
|
284
|
+
"request variant model must be a Pydantic BaseModel subclass"
|
|
285
|
+
)
|
|
286
|
+
if self.model.model_config.get("extra") != "forbid":
|
|
287
|
+
raise TypeError(
|
|
288
|
+
f"request model {self.model.__name__} must set extra='forbid'"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def fields(self) -> FieldSchema:
|
|
293
|
+
return request_fields(self.model)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True, slots=True)
|
|
297
|
+
class RequestDefinition:
|
|
298
|
+
"""Typed request shape with optional mutually-exclusive alternatives.
|
|
299
|
+
|
|
300
|
+
Variants marked ``support_required`` are part of the extension's core
|
|
301
|
+
contract; the others appear only when an agent binds or explicitly enables
|
|
302
|
+
them.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
model: type[BaseModel] | None = None
|
|
306
|
+
variants: tuple[RequestVariant, ...] = ()
|
|
307
|
+
|
|
308
|
+
def __post_init__(self) -> None:
|
|
309
|
+
object.__setattr__(self, "variants", tuple(self.variants))
|
|
310
|
+
if (self.model is None) == (not self.variants):
|
|
311
|
+
raise ValueError(
|
|
312
|
+
"request definition requires exactly one model or one or more variants"
|
|
313
|
+
)
|
|
314
|
+
if self.model is not None and (
|
|
315
|
+
not isinstance(self.model, type) or not issubclass(self.model, BaseModel)
|
|
316
|
+
):
|
|
317
|
+
raise TypeError("request model must be a Pydantic BaseModel subclass")
|
|
318
|
+
if self.model is not None and self.model.model_config.get("extra") != "forbid":
|
|
319
|
+
raise TypeError(
|
|
320
|
+
f"request model {self.model.__name__} must set extra='forbid'"
|
|
321
|
+
)
|
|
322
|
+
names = [variant.name for variant in self.variants]
|
|
323
|
+
if len(names) != len(set(names)):
|
|
324
|
+
raise ValueError(f"duplicate request variant: {names}")
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def common(self) -> FieldSchema:
|
|
328
|
+
if self.model is not None:
|
|
329
|
+
return request_fields(self.model)
|
|
330
|
+
return self._common(self.variants)
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _common(variants: Iterable[RequestVariant]) -> FieldSchema:
|
|
334
|
+
schemas = [variant.fields for variant in variants]
|
|
335
|
+
if not schemas:
|
|
336
|
+
return FieldSchema()
|
|
337
|
+
first = schemas[0]
|
|
338
|
+
required = tuple(
|
|
339
|
+
name
|
|
340
|
+
for name in first.required
|
|
341
|
+
if all(name in schema.required for schema in schemas[1:])
|
|
342
|
+
)
|
|
343
|
+
optional = tuple(
|
|
344
|
+
name
|
|
345
|
+
for name in first.optional
|
|
346
|
+
if all(name in schema.optional for schema in schemas[1:])
|
|
347
|
+
)
|
|
348
|
+
return FieldSchema(required=required, optional=optional)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def core_variants(self) -> frozenset[str]:
|
|
352
|
+
return frozenset(
|
|
353
|
+
variant.name for variant in self.variants if variant.support_required
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def optional_variants(self) -> frozenset[str]:
|
|
358
|
+
return frozenset(
|
|
359
|
+
variant.name for variant in self.variants if not variant.support_required
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def variant(self, name: str) -> RequestVariant:
|
|
363
|
+
for variant in self.variants:
|
|
364
|
+
if variant.name == name:
|
|
365
|
+
return variant
|
|
366
|
+
raise KeyError(name)
|
|
367
|
+
|
|
368
|
+
def enabled_variants(
|
|
369
|
+
self, optional: Iterable[str] = ()
|
|
370
|
+
) -> tuple[RequestVariant, ...]:
|
|
371
|
+
requested = frozenset(optional)
|
|
372
|
+
unknown = requested - self.optional_variants
|
|
373
|
+
if unknown:
|
|
374
|
+
raise ValueError(f"unknown optional request variants: {sorted(unknown)}")
|
|
375
|
+
return tuple(
|
|
376
|
+
variant
|
|
377
|
+
for variant in self.variants
|
|
378
|
+
if variant.support_required or variant.name in requested
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def to_card(self, optional: Iterable[str] = ()) -> dict[str, Any]:
|
|
382
|
+
variants = self.enabled_variants(optional)
|
|
383
|
+
common = self._common(variants) if self.model is None else self.common
|
|
384
|
+
result = common.to_card()
|
|
385
|
+
if not variants:
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
common_names = set(common.required) | set(common.optional)
|
|
389
|
+
|
|
390
|
+
if len(variants) == 1:
|
|
391
|
+
only = variants[0].fields
|
|
392
|
+
required = only.required
|
|
393
|
+
optional_fields = only.optional
|
|
394
|
+
if required:
|
|
395
|
+
result["required"] = list(required)
|
|
396
|
+
if optional_fields:
|
|
397
|
+
result["optional"] = list(optional_fields)
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
result["oneOf"] = [
|
|
401
|
+
FieldSchema(
|
|
402
|
+
required=tuple(
|
|
403
|
+
name for name in variant.fields.required if name not in common_names
|
|
404
|
+
),
|
|
405
|
+
optional=tuple(
|
|
406
|
+
name for name in variant.fields.optional if name not in common_names
|
|
407
|
+
),
|
|
408
|
+
).to_card()
|
|
409
|
+
for variant in variants
|
|
410
|
+
]
|
|
411
|
+
return result
|
|
412
|
+
|
|
413
|
+
def model_for_variant(self, variant: str | None) -> type[BaseModel]:
|
|
414
|
+
if variant is None:
|
|
415
|
+
if self.model is None:
|
|
416
|
+
raise ValueError("request variant was not selected")
|
|
417
|
+
return self.model
|
|
418
|
+
return self.variant(variant).model
|
|
419
|
+
|
|
420
|
+
def select_variant(
|
|
421
|
+
self, payload: Mapping[str, Any], optional: Iterable[str] = ()
|
|
422
|
+
) -> str | None:
|
|
423
|
+
variants = self.enabled_variants(optional)
|
|
424
|
+
if not variants:
|
|
425
|
+
missing = set(self.common.required) - set(payload)
|
|
426
|
+
if missing:
|
|
427
|
+
raise ValueError(f"missing required fields: {sorted(missing)}")
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
common = self._common(variants) if self.model is None else self.common
|
|
431
|
+
enabled_names = {
|
|
432
|
+
field_name
|
|
433
|
+
for variant in variants
|
|
434
|
+
for field_name in (*variant.fields.required, *variant.fields.optional)
|
|
435
|
+
}
|
|
436
|
+
disabled = {
|
|
437
|
+
field_name
|
|
438
|
+
for variant in self.variants
|
|
439
|
+
if variant not in variants
|
|
440
|
+
for field_name in (*variant.fields.required, *variant.fields.optional)
|
|
441
|
+
if field_name not in enabled_names
|
|
442
|
+
}
|
|
443
|
+
supplied_disabled = disabled & set(payload)
|
|
444
|
+
if supplied_disabled:
|
|
445
|
+
raise ValueError(
|
|
446
|
+
f"unsupported request variant fields: {sorted(supplied_disabled)}"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
missing_common = set(common.required) - set(payload)
|
|
450
|
+
if missing_common:
|
|
451
|
+
raise ValueError(f"missing required fields: {sorted(missing_common)}")
|
|
452
|
+
|
|
453
|
+
matches = []
|
|
454
|
+
failures: dict[str, str] = {}
|
|
455
|
+
for variant in variants:
|
|
456
|
+
try:
|
|
457
|
+
variant.model.model_validate(payload)
|
|
458
|
+
except ValidationError as exc:
|
|
459
|
+
failures[variant.name] = "; ".join(
|
|
460
|
+
f"{'.'.join(str(item) for item in error['loc'])}: {error['msg']}"
|
|
461
|
+
for error in exc.errors(include_input=False, include_url=False)
|
|
462
|
+
)
|
|
463
|
+
continue
|
|
464
|
+
matches.append(variant.name)
|
|
465
|
+
if len(matches) != 1:
|
|
466
|
+
names = [variant.name for variant in variants]
|
|
467
|
+
detail = "; ".join(
|
|
468
|
+
f"{name} ({failures[name]})" for name in names if name in failures
|
|
469
|
+
)
|
|
470
|
+
suffix = f"; validation errors: {detail}" if detail else ""
|
|
471
|
+
raise ValueError(
|
|
472
|
+
f"request must match exactly one of variants: {names}{suffix}"
|
|
473
|
+
)
|
|
474
|
+
return matches[0]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def request_fields(model: type[BaseModel]) -> FieldSchema:
|
|
478
|
+
"""The wire fields of a request model, as an Agent Card declares them."""
|
|
479
|
+
required: list[str] = []
|
|
480
|
+
optional: list[str] = []
|
|
481
|
+
for name, field_info in model.model_fields.items():
|
|
482
|
+
alias = field_info.validation_alias
|
|
483
|
+
if alias is not None and not isinstance(alias, str):
|
|
484
|
+
raise TypeError(
|
|
485
|
+
f"request model {model.__name__}.{name} must use a string validation alias"
|
|
486
|
+
)
|
|
487
|
+
wire_name = alias or name
|
|
488
|
+
(required if field_info.is_required() else optional).append(wire_name)
|
|
489
|
+
return FieldSchema(required=tuple(required), optional=tuple(optional))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def card_request_accepts(request: Mapping[str, Any], fields: Iterable[str]) -> bool:
|
|
493
|
+
"""Whether an Agent Card's request contract (as ``RequestDefinition.to_card`` renders it, or
|
|
494
|
+
a hand-written card with ``supported`` lists) takes a body of exactly ``fields``: one of its
|
|
495
|
+
branches requires none of the others and declares all of them."""
|
|
496
|
+
sent = set(fields)
|
|
497
|
+
required, declared = _card_fields(request)
|
|
498
|
+
for branch in request.get("oneOf") or [{}]:
|
|
499
|
+
branch_required, branch_declared = _card_fields(branch)
|
|
500
|
+
if required | branch_required <= sent <= declared | branch_declared:
|
|
501
|
+
return True
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _card_fields(schema: Mapping[str, Any]) -> tuple[set[str], set[str]]:
|
|
506
|
+
required = set(schema.get("required") or ())
|
|
507
|
+
return required, required.union(schema.get("optional") or (), schema.get("supported") or ())
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _response_model_field_schema(model: type[BaseModel]) -> FieldSchema:
|
|
511
|
+
required: list[str] = []
|
|
512
|
+
optional: list[str] = []
|
|
513
|
+
for name, field_info in model.model_fields.items():
|
|
514
|
+
wire_name = field_info.serialization_alias or field_info.alias or name
|
|
515
|
+
(required if field_info.is_required() else optional).append(wire_name)
|
|
516
|
+
return FieldSchema(required=tuple(required), optional=tuple(optional))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@dataclass(frozen=True, slots=True)
|
|
520
|
+
class OperationDefinition:
|
|
521
|
+
name: str
|
|
522
|
+
method: str
|
|
523
|
+
path: str
|
|
524
|
+
implementation: ImplementationOwner
|
|
525
|
+
request: RequestDefinition | type[BaseModel] | None = None
|
|
526
|
+
response: FieldSchema | type[BaseModel] | None = None
|
|
527
|
+
response_model: type[BaseModel] | None = field(default=None, init=False)
|
|
528
|
+
|
|
529
|
+
def __post_init__(self) -> None:
|
|
530
|
+
object.__setattr__(self, "method", self.method.upper())
|
|
531
|
+
if isinstance(self.request, type) and issubclass(self.request, BaseModel):
|
|
532
|
+
object.__setattr__(self, "request", RequestDefinition(model=self.request))
|
|
533
|
+
elif self.request is not None and not isinstance(
|
|
534
|
+
self.request, RequestDefinition
|
|
535
|
+
):
|
|
536
|
+
raise TypeError(
|
|
537
|
+
"operation request must be a Pydantic BaseModel subclass or "
|
|
538
|
+
"RequestDefinition"
|
|
539
|
+
)
|
|
540
|
+
if isinstance(self.response, type) and issubclass(self.response, BaseModel):
|
|
541
|
+
if self.response.model_config.get("extra") != "forbid":
|
|
542
|
+
raise TypeError(
|
|
543
|
+
f"response model {self.response.__name__} must set extra='forbid'"
|
|
544
|
+
)
|
|
545
|
+
object.__setattr__(self, "response_model", self.response)
|
|
546
|
+
object.__setattr__(
|
|
547
|
+
self, "response", _response_model_field_schema(self.response)
|
|
548
|
+
)
|
|
549
|
+
if not self.path.startswith("/"):
|
|
550
|
+
raise ValueError(
|
|
551
|
+
f"extension operation path must be absolute: {self.path!r}"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@dataclass(frozen=True, slots=True)
|
|
556
|
+
class OperationGroup:
|
|
557
|
+
operations: Mapping[str, OperationDefinition]
|
|
558
|
+
required_together: bool = True
|
|
559
|
+
|
|
560
|
+
def __post_init__(self) -> None:
|
|
561
|
+
object.__setattr__(self, "operations", MappingProxyType(dict(self.operations)))
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass(frozen=True, slots=True)
|
|
565
|
+
class ExtensionConfiguration:
|
|
566
|
+
"""Validated configuration contributed by an extension definition."""
|
|
567
|
+
|
|
568
|
+
wire_params: Mapping[str, Any] = field(default_factory=dict)
|
|
569
|
+
options: Mapping[str, Any] = field(default_factory=dict)
|
|
570
|
+
features: frozenset[str] = frozenset()
|
|
571
|
+
|
|
572
|
+
def __post_init__(self) -> None:
|
|
573
|
+
object.__setattr__(
|
|
574
|
+
self, "wire_params", MappingProxyType(dict(self.wire_params))
|
|
575
|
+
)
|
|
576
|
+
object.__setattr__(self, "options", MappingProxyType(dict(self.options)))
|
|
577
|
+
object.__setattr__(self, "features", frozenset(self.features))
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@dataclass(frozen=True, slots=True)
|
|
581
|
+
class ExtensionDefinition:
|
|
582
|
+
uri: str
|
|
583
|
+
description: str
|
|
584
|
+
endpoint: str | None
|
|
585
|
+
core_operations: Mapping[str, OperationDefinition] = field(default_factory=dict)
|
|
586
|
+
optional_features: Mapping[str, OperationGroup] = field(default_factory=dict)
|
|
587
|
+
configuration_validator: Callable[..., ExtensionConfiguration] | None = None
|
|
588
|
+
|
|
589
|
+
def __post_init__(self) -> None:
|
|
590
|
+
if self.endpoint is not None and not self.endpoint.startswith("/"):
|
|
591
|
+
raise ValueError(f"extension endpoint must be absolute: {self.endpoint!r}")
|
|
592
|
+
core = dict(self.core_operations)
|
|
593
|
+
optional = {name: group for name, group in self.optional_features.items()}
|
|
594
|
+
all_names = [operation.name for operation in core.values()]
|
|
595
|
+
for group in optional.values():
|
|
596
|
+
all_names.extend(operation.name for operation in group.operations.values())
|
|
597
|
+
if len(all_names) != len(set(all_names)):
|
|
598
|
+
raise ValueError(f"duplicate operation in {self.uri}")
|
|
599
|
+
if self.configuration_validator is not None and not callable(
|
|
600
|
+
self.configuration_validator
|
|
601
|
+
):
|
|
602
|
+
raise TypeError("configuration_validator must be callable")
|
|
603
|
+
object.__setattr__(self, "core_operations", MappingProxyType(core))
|
|
604
|
+
object.__setattr__(self, "optional_features", MappingProxyType(optional))
|
|
605
|
+
|
|
606
|
+
@property
|
|
607
|
+
def operations(self) -> Mapping[str, OperationDefinition]:
|
|
608
|
+
result = dict(self.core_operations)
|
|
609
|
+
for group in self.optional_features.values():
|
|
610
|
+
result.update(group.operations)
|
|
611
|
+
return MappingProxyType(result)
|
|
612
|
+
|
|
613
|
+
def operation(self, name: str) -> OperationDefinition:
|
|
614
|
+
try:
|
|
615
|
+
return self.operations[name]
|
|
616
|
+
except KeyError as exc:
|
|
617
|
+
raise ValueError(f"{self.uri} has no operation {name!r}") from exc
|
|
618
|
+
|
|
619
|
+
def __getattr__(self, name: str) -> "OperationReference | _FeatureOperations":
|
|
620
|
+
if name in self.optional_features:
|
|
621
|
+
return _FeatureOperations(self, name)
|
|
622
|
+
try:
|
|
623
|
+
self.operation(name)
|
|
624
|
+
except ValueError as exc:
|
|
625
|
+
raise AttributeError(name) from exc
|
|
626
|
+
return OperationReference(self, name)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@dataclass(frozen=True, slots=True)
|
|
630
|
+
class ExtensionActivation:
|
|
631
|
+
definition: ExtensionDefinition
|
|
632
|
+
description: str | None = None
|
|
633
|
+
features: frozenset[str] = frozenset()
|
|
634
|
+
variants: Mapping[str, frozenset[str]] = field(default_factory=dict)
|
|
635
|
+
wire_params: Mapping[str, Any] = field(default_factory=dict)
|
|
636
|
+
options: Mapping[str, Any] = field(default_factory=dict)
|
|
637
|
+
required: bool | None = None
|
|
638
|
+
|
|
639
|
+
def __post_init__(self) -> None:
|
|
640
|
+
if self.description is not None:
|
|
641
|
+
if not isinstance(self.description, str):
|
|
642
|
+
raise TypeError("description must be a string")
|
|
643
|
+
if not self.description.strip():
|
|
644
|
+
raise ValueError("extension description must not be empty")
|
|
645
|
+
object.__setattr__(self, "features", frozenset(self.features))
|
|
646
|
+
unknown_features = set(self.features) - set(self.definition.optional_features)
|
|
647
|
+
if unknown_features:
|
|
648
|
+
raise ValueError(
|
|
649
|
+
f"unknown features for {self.definition.uri}: "
|
|
650
|
+
f"{sorted(unknown_features)}"
|
|
651
|
+
)
|
|
652
|
+
variants = {name: frozenset(values) for name, values in self.variants.items()}
|
|
653
|
+
unknown_operations = set(variants) - set(self.definition.operations)
|
|
654
|
+
if unknown_operations:
|
|
655
|
+
raise ValueError(
|
|
656
|
+
f"unknown variant operations for {self.definition.uri}: "
|
|
657
|
+
f"{sorted(unknown_operations)}"
|
|
658
|
+
)
|
|
659
|
+
for operation_name, enabled in variants.items():
|
|
660
|
+
request = self.definition.operation(operation_name).request
|
|
661
|
+
if request is None:
|
|
662
|
+
raise ValueError(
|
|
663
|
+
f"{self.definition.uri}.{operation_name} has no request variants"
|
|
664
|
+
)
|
|
665
|
+
unknown_variants = set(enabled) - request.optional_variants
|
|
666
|
+
if unknown_variants:
|
|
667
|
+
raise ValueError(
|
|
668
|
+
f"unknown optional request variants for "
|
|
669
|
+
f"{self.definition.uri}.{operation_name}: {sorted(unknown_variants)}"
|
|
670
|
+
)
|
|
671
|
+
object.__setattr__(self, "variants", MappingProxyType(variants))
|
|
672
|
+
object.__setattr__(
|
|
673
|
+
self, "wire_params", MappingProxyType(dict(self.wire_params))
|
|
674
|
+
)
|
|
675
|
+
object.__setattr__(self, "options", MappingProxyType(dict(self.options)))
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@dataclass(frozen=True, slots=True)
|
|
679
|
+
class HandlerBinding:
|
|
680
|
+
extension: ExtensionDefinition
|
|
681
|
+
operation: str
|
|
682
|
+
variant: str | None = None
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
_HANDLER_BINDING = "_agentenv_a2a_handler_binding"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _bind(fn: Callable, binding: HandlerBinding) -> Callable:
|
|
689
|
+
if getattr(fn, _HANDLER_BINDING, None) is not None:
|
|
690
|
+
raise TypeError(f"{fn.__name__} already has an A2A extension binding")
|
|
691
|
+
setattr(fn, _HANDLER_BINDING, binding)
|
|
692
|
+
return fn
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@dataclass(frozen=True, slots=True)
|
|
696
|
+
class OperationReference:
|
|
697
|
+
"""Typed reference passed to the generic :func:`extension` decorator."""
|
|
698
|
+
|
|
699
|
+
extension_definition: ExtensionDefinition
|
|
700
|
+
operation: str
|
|
701
|
+
request_variant: str | None = None
|
|
702
|
+
|
|
703
|
+
def variant(self, name: str) -> "OperationReference":
|
|
704
|
+
operation = self.extension_definition.operation(self.operation)
|
|
705
|
+
if operation.request is None:
|
|
706
|
+
raise ValueError(f"{operation.name} has no request variants")
|
|
707
|
+
try:
|
|
708
|
+
operation.request.variant(name)
|
|
709
|
+
except KeyError as exc:
|
|
710
|
+
raise ValueError(
|
|
711
|
+
f"{operation.name} has no request variant {name!r}"
|
|
712
|
+
) from exc
|
|
713
|
+
return OperationReference(self.extension_definition, self.operation, name)
|
|
714
|
+
|
|
715
|
+
def __getattr__(self, name: str) -> "OperationReference":
|
|
716
|
+
try:
|
|
717
|
+
return self.variant(name)
|
|
718
|
+
except ValueError as exc:
|
|
719
|
+
raise AttributeError(name) from exc
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class _FeatureOperations:
|
|
723
|
+
def __init__(self, extension: ExtensionDefinition, feature: str) -> None:
|
|
724
|
+
self._extension = extension
|
|
725
|
+
self._feature = feature
|
|
726
|
+
|
|
727
|
+
def __getattr__(self, operation: str) -> OperationReference:
|
|
728
|
+
group = self._extension.optional_features[self._feature]
|
|
729
|
+
if operation not in group.operations:
|
|
730
|
+
raise AttributeError(operation)
|
|
731
|
+
return OperationReference(self._extension, operation)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def extension(operation: OperationReference) -> Callable[[Callable], Callable]:
|
|
735
|
+
"""Bind a method to an operation or request variant.
|
|
736
|
+
|
|
737
|
+
Binding any operation activates its containing extension. A shared
|
|
738
|
+
non-AgentEnv ``ExtensionDefinition`` is the canonical way to bind multiple
|
|
739
|
+
custom operations under one URI.
|
|
740
|
+
"""
|
|
741
|
+
if not isinstance(operation, OperationReference):
|
|
742
|
+
raise TypeError("@extension expects an OperationReference")
|
|
743
|
+
_validate_reserved_definition(operation.extension_definition)
|
|
744
|
+
|
|
745
|
+
def decorator(fn: Callable) -> Callable:
|
|
746
|
+
return _bind(
|
|
747
|
+
fn,
|
|
748
|
+
HandlerBinding(
|
|
749
|
+
operation.extension_definition,
|
|
750
|
+
operation.operation,
|
|
751
|
+
variant=operation.request_variant,
|
|
752
|
+
),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
return decorator
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def custom_extension(
|
|
759
|
+
*,
|
|
760
|
+
uri: str,
|
|
761
|
+
operation: str,
|
|
762
|
+
method: str,
|
|
763
|
+
path: str,
|
|
764
|
+
request: type[BaseModel] | None = None,
|
|
765
|
+
response: FieldSchema | None = None,
|
|
766
|
+
description: str = "",
|
|
767
|
+
) -> Callable[[Callable], Callable]:
|
|
768
|
+
"""Single-operation sugar for a non-AgentEnv extension."""
|
|
769
|
+
if uri.startswith("urn:agentenv:"):
|
|
770
|
+
raise ValueError(
|
|
771
|
+
"custom_extension cannot redefine urn:agentenv:*; use a canonical hook "
|
|
772
|
+
"or scoped override"
|
|
773
|
+
)
|
|
774
|
+
definition = ExtensionDefinition(
|
|
775
|
+
uri=uri,
|
|
776
|
+
description=description,
|
|
777
|
+
endpoint=path,
|
|
778
|
+
core_operations={
|
|
779
|
+
operation: OperationDefinition(
|
|
780
|
+
name=operation,
|
|
781
|
+
method=method,
|
|
782
|
+
path=path,
|
|
783
|
+
implementation=ImplementationOwner.RUNTIME,
|
|
784
|
+
request=request,
|
|
785
|
+
response=response,
|
|
786
|
+
)
|
|
787
|
+
},
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
def decorator(fn: Callable) -> Callable:
|
|
791
|
+
return extension(OperationReference(definition, operation))(fn)
|
|
792
|
+
|
|
793
|
+
return decorator
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _op(
|
|
797
|
+
name: str,
|
|
798
|
+
method: str,
|
|
799
|
+
path: str,
|
|
800
|
+
owner: ImplementationOwner,
|
|
801
|
+
*,
|
|
802
|
+
request_model: type[BaseModel] | None = None,
|
|
803
|
+
response_model: type[BaseModel] | None = None,
|
|
804
|
+
response_required: Iterable[str] = (),
|
|
805
|
+
response_optional: Iterable[str] = (),
|
|
806
|
+
variants: Iterable[RequestVariant] = (),
|
|
807
|
+
) -> OperationDefinition:
|
|
808
|
+
response_required = _tuple(response_required)
|
|
809
|
+
response_optional = _tuple(response_optional)
|
|
810
|
+
variants = tuple(variants)
|
|
811
|
+
if request_model is not None and variants:
|
|
812
|
+
raise TypeError("an operation cannot declare both a request model and variants")
|
|
813
|
+
request: RequestDefinition | type[BaseModel] | None = request_model
|
|
814
|
+
if variants:
|
|
815
|
+
request = RequestDefinition(variants=variants)
|
|
816
|
+
response: FieldSchema | type[BaseModel] | None = response_model
|
|
817
|
+
if response_required or response_optional:
|
|
818
|
+
response = FieldSchema(required=response_required, optional=response_optional)
|
|
819
|
+
return OperationDefinition(
|
|
820
|
+
name=name,
|
|
821
|
+
method=method,
|
|
822
|
+
path=path,
|
|
823
|
+
implementation=owner,
|
|
824
|
+
request=request,
|
|
825
|
+
response=response,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _configure_agent_config(
|
|
830
|
+
*,
|
|
831
|
+
fields: Iterable[str],
|
|
832
|
+
defaults: Mapping[str, Any] | None = None,
|
|
833
|
+
schema: Mapping[str, Any] | None = None,
|
|
834
|
+
readback: bool = False,
|
|
835
|
+
) -> ExtensionConfiguration:
|
|
836
|
+
if isinstance(fields, str):
|
|
837
|
+
raise TypeError("fields must be an iterable of field names, not a string")
|
|
838
|
+
fields = tuple(fields)
|
|
839
|
+
if not all(isinstance(name, str) and name for name in fields):
|
|
840
|
+
raise TypeError("fields must contain non-empty strings")
|
|
841
|
+
supported_fields = frozenset(fields)
|
|
842
|
+
if defaults is None:
|
|
843
|
+
defaults = {}
|
|
844
|
+
if not isinstance(defaults, Mapping):
|
|
845
|
+
raise TypeError("defaults must be a mapping")
|
|
846
|
+
unsupported_defaults = set(defaults) - supported_fields
|
|
847
|
+
if unsupported_defaults:
|
|
848
|
+
raise ValueError(
|
|
849
|
+
"agent config defaults contain unsupported fields: "
|
|
850
|
+
f"{sorted(unsupported_defaults)}"
|
|
851
|
+
)
|
|
852
|
+
if not isinstance(readback, bool):
|
|
853
|
+
raise TypeError("readback must be a boolean")
|
|
854
|
+
if schema is not None and not isinstance(schema, Mapping):
|
|
855
|
+
raise TypeError("schema must be a mapping")
|
|
856
|
+
request: dict[str, Any] = {"supported": sorted(supported_fields)}
|
|
857
|
+
if schema is not None:
|
|
858
|
+
request["schema"] = dict(schema)
|
|
859
|
+
return ExtensionConfiguration(
|
|
860
|
+
wire_params={"methods": {"set": {"request": request}}},
|
|
861
|
+
options={"defaults": dict(defaults)},
|
|
862
|
+
features=frozenset({"readback"}) if readback else frozenset(),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _configure_install(**declaration: Any) -> ExtensionConfiguration:
|
|
867
|
+
return ExtensionConfiguration(wire_params=declaration)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
SDK = ImplementationOwner.SDK
|
|
871
|
+
RUNTIME = ImplementationOwner.RUNTIME
|
|
872
|
+
|
|
873
|
+
AGENT_CONFIG_V1 = ExtensionDefinition(
|
|
874
|
+
uri="urn:agentenv:agent-config/v1",
|
|
875
|
+
description="Set or read agent configuration.",
|
|
876
|
+
endpoint="/ext/agent-config",
|
|
877
|
+
core_operations={
|
|
878
|
+
"set": _op(
|
|
879
|
+
"set",
|
|
880
|
+
"POST",
|
|
881
|
+
"/ext/agent-config",
|
|
882
|
+
SDK,
|
|
883
|
+
request_model=AgentConfig,
|
|
884
|
+
)
|
|
885
|
+
},
|
|
886
|
+
optional_features={
|
|
887
|
+
"readback": OperationGroup(
|
|
888
|
+
{
|
|
889
|
+
"get": _op(
|
|
890
|
+
"get",
|
|
891
|
+
"GET",
|
|
892
|
+
"/ext/agent-config",
|
|
893
|
+
SDK,
|
|
894
|
+
response_required=("config",),
|
|
895
|
+
)
|
|
896
|
+
}
|
|
897
|
+
)
|
|
898
|
+
},
|
|
899
|
+
configuration_validator=_configure_agent_config,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
MCP_CONFIG_V1 = ExtensionDefinition(
|
|
903
|
+
uri="urn:agentenv:mcp-config/v1",
|
|
904
|
+
description="Register MCP servers the agent should use.",
|
|
905
|
+
endpoint="/ext/mcp-config",
|
|
906
|
+
core_operations={
|
|
907
|
+
"add": _op(
|
|
908
|
+
"add",
|
|
909
|
+
"POST",
|
|
910
|
+
"/ext/mcp-config",
|
|
911
|
+
SDK,
|
|
912
|
+
request_model=McpAddRequest,
|
|
913
|
+
),
|
|
914
|
+
"list": _op(
|
|
915
|
+
"list", "GET", "/ext/mcp-config", SDK, response_required=("mcp_servers",)
|
|
916
|
+
),
|
|
917
|
+
},
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
SKILL_CONFIG_V1 = ExtensionDefinition(
|
|
921
|
+
uri="urn:agentenv:skill-config/v1",
|
|
922
|
+
description="Register skills made available to the agent.",
|
|
923
|
+
endpoint="/ext/skill-config",
|
|
924
|
+
core_operations={
|
|
925
|
+
"add": OperationDefinition(
|
|
926
|
+
name="add",
|
|
927
|
+
method="POST",
|
|
928
|
+
path="/ext/skill-config",
|
|
929
|
+
implementation=RUNTIME,
|
|
930
|
+
request=RequestDefinition(
|
|
931
|
+
variants=(
|
|
932
|
+
RequestVariant("inline", InlineSkillRequest),
|
|
933
|
+
RequestVariant("bundle", BundleSkillRequest),
|
|
934
|
+
),
|
|
935
|
+
),
|
|
936
|
+
response=SkillAddResponse,
|
|
937
|
+
),
|
|
938
|
+
"list": _op(
|
|
939
|
+
"list", "GET", "/ext/skill-config", SDK, response_required=("skills",)
|
|
940
|
+
),
|
|
941
|
+
},
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
TRAJECTORY_V1 = ExtensionDefinition(
|
|
945
|
+
uri="urn:agentenv:trajectory/v1",
|
|
946
|
+
description="Retrieve an agent trajectory.",
|
|
947
|
+
endpoint="/ext/trajectory",
|
|
948
|
+
core_operations={
|
|
949
|
+
"get": OperationDefinition(
|
|
950
|
+
name="get",
|
|
951
|
+
method="POST",
|
|
952
|
+
path="/ext/trajectory",
|
|
953
|
+
implementation=SDK,
|
|
954
|
+
request=RequestDefinition(
|
|
955
|
+
variants=(
|
|
956
|
+
RequestVariant("task", TaskTrajectoryRequest),
|
|
957
|
+
RequestVariant("task_objects", TaskObjectTrajectoryRequest),
|
|
958
|
+
RequestVariant(
|
|
959
|
+
"context",
|
|
960
|
+
ContextTrajectoryRequest,
|
|
961
|
+
support_required=False,
|
|
962
|
+
implementation=RUNTIME,
|
|
963
|
+
),
|
|
964
|
+
RequestVariant(
|
|
965
|
+
"context_objects",
|
|
966
|
+
ContextObjectTrajectoryRequest,
|
|
967
|
+
support_required=False,
|
|
968
|
+
implementation=RUNTIME,
|
|
969
|
+
),
|
|
970
|
+
),
|
|
971
|
+
),
|
|
972
|
+
)
|
|
973
|
+
},
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
SNAPSHOT_V1 = ExtensionDefinition(
|
|
977
|
+
uri="urn:agentenv:snapshot/v1",
|
|
978
|
+
description="Save and restore agent-native state.",
|
|
979
|
+
endpoint="/ext/snapshot",
|
|
980
|
+
core_operations={
|
|
981
|
+
"save": _op(
|
|
982
|
+
"save",
|
|
983
|
+
"POST",
|
|
984
|
+
"/ext/snapshot",
|
|
985
|
+
RUNTIME,
|
|
986
|
+
request_model=ObjectSnapshotSaveRequest,
|
|
987
|
+
response_model=ObjectSnapshotSaveResponse,
|
|
988
|
+
),
|
|
989
|
+
"load": _op(
|
|
990
|
+
"load",
|
|
991
|
+
"PUT",
|
|
992
|
+
"/ext/snapshot",
|
|
993
|
+
RUNTIME,
|
|
994
|
+
request_model=ObjectSnapshotLoadRequest,
|
|
995
|
+
response_model=ObjectSnapshotLoadResponse,
|
|
996
|
+
),
|
|
997
|
+
},
|
|
998
|
+
optional_features={
|
|
999
|
+
"changelog": OperationGroup(
|
|
1000
|
+
operations={
|
|
1001
|
+
"enable": _op(
|
|
1002
|
+
"enable-changelog",
|
|
1003
|
+
"POST",
|
|
1004
|
+
"/ext/snapshot/changelog",
|
|
1005
|
+
RUNTIME,
|
|
1006
|
+
request_model=NamespaceChangelogEnableRequest,
|
|
1007
|
+
response_model=NamespaceChangelogEnableResponse,
|
|
1008
|
+
),
|
|
1009
|
+
"apply": _op(
|
|
1010
|
+
"apply-changelog",
|
|
1011
|
+
"PUT",
|
|
1012
|
+
"/ext/snapshot/changelog",
|
|
1013
|
+
RUNTIME,
|
|
1014
|
+
request_model=ObjectChangelogApplyRequest,
|
|
1015
|
+
response_model=ObjectChangelogApplyResponse,
|
|
1016
|
+
),
|
|
1017
|
+
}
|
|
1018
|
+
)
|
|
1019
|
+
},
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
PEER_AGENTS_V1 = ExtensionDefinition(
|
|
1023
|
+
uri="urn:agentenv:peer-agents/v1",
|
|
1024
|
+
description="Configure peer A2A agents available for delegation.",
|
|
1025
|
+
endpoint="/ext/peer-agents",
|
|
1026
|
+
core_operations={
|
|
1027
|
+
"set": _op(
|
|
1028
|
+
"set",
|
|
1029
|
+
"POST",
|
|
1030
|
+
"/ext/peer-agents",
|
|
1031
|
+
RUNTIME,
|
|
1032
|
+
request_model=PeerAgentsSetRequest,
|
|
1033
|
+
),
|
|
1034
|
+
"list": _op(
|
|
1035
|
+
"list", "GET", "/ext/peer-agents", RUNTIME, response_required=("peers",)
|
|
1036
|
+
),
|
|
1037
|
+
},
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
TRIGGERS_V1 = ExtensionDefinition(
|
|
1041
|
+
uri="urn:agentenv:triggers/v1",
|
|
1042
|
+
description="Register triggers and decide deterministic responses.",
|
|
1043
|
+
endpoint="/ext/triggers",
|
|
1044
|
+
core_operations={
|
|
1045
|
+
"register": _op(
|
|
1046
|
+
"register",
|
|
1047
|
+
"POST",
|
|
1048
|
+
"/ext/triggers",
|
|
1049
|
+
SDK,
|
|
1050
|
+
request_model=TriggerRegisterRequest,
|
|
1051
|
+
),
|
|
1052
|
+
"decide": _op(
|
|
1053
|
+
"decide",
|
|
1054
|
+
"POST",
|
|
1055
|
+
"/ext/triggers/decide",
|
|
1056
|
+
SDK,
|
|
1057
|
+
request_model=TriggerDecideRequest,
|
|
1058
|
+
response_required=("parts", "done", "fired"),
|
|
1059
|
+
),
|
|
1060
|
+
"state": _op(
|
|
1061
|
+
"state",
|
|
1062
|
+
"GET",
|
|
1063
|
+
"/ext/triggers",
|
|
1064
|
+
SDK,
|
|
1065
|
+
response_required=("firing_log",),
|
|
1066
|
+
),
|
|
1067
|
+
},
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
INSTALL_V1 = ExtensionDefinition(
|
|
1071
|
+
uri="urn:agentenv:install/v1",
|
|
1072
|
+
description="Declare commands for installing the agent into an existing target.",
|
|
1073
|
+
endpoint=None,
|
|
1074
|
+
configuration_validator=_configure_install,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
STANDARD_EXTENSIONS: Mapping[str, ExtensionDefinition] = MappingProxyType(
|
|
1078
|
+
{
|
|
1079
|
+
extension.uri: extension
|
|
1080
|
+
for extension in (
|
|
1081
|
+
AGENT_CONFIG_V1,
|
|
1082
|
+
MCP_CONFIG_V1,
|
|
1083
|
+
SKILL_CONFIG_V1,
|
|
1084
|
+
TRAJECTORY_V1,
|
|
1085
|
+
SNAPSHOT_V1,
|
|
1086
|
+
PEER_AGENTS_V1,
|
|
1087
|
+
TRIGGERS_V1,
|
|
1088
|
+
INSTALL_V1,
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _validate_reserved_definition(definition: ExtensionDefinition) -> None:
|
|
1095
|
+
"""Require reserved AgentEnv URIs to use their canonical SDK object."""
|
|
1096
|
+
if not definition.uri.startswith("urn:agentenv:"):
|
|
1097
|
+
return
|
|
1098
|
+
canonical = STANDARD_EXTENSIONS.get(definition.uri)
|
|
1099
|
+
if canonical is None or definition is not canonical:
|
|
1100
|
+
raise ValueError(
|
|
1101
|
+
f"reserved extension URI {definition.uri!r} must use its canonical "
|
|
1102
|
+
"SDK definition"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def enable(
|
|
1107
|
+
definition: ExtensionDefinition,
|
|
1108
|
+
*,
|
|
1109
|
+
description: str | None = None,
|
|
1110
|
+
features: Iterable[str] = (),
|
|
1111
|
+
variants: Mapping[str, Iterable[str]] | None = None,
|
|
1112
|
+
required: bool | None = None,
|
|
1113
|
+
**configuration: Any,
|
|
1114
|
+
) -> ExtensionActivation:
|
|
1115
|
+
"""Enable a versioned extension definition with semantic configuration."""
|
|
1116
|
+
if not isinstance(definition, ExtensionDefinition):
|
|
1117
|
+
raise TypeError("enable expects an ExtensionDefinition")
|
|
1118
|
+
_validate_reserved_definition(definition)
|
|
1119
|
+
if description is not None and not isinstance(description, str):
|
|
1120
|
+
raise TypeError("description must be a string")
|
|
1121
|
+
|
|
1122
|
+
validator = definition.configuration_validator
|
|
1123
|
+
if validator is None:
|
|
1124
|
+
validated = ExtensionConfiguration()
|
|
1125
|
+
else:
|
|
1126
|
+
validated = validator(**configuration)
|
|
1127
|
+
if not isinstance(validated, ExtensionConfiguration):
|
|
1128
|
+
raise TypeError(
|
|
1129
|
+
f"configuration validator for {definition.uri} must return "
|
|
1130
|
+
"ExtensionConfiguration"
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
if validator is None and configuration:
|
|
1134
|
+
raise TypeError(
|
|
1135
|
+
f"unsupported configuration for {definition.uri}: {sorted(configuration)}"
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
selected_features = set(features)
|
|
1139
|
+
selected_features.update(validated.features)
|
|
1140
|
+
|
|
1141
|
+
return ExtensionActivation(
|
|
1142
|
+
definition=definition,
|
|
1143
|
+
description=description,
|
|
1144
|
+
features=frozenset(selected_features),
|
|
1145
|
+
variants={
|
|
1146
|
+
operation: frozenset(names) for operation, names in (variants or {}).items()
|
|
1147
|
+
},
|
|
1148
|
+
wire_params=validated.wire_params,
|
|
1149
|
+
options=validated.options,
|
|
1150
|
+
required=required,
|
|
1151
|
+
)
|