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.
- agno/agent/agent.py +26 -1
- agno/agent/remote.py +233 -72
- agno/client/a2a/__init__.py +10 -0
- agno/client/a2a/client.py +554 -0
- agno/client/a2a/schemas.py +112 -0
- agno/client/a2a/utils.py +369 -0
- agno/db/migrations/utils.py +19 -0
- agno/db/migrations/v1_to_v2.py +54 -16
- agno/db/migrations/versions/v2_3_0.py +92 -53
- agno/db/postgres/async_postgres.py +162 -40
- agno/db/postgres/postgres.py +181 -31
- agno/db/postgres/utils.py +6 -2
- agno/knowledge/chunking/document.py +3 -2
- agno/knowledge/chunking/markdown.py +8 -3
- agno/knowledge/chunking/recursive.py +2 -2
- agno/models/openai/chat.py +1 -1
- agno/models/openai/responses.py +14 -7
- agno/os/middleware/jwt.py +66 -27
- agno/os/routers/agents/router.py +2 -2
- agno/os/routers/knowledge/knowledge.py +3 -3
- agno/os/routers/teams/router.py +2 -2
- agno/os/routers/workflows/router.py +2 -2
- agno/reasoning/deepseek.py +11 -1
- agno/reasoning/gemini.py +6 -2
- agno/reasoning/groq.py +8 -3
- agno/reasoning/openai.py +2 -0
- agno/remote/base.py +105 -8
- agno/skills/__init__.py +17 -0
- agno/skills/agent_skills.py +370 -0
- agno/skills/errors.py +32 -0
- agno/skills/loaders/__init__.py +4 -0
- agno/skills/loaders/base.py +27 -0
- agno/skills/loaders/local.py +216 -0
- agno/skills/skill.py +65 -0
- agno/skills/utils.py +107 -0
- agno/skills/validator.py +277 -0
- agno/team/remote.py +219 -59
- agno/team/team.py +22 -2
- agno/tools/mcp/mcp.py +299 -17
- agno/tools/mcp/multi_mcp.py +269 -14
- agno/utils/mcp.py +49 -8
- agno/utils/string.py +43 -1
- agno/workflow/condition.py +4 -2
- agno/workflow/loop.py +20 -1
- agno/workflow/remote.py +172 -32
- agno/workflow/router.py +4 -1
- agno/workflow/steps.py +4 -0
- {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/METADATA +13 -14
- {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/RECORD +52 -38
- {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/WHEEL +0 -0
- {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/licenses/LICENSE +0 -0
- {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
|