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.
@@ -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
+ )