agno 2.3.21__py3-none-any.whl → 2.3.22__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.
Files changed (52) hide show
  1. agno/agent/agent.py +26 -1
  2. agno/agent/remote.py +233 -72
  3. agno/client/a2a/__init__.py +10 -0
  4. agno/client/a2a/client.py +554 -0
  5. agno/client/a2a/schemas.py +112 -0
  6. agno/client/a2a/utils.py +369 -0
  7. agno/db/migrations/utils.py +19 -0
  8. agno/db/migrations/v1_to_v2.py +54 -16
  9. agno/db/migrations/versions/v2_3_0.py +92 -53
  10. agno/db/postgres/async_postgres.py +162 -40
  11. agno/db/postgres/postgres.py +181 -31
  12. agno/db/postgres/utils.py +6 -2
  13. agno/knowledge/chunking/document.py +3 -2
  14. agno/knowledge/chunking/markdown.py +8 -3
  15. agno/knowledge/chunking/recursive.py +2 -2
  16. agno/models/openai/chat.py +1 -1
  17. agno/models/openai/responses.py +14 -7
  18. agno/os/middleware/jwt.py +66 -27
  19. agno/os/routers/agents/router.py +2 -2
  20. agno/os/routers/knowledge/knowledge.py +3 -3
  21. agno/os/routers/teams/router.py +2 -2
  22. agno/os/routers/workflows/router.py +2 -2
  23. agno/reasoning/deepseek.py +11 -1
  24. agno/reasoning/gemini.py +6 -2
  25. agno/reasoning/groq.py +8 -3
  26. agno/reasoning/openai.py +2 -0
  27. agno/remote/base.py +105 -8
  28. agno/skills/__init__.py +17 -0
  29. agno/skills/agent_skills.py +370 -0
  30. agno/skills/errors.py +32 -0
  31. agno/skills/loaders/__init__.py +4 -0
  32. agno/skills/loaders/base.py +27 -0
  33. agno/skills/loaders/local.py +216 -0
  34. agno/skills/skill.py +65 -0
  35. agno/skills/utils.py +107 -0
  36. agno/skills/validator.py +277 -0
  37. agno/team/remote.py +219 -59
  38. agno/team/team.py +22 -2
  39. agno/tools/mcp/mcp.py +299 -17
  40. agno/tools/mcp/multi_mcp.py +269 -14
  41. agno/utils/mcp.py +49 -8
  42. agno/utils/string.py +43 -1
  43. agno/workflow/condition.py +4 -2
  44. agno/workflow/loop.py +20 -1
  45. agno/workflow/remote.py +172 -32
  46. agno/workflow/router.py +4 -1
  47. agno/workflow/steps.py +4 -0
  48. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/METADATA +13 -14
  49. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/RECORD +52 -38
  50. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/WHEEL +0 -0
  51. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/licenses/LICENSE +0 -0
  52. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,554 @@
