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,408 @@
1
+ """Version 1 of the stable agent task boundary.
2
+
3
+ The framework constructs :class:`TaskRequest` instances and agent authors return
4
+ an immutable :class:`TaskResult`. V1 is additive-only: new request fields must
5
+ have defaults and new result capabilities are exposed through optional builder
6
+ methods rather than required constructor arguments.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator, Awaitable, Iterable, Mapping
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar, Union
16
+
17
+ from a2a.types import TaskStatusUpdateEvent
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+
21
+ _WriteOnlyT = TypeVar("_WriteOnlyT")
22
+ WriteOnly: TypeAlias = Annotated[
23
+ _WriteOnlyT,
24
+ Field(json_schema_extra={"writeOnly": True}),
25
+ ]
26
+
27
+
28
+ def _copy_json_value(value: Any) -> Any:
29
+ """Detach JSON-shaped values without changing their serializable types."""
30
+ if isinstance(value, BaseModel):
31
+ return value.model_copy(deep=True)
32
+ if isinstance(value, Mapping):
33
+ return {str(key): _copy_json_value(item) for key, item in value.items()}
34
+ if isinstance(value, (list, tuple)):
35
+ return [_copy_json_value(item) for item in value]
36
+ if isinstance(value, (set, frozenset)):
37
+ return [_copy_json_value(item) for item in value]
38
+ return value
39
+
40
+
41
+ def _thaw(value: Any) -> Any:
42
+ """Convert immutable boundary values into JSON-ready containers."""
43
+ if isinstance(value, Mapping):
44
+ return {key: _thaw(item) for key, item in value.items()}
45
+ if isinstance(value, tuple):
46
+ return [_thaw(item) for item in value]
47
+ if isinstance(value, frozenset):
48
+ return [_thaw(item) for item in value]
49
+ return value
50
+
51
+
52
+ @dataclass(frozen=True, slots=True, kw_only=True)
53
+ class TextPart:
54
+ text: str
55
+ kind: Literal["text"] = "text"
56
+ metadata: Mapping[str, Any] = field(default_factory=dict)
57
+
58
+ def __post_init__(self) -> None:
59
+ object.__setattr__(self, "metadata", _copy_json_value(self.metadata))
60
+
61
+
62
+ @dataclass(frozen=True, slots=True, kw_only=True)
63
+ class FilePart:
64
+ name: str | None = None
65
+ mime_type: str | None = None
66
+ bytes: str | None = None
67
+ uri: str | None = None
68
+ kind: Literal["file"] = "file"
69
+ metadata: Mapping[str, Any] = field(default_factory=dict)
70
+
71
+ def __post_init__(self) -> None:
72
+ if (self.bytes is None) == (self.uri is None):
73
+ raise ValueError("a file part requires exactly one of bytes or uri")
74
+ object.__setattr__(self, "metadata", _copy_json_value(self.metadata))
75
+
76
+
77
+ @dataclass(frozen=True, slots=True, kw_only=True)
78
+ class DataPart:
79
+ data: Mapping[str, Any]
80
+ kind: Literal["data"] = "data"
81
+ metadata: Mapping[str, Any] = field(default_factory=dict)
82
+
83
+ def __post_init__(self) -> None:
84
+ object.__setattr__(self, "data", _copy_json_value(self.data))
85
+ object.__setattr__(self, "metadata", _copy_json_value(self.metadata))
86
+
87
+
88
+ TaskPart = Union[TextPart, FilePart, DataPart]
89
+
90
+
91
+ @dataclass(frozen=True, slots=True, kw_only=True)
92
+ class TaskProgress:
93
+ """A non-terminal status update yielded by a streaming ``run`` method."""
94
+
95
+ parts: tuple[TaskPart, ...] = ()
96
+ metadata: Mapping[str, Any] = field(default_factory=dict)
97
+
98
+ def __post_init__(self) -> None:
99
+ object.__setattr__(self, "parts", tuple(self.parts))
100
+ object.__setattr__(self, "metadata", _copy_json_value(self.metadata))
101
+
102
+ @classmethod
103
+ def text(
104
+ cls, text: str, *, metadata: Mapping[str, Any] | None = None
105
+ ) -> TaskProgress:
106
+ return cls(parts=(TextPart(text=text),), metadata=metadata or {})
107
+
108
+
109
+ class AgentConfig(BaseModel):
110
+ """Base configuration supplied by AgentEnv to every configured agent.
111
+
112
+ Subclasses add runtime-specific fields. These SDK-owned fields are part of
113
+ the control-plane contract and therefore do not need to be repeated by each
114
+ agent author.
115
+ """
116
+
117
+ model_config = ConfigDict(extra="forbid", frozen=True)
118
+
119
+ name: str | None = None
120
+ description: str | None = None
121
+ role: str | None = None
122
+ timeout_seconds: int = 600
123
+
124
+
125
+ ConfigT = TypeVar("ConfigT", bound=AgentConfig)
126
+
127
+
128
+ @dataclass(frozen=True, slots=True, kw_only=True)
129
+ class TaskRequest(Generic[ConfigT]):
130
+ """Frozen request record with detached, JSON-native nested values."""
131
+
132
+ task_id: str
133
+ context_id: str
134
+ parts: tuple[TaskPart, ...]
135
+ config: ConfigT
136
+ mcp_servers: Mapping[str, Any] = field(default_factory=dict)
137
+ skills: tuple[Mapping[str, Any], ...] = ()
138
+ metadata: Mapping[str, Any] = field(default_factory=dict)
139
+ session_ref: str | None = None
140
+ workspace: Path | None = None
141
+
142
+ def __post_init__(self) -> None:
143
+ object.__setattr__(self, "parts", tuple(self.parts))
144
+ if not isinstance(self.config, AgentConfig):
145
+ raise TypeError("TaskRequest.config must be an AgentConfig instance")
146
+ # Detach mutable values from framework-owned state while preserving the
147
+ # JSON-native types declared by the config model and request contract.
148
+ object.__setattr__(self, "config", self.config.model_copy(deep=True))
149
+ object.__setattr__(self, "mcp_servers", _copy_json_value(self.mcp_servers))
150
+ object.__setattr__(
151
+ self, "skills", tuple(_copy_json_value(skill) for skill in self.skills)
152
+ )
153
+ object.__setattr__(self, "metadata", _copy_json_value(self.metadata))
154
+
155
+
156
+ class TaskOutcome(str, Enum):
157
+ SUCCEEDED = "succeeded"
158
+ FAILED = "failed"
159
+
160
+
161
+ @dataclass(frozen=True, slots=True, kw_only=True)
162
+ class TaskError:
163
+ code: str
164
+ message: str
165
+ error_type: Literal["agent_error", "infra_error"] = "agent_error"
166
+
167
+ def __post_init__(self) -> None:
168
+ if self.error_type not in {"agent_error", "infra_error"}:
169
+ raise ValueError("error_type must be 'agent_error' or 'infra_error'")
170
+
171
+
172
+ @dataclass(frozen=True, slots=True, kw_only=True)
173
+ class NativeTrajectory:
174
+ format: str
175
+ payload: Any
176
+ version: int = 1
177
+
178
+ def __post_init__(self) -> None:
179
+ if self.version < 1:
180
+ raise ValueError("trajectory envelope version must be positive")
181
+ object.__setattr__(self, "payload", _copy_json_value(self.payload))
182
+
183
+
184
+ @dataclass(frozen=True, slots=True, kw_only=True)
185
+ class Usage:
186
+ """Common execution metrics plus runtime-specific, JSON-shaped details."""
187
+
188
+ tool_call_count: int | None = None
189
+ input_tokens: int | None = None
190
+ output_tokens: int | None = None
191
+ total_tokens: int | None = None
192
+ cost_usd: float | None = None
193
+ provider_details: Mapping[str, Any] = field(default_factory=dict)
194
+
195
+ def __post_init__(self) -> None:
196
+ for name in (
197
+ "tool_call_count",
198
+ "input_tokens",
199
+ "output_tokens",
200
+ "total_tokens",
201
+ ):
202
+ value = getattr(self, name)
203
+ if value is not None and (
204
+ isinstance(value, bool) or not isinstance(value, int) or value < 0
205
+ ):
206
+ raise ValueError(f"{name} cannot be negative")
207
+ if self.cost_usd is not None and (
208
+ isinstance(self.cost_usd, bool)
209
+ or not isinstance(self.cost_usd, (int, float))
210
+ or self.cost_usd < 0
211
+ ):
212
+ raise ValueError("cost_usd cannot be negative")
213
+ if not isinstance(self.provider_details, Mapping):
214
+ raise TypeError("provider_details must be a mapping")
215
+ object.__setattr__(
216
+ self, "provider_details", _copy_json_value(self.provider_details)
217
+ )
218
+
219
+ @property
220
+ def is_empty(self) -> bool:
221
+ return (
222
+ self.tool_call_count is None
223
+ and self.input_tokens is None
224
+ and self.output_tokens is None
225
+ and self.total_tokens is None
226
+ and self.cost_usd is None
227
+ and not self.provider_details
228
+ )
229
+
230
+ def to_dict(self) -> dict[str, Any]:
231
+ values = {
232
+ "tool_call_count": self.tool_call_count,
233
+ "input_tokens": self.input_tokens,
234
+ "output_tokens": self.output_tokens,
235
+ "total_tokens": self.total_tokens,
236
+ "cost_usd": self.cost_usd,
237
+ }
238
+ result = {name: value for name, value in values.items() if value is not None}
239
+ if self.provider_details:
240
+ result["provider_details"] = _thaw(self.provider_details)
241
+ return result
242
+
243
+
244
+ @dataclass(frozen=True, slots=True, kw_only=True)
245
+ class TaskResult:
246
+ """Validated, immutable result returned by an agent's ``run`` method."""
247
+
248
+ outcome: TaskOutcome
249
+ parts: tuple[TaskPart, ...] = ()
250
+ error: TaskError | None = None
251
+ session_ref: str | None = None
252
+ usage: Usage = field(default_factory=Usage)
253
+ native_trajectory: NativeTrajectory | None = None
254
+
255
+ def __post_init__(self) -> None:
256
+ object.__setattr__(self, "parts", tuple(self.parts))
257
+ object.__setattr__(self, "usage", _copy_json_value(self.usage))
258
+ if self.outcome is TaskOutcome.SUCCEEDED and self.error is not None:
259
+ raise ValueError("a successful result cannot contain an error")
260
+ if self.outcome is TaskOutcome.SUCCEEDED and not self.parts:
261
+ raise ValueError("a successful result requires at least one part")
262
+ if self.outcome is TaskOutcome.FAILED and self.error is None:
263
+ raise ValueError("a failed result requires an error")
264
+ if not isinstance(self.usage, Usage):
265
+ raise TypeError("TaskResult.usage must be a Usage instance")
266
+
267
+ @classmethod
268
+ def builder(cls) -> "TaskResultBuilder":
269
+ return TaskResultBuilder()
270
+
271
+ @classmethod
272
+ def text(cls, text: str, **kwargs: Any) -> "TaskResult":
273
+ return cls.success(parts=(TextPart(text=text),), **kwargs)
274
+
275
+ @classmethod
276
+ def success(
277
+ cls,
278
+ *,
279
+ parts: Iterable[TaskPart] = (),
280
+ session_ref: str | None = None,
281
+ usage: Usage | None = None,
282
+ native_trajectory: NativeTrajectory | None = None,
283
+ ) -> "TaskResult":
284
+ builder = (
285
+ cls.builder()
286
+ .succeeded()
287
+ .parts(parts)
288
+ .session_ref(session_ref)
289
+ .usage(usage or Usage())
290
+ )
291
+ if native_trajectory is not None:
292
+ builder.native_trajectory(
293
+ format=native_trajectory.format,
294
+ payload=native_trajectory.payload,
295
+ version=native_trajectory.version,
296
+ )
297
+ return builder.build()
298
+
299
+ @classmethod
300
+ def failure(
301
+ cls,
302
+ code: str,
303
+ message: str,
304
+ *,
305
+ error_type: Literal["agent_error", "infra_error"] = "agent_error",
306
+ parts: Iterable[TaskPart] | None = None,
307
+ ) -> "TaskResult":
308
+ return (
309
+ cls.builder()
310
+ .failed(code, message, error_type=error_type)
311
+ .parts(parts if parts is not None else (TextPart(text=message),))
312
+ .build()
313
+ )
314
+
315
+
316
+ class TaskResultBuilder:
317
+ """Mutable assembly object whose ``build`` method returns a valid result."""
318
+
319
+ def __init__(self) -> None:
320
+ self._outcome: TaskOutcome | None = None
321
+ self._parts: list[TaskPart] = []
322
+ self._error: TaskError | None = None
323
+ self._session_ref: str | None = None
324
+ self._usage = Usage()
325
+ self._native_trajectory: NativeTrajectory | None = None
326
+
327
+ def succeeded(self) -> "TaskResultBuilder":
328
+ self._outcome = TaskOutcome.SUCCEEDED
329
+ self._error = None
330
+ return self
331
+
332
+ def failed(
333
+ self,
334
+ code: str,
335
+ message: str,
336
+ *,
337
+ error_type: Literal["agent_error", "infra_error"] = "agent_error",
338
+ ) -> "TaskResultBuilder":
339
+ self._outcome = TaskOutcome.FAILED
340
+ self._error = TaskError(code=code, message=message, error_type=error_type)
341
+ return self
342
+
343
+ def parts(self, parts: Iterable[TaskPart]) -> "TaskResultBuilder":
344
+ self._parts = list(parts)
345
+ return self
346
+
347
+ def add_text(
348
+ self, text: str, *, metadata: Mapping[str, Any] | None = None
349
+ ) -> "TaskResultBuilder":
350
+ self._parts.append(TextPart(text=text, metadata=metadata or {}))
351
+ return self
352
+
353
+ def add_data(
354
+ self, data: Mapping[str, Any], *, metadata: Mapping[str, Any] | None = None
355
+ ) -> "TaskResultBuilder":
356
+ self._parts.append(DataPart(data=data, metadata=metadata or {}))
357
+ return self
358
+
359
+ def add_structured_output(self, value: Any) -> "TaskResultBuilder":
360
+ """Append the AgentEnv structured-output convention as a data part.
361
+
362
+ Structured output is ordinary terminal response data, not a separate
363
+ result field. AgentEnv consumers read ``data["structured_output"]``.
364
+ """
365
+ return self.add_data({"structured_output": value})
366
+
367
+ def session_ref(self, value: str | None) -> "TaskResultBuilder":
368
+ self._session_ref = value
369
+ return self
370
+
371
+ def usage(self, value: Usage) -> "TaskResultBuilder":
372
+ if not isinstance(value, Usage):
373
+ raise TypeError("usage must be a Usage instance")
374
+ self._usage = value
375
+ return self
376
+
377
+ def native_trajectory(
378
+ self, *, format: str, payload: Any, version: int = 1
379
+ ) -> "TaskResultBuilder":
380
+ self._native_trajectory = NativeTrajectory(
381
+ format=format, payload=payload, version=version
382
+ )
383
+ return self
384
+
385
+ def build(self) -> TaskResult:
386
+ if self._outcome is None:
387
+ raise ValueError(
388
+ "task result outcome is required; call succeeded() or failed()"
389
+ )
390
+ return TaskResult(
391
+ outcome=self._outcome,
392
+ parts=tuple(self._parts),
393
+ error=self._error,
394
+ session_ref=self._session_ref,
395
+ usage=self._usage,
396
+ native_trajectory=self._native_trajectory,
397
+ )
398
+
399
+
400
+ TaskStreamItem = Union[
401
+ TaskProgress,
402
+ TaskStatusUpdateEvent,
403
+ TaskResult,
404
+ ]
405
+ AgentRunResult = Union[
406
+ Awaitable[TaskResult],
407
+ AsyncIterator[TaskStreamItem],
408
+ ]