1
+ """A2A (Agent-to-Agent) protocol client for Agno.
2
+
3
+ This module provides a Pythonic client for communicating with any A2A-compatible
4
+ agent server, enabling cross-framework agent communication.
5
+
6
+ """
7
+
8
+ import json
9
+ from typing import Any, AsyncIterator, Dict, List, Literal, Optional
10
+ from uuid import uuid4
11
+
12
+ from agno.client.a2a.schemas import AgentCard, Artifact, StreamEvent, TaskResult
13
+ from agno.exceptions import RemoteServerUnavailableError
14
+ from agno.media import Audio, File, Image, Video
15
+ from agno.utils.http import get_default_async_client, get_default_sync_client
16
+ from agno.utils.log import log_warning
17
+
18
+ try:
19
+ from httpx import ConnectError, ConnectTimeout, TimeoutException
20
+ except ImportError:
21
+ raise ImportError("`httpx` not installed. Please install using `pip install httpx`")
22
+
23
+
24
+ __all__ = ["A2AClient"]
25
+
26
+
27
+ class A2AClient:
28
+ """Async client for A2A (Agent-to-Agent) protocol communication.
29
+
30
+ Provides a Pythonic interface for communicating with any A2A-compatible
31
+ agent server, including Agno AgentOS with a2a_interface=True.
32
+
33
+ The A2A protocol is a standard for agent-to-agent communication that enables
34
+ interoperability between different AI agent frameworks.
35
+
36
+ Attributes:
37
+ base_url: Base URL of the A2A server
38
+ timeout: Request timeout in seconds
39
+ a2a_prefix: URL prefix for A2A endpoints (default: "/a2a")
40
+
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ timeout: int = 30,
47
+ protocol: Literal["rest", "json-rpc"] = "rest",
48
+ ):
49
+ """Initialize A2AClient.
50
+
51
+ Args:
52
+ base_url: Base URL of the A2A server (e.g., "http://localhost:7777")
53
+ timeout: Request timeout in seconds (default: 30)
54
+ protocol: Protocol to use for A2A communication (default: "rest")
55
+ """
56
+ self.base_url = base_url.rstrip("/")
57
+ self.timeout = timeout
58
+ self.protocol = protocol
59
+
60
+ def _get_endpoint(self, path: str) -> str:
61
+ """Build full endpoint URL.
62
+
63
+ If protocol is "json-rpc", always use the base URL. Otherwise, use the traditional
64
+ REST-style endpoints.
65
+ """
66
+ if self.protocol == "json-rpc":
67
+ return self.base_url if self.base_url.endswith("/") else f"{self.base_url}/"
68
+
69
+ # Manually construct URL to ensure proper path joining
70
+ base = self.base_url.rstrip("/")
71
+ path_clean = path.lstrip("/")
72
+ return f"{base}/{path_clean}" if path_clean else base
73
+
74
+ def _build_message_request(
75
+ self,
76
+ message: str,
77
+ context_id: Optional[str] = None,
78
+ user_id: Optional[str] = None,
79
+ images: Optional[List[Image]] = None,
80
+ audio: Optional[List[Audio]] = None,
81
+ videos: Optional[List[Video]] = None,
82
+ files: Optional[List[File]] = None,
83
+ metadata: Optional[Dict[str, Any]] = None,
84
+ stream: bool = False,
85
+ ) -> Dict[str, Any]:
86
+ """Build A2A JSON-RPC request payload.
87
+
88
+ Args:
89
+ message: Text message to send
90
+ context_id: Session/context ID for multi-turn conversations
91
+ user_id: User identifier
92
+ images: List of images to include
93
+ audio: List of audio files to include
94
+ videos: List of videos to include
95
+ files: List of files to include
96
+ metadata: Additional metadata
97
+ stream: Whether this is a streaming request
98
+
99
+ Returns:
100
+ Dict containing the JSON-RPC request payload
101
+ """
102
+ message_id = str(uuid4())
103
+
104
+ # Build message parts
105
+ parts: List[Dict[str, Any]] = [{"kind": "text", "text": message}]
106
+
107
+ # Add images as file parts
108
+ if images:
109
+ for img in images:
110
+ if hasattr(img, "url") and img.url:
111
+ parts.append(
112
+ {
113
+ "kind": "file",
114
+ "file": {"uri": img.url, "mimeType": "image/*"},
115
+ }
116
+ )
117
+
118
+ # Add audio as file parts
119
+ if audio:
120
+ for aud in audio:
121
+ if hasattr(aud, "url") and aud.url:
122
+ parts.append(
123
+ {
124
+ "kind": "file",
125
+ "file": {"uri": aud.url, "mimeType": "audio/*"},
126
+ }
127
+ )
128
+
129
+ # Add videos as file parts
130
+ if videos:
131
+ for vid in videos:
132
+ if hasattr(vid, "url") and vid.url:
133
+ parts.append(
134
+ {
135
+ "kind": "file",
136
+ "file": {"uri": vid.url, "mimeType": "video/*"},
137
+ }
138
+ )
139
+
140
+ # Add files as file parts
141
+ if files:
142
+ for f in files:
143
+ if hasattr(f, "url") and f.url:
144
+ mime_type = getattr(f, "mime_type", "application/octet-stream")
145
+ parts.append(
146
+ {
147
+ "kind": "file",
148
+ "file": {"uri": f.url, "mimeType": mime_type},
149
+ }
150
+ )
151
+
152
+ # Build metadata
153
+ msg_metadata: Dict[str, Any] = {}
154
+ if user_id:
155
+ msg_metadata["userId"] = user_id
156
+ if metadata:
157
+ msg_metadata.update(metadata)
158
+
159
+ # Build the message object, excluding null values
160
+ message_obj: Dict[str, Any] = {
161
+ "messageId": message_id,
162
+ "role": "user",
163
+ "parts": parts,
164
+ }
165
+ if context_id:
166
+ message_obj["contextId"] = context_id
167
+ if msg_metadata:
168
+ message_obj["metadata"] = msg_metadata
169
+
170
+ # Build the request
171
+ return {
172
+ "jsonrpc": "2.0",
173
+ "method": "message/stream" if stream else "message/send",
174
+ "id": message_id,
175
+ "params": {"message": message_obj},
176
+ }
177
+
178
+ def _parse_task_result(self, response_data: Dict[str, Any]) -> TaskResult:
179
+ """Parse A2A response into TaskResult.
180
+
181
+ Args:
182
+ response_data: Raw JSON-RPC response
183
+
184
+ Returns:
185
+ TaskResult with parsed content
186
+ """
187
+ result = response_data.get("result", {})
188
+
189
+ # Handle both direct task and nested task formats
190
+ task = result if "id" in result else result.get("task", result)
191
+
192
+ # Extract task metadata
193
+ task_id = task.get("id", "")
194
+ context_id = task.get("context_id", task.get("contextId", ""))
195
+ status_obj = task.get("status", {})
196
+ status = status_obj.get("state", "unknown") if isinstance(status_obj, dict) else str(status_obj)
197
+
198
+ # Extract content from history
199
+ content_parts: List[str] = []
200
+ for msg in task.get("history", []):
201
+ if msg.get("role") == "agent":
202
+ for part in msg.get("parts", []):
203
+ part_data = part.get("root", part) # Handle wrapped parts
204
+ if part_data.get("kind") == "text" or "text" in part_data:
205
+ text = part_data.get("text", "")
206
+ if text:
207
+ content_parts.append(text)
208
+
209
+ # Extract artifacts
210
+ artifacts: List[Artifact] = []
211
+ for artifact_data in task.get("artifacts", []):
212
+ artifacts.append(
213
+ Artifact(
214
+ artifact_id=artifact_data.get("artifact_id", artifact_data.get("artifactId", "")),
215
+ name=artifact_data.get("name"),
216
+ description=artifact_data.get("description"),
217
+ mime_type=artifact_data.get("mime_type", artifact_data.get("mimeType")),
218
+ uri=artifact_data.get("uri"),
219
+ )
220
+ )
221
+
222
+ return TaskResult(
223
+ task_id=task_id,
224
+ context_id=context_id,
225
+ status=status,
226
+ content="".join(content_parts),
227
+ artifacts=artifacts,
228
+ metadata=task.get("metadata"),
229
+ )
230
+
231
+ def _parse_stream_event(self, data: Dict[str, Any]) -> StreamEvent:
232
+ """Parse streaming response line into StreamEvent.
233
+
234
+ Args:
235
+ data: Parsed JSON from stream line
236
+
237
+ Returns:
238
+ StreamEvent with parsed data
239
+ """
240
+ result = data.get("result", {})
241
+
242
+ # Determine event type from various indicators
243
+ event_type = "unknown"
244
+ content = None
245
+ is_final = False
246
+ task_id = result.get("taskId", result.get("task_id"))
247
+ context_id = result.get("contextId", result.get("context_id"))
248
+ metadata = result.get("metadata")
249
+
250
+ # Use the 'kind' field to determine event type (A2A protocol standard)
251
+ kind = result.get("kind", "")
252
+ if kind == "task":
253
+ # Final task result
254
+ event_type = "task"
255
+ is_final = True
256
+ task_id = result.get("id", task_id)
257
+ # Extract content from history
258
+ for msg in result.get("history", []):
259
+ if msg.get("role") == "agent":
260
+ for part in msg.get("parts", []):
261
+ if part.get("kind") == "text" or "text" in part:
262
+ content = part.get("text", "")
263
+ break
264
+
265
+ elif kind == "status-update":
266
+ # Status update event
267
+ is_final = result.get("final", False)
268
+ status = result.get("status", {})
269
+ state = status.get("state", "") if isinstance(status, dict) else ""
270
+
271
+ event_type = state if state in {"working", "completed", "failed", "canceled"} else "status"
272
+
273
+ elif kind == "message":
274
+ # Content message event
275
+ event_type = "content"
276
+
277
+ if metadata and metadata.get("agno_content_category") == "reasoning":
278
+ event_type = "reasoning"
279
+
280
+ # Extract text content from parts
281
+ for part in result.get("parts", []):
282
+ if part.get("kind") == "text" or "text" in part:
283
+ content = part.get("text", "")
284
+ break
285
+ elif kind == "artifact-update":
286
+ event_type = "content"
287
+ artifact = result.get("artifact", {})
288
+ for part in artifact.get("parts", []):
289
+ if part.get("kind") == "text" or "text" in part:
290
+ content = part.get("text", "")
291
+ break
292
+
293
+ # Fallback parsing for non-standard formats
294
+ elif "history" in result:
295
+ event_type = "task"
296
+ is_final = True
297
+ task_id = result.get("id", task_id)
298
+ for msg in result.get("history", []):
299
+ if msg.get("role") == "agent":
300
+ for part in msg.get("parts", []):
301
+ part_data = part.get("root", part)
302
+ if "text" in part_data:
303
+ content = part_data.get("text", "")
304
+ break
305
+
306
+ elif "messageId" in result or "message_id" in result or "parts" in result:
307
+ event_type = "content"
308
+ for part in result.get("parts", []):
309
+ part_data = part.get("root", part)
310
+ if "text" in part_data:
311
+ content = part_data.get("text", "")
312
+ break
313
+
314
+ return StreamEvent(
315
+ event_type=event_type,
316
+ content=content,
317
+ task_id=task_id,
318
+ context_id=context_id,
319
+ metadata=metadata,
320
+ is_final=is_final,
321
+ )
322
+
323
+ async def send_message(
324
+ self,
325
+ message: str,
326
+ *,
327
+ context_id: Optional[str] = None,
328
+ user_id: Optional[str] = None,
329
+ images: Optional[List[Image]] = None,
330
+ audio: Optional[List[Audio]] = None,
331
+ videos: Optional[List[Video]] = None,
332
+ files: Optional[List[File]] = None,
333
+ metadata: Optional[Dict[str, Any]] = None,
334
+ headers: Optional[Dict[str, str]] = None,
335
+ ) -> TaskResult:
336
+ """Send a message to an A2A agent and wait for the response.
337
+
338
+ Args:
339
+ message: Text message to send
340
+ context_id: Session/context ID for multi-turn conversations
341
+ user_id: User identifier (optional)
342
+ images: List of Image objects to include (optional)
343
+ audio: List of Audio objects to include (optional)
344
+ videos: List of Video objects to include (optional)
345
+ files: List of File objects to include (optional)
346
+ metadata: Additional metadata (optional)
347
+ headers: HTTP headers to include in the request (optional)
348
+ Returns:
349
+ TaskResult containing the agent's response
350
+
351
+ Raises:
352
+ HTTPStatusError: If the server returns an HTTP error (4xx, 5xx)
353
+ RemoteServerUnavailableError: If connection fails or times out
354
+ """
355
+ client = get_default_async_client()
356
+
357
+ request_body = self._build_message_request(
358
+ message=message,
359
+ context_id=context_id,
360
+ user_id=user_id,
361
+ images=images,
362
+ audio=audio,
363
+ videos=videos,
364
+ files=files,
365
+ metadata=metadata,
366
+ stream=False,
367
+ )
368
+ try:
369
+ response = await client.post(
370
+ self._get_endpoint(path="/v1/message:send"),
371
+ json=request_body,
372
+ timeout=self.timeout,
373
+ headers=headers,
374
+ )
375
+ response.raise_for_status()
376
+ response_data = response.json()
377
+
378
+ return self._parse_task_result(response_data)
379
+
380
+ except (ConnectError, ConnectTimeout) as e:
381
+ raise RemoteServerUnavailableError(
382
+ message=f"Failed to connect to A2A server at {self.base_url}",
383
+ base_url=self.base_url,
384
+ original_error=e,
385
+ ) from e
386
+ except TimeoutException as e:
387
+ raise RemoteServerUnavailableError(
388
+ message=f"Request to A2A server at {self.base_url} timed out",
389
+ base_url=self.base_url,
390
+ original_error=e,
391
+ ) from e
392
+
393
+ async def stream_message(
394
+ self,
395
+ message: str,
396
+ *,
397
+ context_id: Optional[str] = None,
398
+ user_id: Optional[str] = None,
399
+ images: Optional[List[Image]] = None,
400
+ audio: Optional[List[Audio]] = None,
401
+ videos: Optional[List[Video]] = None,
402
+ files: Optional[List[File]] = None,
403
+ metadata: Optional[Dict[str, Any]] = None,
404
+ headers: Optional[Dict[str, str]] = None,
405
+ ) -> AsyncIterator[StreamEvent]:
406
+ """Stream a message to an A2A agent with real-time events.
407
+
408
+ Args:
409
+ message: Text message to send
410
+ context_id: Session/context ID for multi-turn conversations
411
+ user_id: User identifier (optional)
412
+ images: List of Image objects to include (optional)
413
+ audio: List of Audio objects to include (optional)
414
+ videos: List of Video objects to include (optional)
415
+ files: List of File objects to include (optional)
416
+ metadata: Additional metadata (optional)
417
+ headers: HTTP headers to include in the request (optional)
418
+ Yields:
419
+ StreamEvent objects for each event in the stream
420
+
421
+ Raises:
422
+ HTTPStatusError: If the server returns an HTTP error (4xx, 5xx)
423
+ RemoteServerUnavailableError: If connection fails or times out
424
+
425
+ Example:
426
+ ```python
427
+ async for event in client.stream_message("agent", "Hello"):
428
+ if event.is_content and event.content:
429
+ print(event.content, end="", flush=True)
430
+ elif event.is_final:
431
+ print() # Newline at end
432
+ ```
433
+ """
434
+ http_client = get_default_async_client()
435
+
436
+ request_body = self._build_message_request(
437
+ message=message,
438
+ context_id=context_id,
439
+ user_id=user_id,
440
+ images=images,
441
+ audio=audio,
442
+ videos=videos,
443
+ files=files,
444
+ metadata=metadata,
445
+ stream=True,
446
+ )
447
+
448
+ try:
449
+ if headers is None:
450
+ headers = {}
451
+ if "Accept" not in headers:
452
+ headers["Accept"] = "text/event-stream"
453
+ if "Cache-Control" not in headers:
454
+ headers["Cache-Control"] = "no-store"
455
+
456
+ async with http_client.stream(
457
+ "POST",
458
+ self._get_endpoint("/v1/message:stream"),
459
+ json=request_body,
460
+ timeout=self.timeout,
461
+ headers=headers,
462
+ ) as response:
463
+ response.raise_for_status()
464
+
465
+ async for line in response.aiter_lines():
466
+ line = line.strip()
467
+ if not line:
468
+ continue
469
+
470
+ # Handle SSE format: skip "event:" lines, parse "data:" lines
471
+ if line.startswith("event:"):
472
+ continue
473
+ if line.startswith("data:"):
474
+ line = line[5:].strip() # Remove "data:" prefix
475
+
476
+ try:
477
+ data = json.loads(line)
478
+ event = self._parse_stream_event(data)
479
+ yield event
480
+
481
+ # Check for task start to capture IDs
482
+ if event.event_type == "started":
483
+ pass # Could store task_id/context_id if needed
484
+
485
+ except json.JSONDecodeError as e:
486
+ log_warning(f"Failed to decode JSON from stream line: {line[:100]}. Error: {e}")
487
+ continue
488
+
489
+ except (ConnectError, ConnectTimeout) as e:
490
+ raise RemoteServerUnavailableError(
491
+ message=f"Failed to connect to A2A server at {self.base_url}",
492
+ base_url=self.base_url,
493
+ original_error=e,
494
+ ) from e
495
+ except TimeoutException as e:
496
+ raise RemoteServerUnavailableError(
497
+ message=f"Request to A2A server at {self.base_url} timed out",
498
+ base_url=self.base_url,
499
+ original_error=e,
500
+ ) from e
501
+
502
+ def get_agent_card(self, headers: Optional[Dict[str, str]] = None) -> Optional[AgentCard]:
503
+ """Get agent card for capability discovery.
504
+
505
+ Note: Not all A2A servers support agent cards. This method returns
506
+ None if the server doesn't provide an agent card.
507
+
508
+ Returns:
509
+ AgentCard if available, None otherwise
510
+ """
511
+ client = get_default_sync_client()
512
+
513
+ agent_card_path = "/.well-known/agent-card.json"
514
+ url = self._get_endpoint(path=agent_card_path)
515
+ response = client.get(url, timeout=self.timeout, headers=headers)
516
+ if response.status_code != 200:
517
+ return None
518
+
519
+ data = response.json()
520
+ return AgentCard(
521
+ name=data.get("name", "Unknown"),
522
+ url=data.get("url", self.base_url),
523
+ description=data.get("description"),
524
+ version=data.get("version"),
525
+ capabilities=data.get("capabilities", []),
526
+ metadata=data.get("metadata"),
527
+ )
528
+
529
+ async def aget_agent_card(self, headers: Optional[Dict[str, str]] = None) -> Optional[AgentCard]:
530
+ """Get agent card for capability discovery.
531
+
532
+ Note: Not all A2A servers support agent cards. This method returns
533
+ None if the server doesn't provide an agent card.
534
+
535
+ Returns:
536
+ AgentCard if available, None otherwise
537
+ """
538
+ client = get_default_async_client()
539
+
540
+ agent_card_path = "/.well-known/agent-card.json"
541
+ url = self._get_endpoint(path=agent_card_path)
542
+ response = await client.get(url, timeout=self.timeout, headers=headers)
543
+ if response.status_code != 200:
544
+ return None
545
+
546
+ data = response.json()
547
+ return AgentCard(
548
+ name=data.get("name", "Unknown"),
549
+ url=data.get("url", self.base_url),
550
+ description=data.get("description"),
551
+ version=data.get("version"),
552
+ capabilities=data.get("capabilities", []),
553
+ metadata=data.get("metadata"),
554
+ )
@@ -0,0 +1,112 @@
1
+ """Agno-friendly schemas for A2A protocol responses.
2
+
3
+ These schemas provide a simplified, Pythonic interface for working with
4
+ A2A protocol responses, abstracting away the JSON-RPC complexity.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional
9
+
10
+
11
+ @dataclass
12
+ class Artifact:
13
+ """Artifact from an A2A task (files, images, etc.)."""
14
+
15
+ artifact_id: str
16
+ name: Optional[str] = None
17
+ description: Optional[str] = None
18
+ mime_type: Optional[str] = None
19
+ uri: Optional[str] = None
20
+ content: Optional[bytes] = None
21
+
22
+
23
+ @dataclass
24
+ class TaskResult:
25
+ """Result from a non-streaming A2A message.
26
+
27
+ Attributes:
28
+ task_id: Unique identifier for the task
29
+ context_id: Session/context identifier for multi-turn conversations
30
+ status: Task status ("completed", "failed", "canceled")
31
+ content: Text content from the agent's response
32
+ artifacts: List of artifacts (files, images, etc.)
33
+ metadata: Additional metadata from the response
34
+ """
35
+
36
+ task_id: str
37
+ context_id: str
38
+ status: str
39
+ content: str
40
+ artifacts: List[Artifact] = field(default_factory=list)
41
+ metadata: Optional[Dict[str, Any]] = None
42
+
43
+ @property
44
+ def is_completed(self) -> bool:
45
+ """Check if task completed successfully."""
46
+ return self.status == "completed"
47
+
48
+ @property
49
+ def is_failed(self) -> bool:
50
+ """Check if task failed."""
51
+ return self.status == "failed"
52
+
53
+ @property
54
+ def is_canceled(self) -> bool:
55
+ """Check if task was canceled."""
56
+ return self.status == "canceled"
57
+
58
+
59
+ @dataclass
60
+ class StreamEvent:
61
+ """Event from a streaming A2A message.
62
+
63
+ Attributes:
64
+ event_type: Type of event (e.g., "started", "content", "tool_call", "completed")
65
+ content: Text content (for content events)
66
+ task_id: Task identifier
67
+ context_id: Session/context identifier
68
+ metadata: Additional event metadata
69
+ is_final: Whether this is the final event in the stream
70
+ """
71
+
72
+ event_type: str
73
+ content: Optional[str] = None
74
+ task_id: Optional[str] = None
75
+ context_id: Optional[str] = None
76
+ metadata: Optional[Dict[str, Any]] = None
77
+ is_final: bool = False
78
+
79
+ @property
80
+ def is_content(self) -> bool:
81
+ """Check if this is a content event with text."""
82
+ return self.event_type == "content" and self.content is not None
83
+
84
+ @property
85
+ def is_started(self) -> bool:
86
+ """Check if this is a task started event."""
87
+ return self.event_type == "started"
88
+
89
+ @property
90
+ def is_completed(self) -> bool:
91
+ """Check if this is a task completed event."""
92
+ return self.event_type == "completed"
93
+
94
+ @property
95
+ def is_tool_call(self) -> bool:
96
+ """Check if this is a tool call event."""
97
+ return self.event_type in ("tool_call_started", "tool_call_completed")
98
+
99
+
100
+ @dataclass
101
+ class AgentCard:
102
+ """Agent capability discovery card.
103
+
104
+ Describes the capabilities and metadata of an A2A-compatible agent.
105
+ """
106
+
107
+ name: str
108
+ url: str
109
+ description: Optional[str] = None
110
+ version: Optional[str] = None
111
+ capabilities: List[str] = field(default_factory=list)
112
+ metadata: Optional[Dict[str, Any]] = None