feat: A2A Client/Server Implementation

Rafael Uzarowski committed Jul 29, 2025 at 17:54 UTC d84d24b3bedea09b4880b2d888847c59df5dd83a
11 files changed +829 -15
prompts/agent.system.tool.a2a_chat.md new
+73
@@ -0,0 +1,73 @@
1 +### a2a_chat:
2 +This tool lets Agent Zero chat with any other FastA2A-compatible agent.
3 +It automatically keeps conversation **context** (so each subsequent call
4 +continues the same dialogue) and supports optional file attachments.
5 +
6 +#### What the tool can do
7 +* Start a brand-new conversation with a remote agent.
8 +* Continue an existing conversation transparently (context handled for you).
9 +* Send text plus optional file URIs (images, docs, etc.).
10 +* Receive the assistant’s reply as plain text.
11 +
12 +#### Arguments
13 +* `agent_url` (string, required) – Base URL of the *remote* agent.
14 + • Accepts `host:port`, `http://host:port`, or full path ending in `/a2a`.
15 +* `message` (string, required) – The text you want to send.
16 +* `attachments` (list[string], optional) – URIs pointing to files you want
17 + to send along with the message (can be http(s):// or file path).
18 +* `reset` (boolean, optional) – Set to `true` to start a **new** conversation
19 + with the same `agent_url` (clears stored context). Default `false`.
20 +
21 +> Leave **context_id** out – the tool handles it internally.
22 +
23 +#### Usage – first message
24 +##### Request
25 +```json
26 +{
27 + "thoughts": [
28 + "I want to ask the weather-bot for today’s forecast."
29 + ],
30 + "headline": "Ask remote agent (weather-bot)",
31 + "tool_name": "a2a_chat",
32 + "tool_args": {
33 + "agent_url": "http://weather.example.com:8000/a2a",
34 + "message": "Hello! What’s the forecast for Berlin today?",
35 + "attachments": [],
36 + "reset": false
37 + }
38 +}
39 +```
40 +##### Response (assistant-side)
41 +```plaintext
42 +☀️ It will be sunny with a high of 22 °C.
43 +```
44 +
45 +#### Usage – follow-up (context automatically preserved)
46 +##### Request
47 +```json
48 +{
49 + "thoughts": [
50 + "Need tomorrow’s forecast too."
51 + ],
52 + "headline": "Follow-up question",
53 + "tool_name": "a2a_chat",
54 + "tool_args": {
55 + "agent_url": "http://weather.example.com:8000/a2a",
56 + "message": "And tomorrow?",
57 + "attachments": [],
58 + "reset": false
59 + }
60 +}
61 +```
62 +##### Response
63 +```plaintext
64 +🌦️ Partly cloudy with showers, high 18 °C.
65 +```
66 +
67 +#### Notes
68 +1. **New conversation** – omit previous `agent_url` or use a *different* URL.
69 +2. **Attachments** – supply absolute URIs ("http://…", "file:/…").
70 +3. The tool stores session IDs per `agent_url` inside the current
71 + `AgentContext` – no manual handling required.
72 +4. Use `"reset": true` to forget previous context and start a new chat.
73 +5. The remote agent must implement FastA2A v0.2+ protocol.
python/helpers/fasta2a_client.py new
+209
@@ -0,0 +1,209 @@
1 +import uuid
2 +from typing import Any, Dict, List, Optional
3 +from python.helpers.print_style import PrintStyle
4 +
5 +try:
6 + from fasta2a.client import A2AClient # type: ignore
7 + import httpx # type: ignore
8 + FASTA2A_CLIENT_AVAILABLE = True
9 +except ImportError:
10 + FASTA2A_CLIENT_AVAILABLE = False
11 + PrintStyle.warning("FastA2A client not available. Agent-to-agent communication disabled.")
12 +
13 +_PRINTER = PrintStyle(italic=True, font_color="cyan", padding=False)
14 +
15 +
16 +class AgentConnection:
17 + """Helper class for connecting to and communicating with other Agent Zero instances via FastA2A."""
18 +
19 + def __init__(self, agent_url: str, timeout: int = 30, token: Optional[str] = None):
20 + """Initialize connection to an agent.
21 +
22 + Args:
23 + agent_url: The base URL of the agent (e.g., "https://agent.example.com")
24 + timeout: Request timeout in seconds
25 + """
26 + if not FASTA2A_CLIENT_AVAILABLE:
27 + raise RuntimeError("FastA2A client not available")
28 +
29 + # Ensure scheme is present
30 + if not agent_url.startswith(('http://', 'https://')):
31 + agent_url = 'http://' + agent_url
32 +
33 + self.agent_url = agent_url.rstrip('/')
34 + self.timeout = timeout
35 + # Auth headers
36 + if token is None:
37 + import os
38 + token = os.getenv("A2A_TOKEN")
39 + headers = {}
40 + if token:
41 + headers["Authorization"] = f"Bearer {token}"
42 + headers["X-API-KEY"] = token
43 + self._http_client = httpx.AsyncClient(timeout=timeout, headers=headers) # type: ignore
44 + self._a2a_client = A2AClient(base_url=self.agent_url, http_client=self._http_client) # type: ignore
45 + self._agent_card: Optional[Dict[str, Any]] = None
46 + # Track conversation context automatically
47 + self._context_id: Optional[str] = None
48 +
49 + async def get_agent_card(self) -> Dict[str, Any]:
50 + """Retrieve the agent card from the remote agent."""
51 + if self._agent_card is None:
52 + try:
53 + response = await self._http_client.get(f"{self.agent_url}/.well-known/agent.json")
54 + response.raise_for_status()
55 + self._agent_card = response.json()
56 + _PRINTER.print(f"Retrieved agent card from {self.agent_url}")
57 + _PRINTER.print(f"Agent: {self._agent_card.get('name', 'Unknown')}")
58 + _PRINTER.print(f"Description: {self._agent_card.get('description', 'No description')}")
59 + except Exception as e:
60 + # Fallback: if URL contains '/a2a', try root path without it
61 + if "/a2a" in self.agent_url:
62 + root_url = self.agent_url.split("/a2a", 1)[0]
63 + try:
64 + response = await self._http_client.get(f"{root_url}/.well-known/agent.json")
65 + response.raise_for_status()
66 + self._agent_card = response.json()
67 + _PRINTER.print(f"Retrieved agent card from {root_url}")
68 + except Exception:
69 + pass # swallow, will re-raise below
70 + _PRINTER.print(f"[!] Could not connect to {self.agent_url}\n → Ensure the server is running and reachable.\n → Full error: {e}")
71 + raise RuntimeError(f"Could not retrieve agent card: {e}")
72 +
73 + return self._agent_card # type: ignore
74 +
75 + async def send_message(
76 + self,
77 + message: str,
78 + attachments: Optional[List[str]] = None,
79 + context_id: Optional[str] = None,
80 + metadata: Optional[Dict[str, Any]] = None
81 + ) -> Dict[str, Any]:
82 + """Send a message to the remote agent and return task response."""
83 + if not self._agent_card:
84 + await self.get_agent_card()
85 +
86 + # Re-use context automatically if caller did not supply one
87 + if context_id is None:
88 + context_id = self._context_id
89 +
90 + # Build message parts
91 + parts = [{'kind': 'text', 'text': message}]
92 +
93 + if attachments:
94 + for attachment in attachments:
95 + file_part = {'kind': 'file', 'file': {'uri': attachment}}
96 + parts.append(file_part) # type: ignore
97 +
98 + # Construct A2A message
99 + a2a_message = {
100 + 'role': 'user',
101 + 'parts': parts,
102 + 'kind': 'message',
103 + 'message_id': str(uuid.uuid4())
104 + }
105 +
106 + if context_id is not None:
107 + a2a_message['context_id'] = context_id
108 +
109 + # Send using the message/send method (not send_task)
110 + try:
111 + response = await self._a2a_client.send_message(
112 + message=a2a_message, # type: ignore
113 + metadata=metadata,
114 + configuration={'accepted_output_modes': ['application/json', 'text/plain'], 'blocking': True} # type: ignore
115 + )
116 +
117 + # Persist context id for subsequent calls
118 + try:
119 + ctx = response.get('result', {}).get('context_id') # type: ignore[index]
120 + if isinstance(ctx, str):
121 + self._context_id = ctx
122 + except Exception:
123 + pass # ignore if structure differs
124 + return response # type: ignore
125 + except Exception as e:
126 + _PRINTER.print(f"[A2A] Error sending message: {e}")
127 + raise
128 +
129 + async def get_task(self, task_id: str) -> Dict[str, Any]:
130 + """Get the status and results of a task.
131 +
132 + Args:
133 + task_id: The ID of the task to query
134 +
135 + Returns:
136 + Dictionary containing the task information
137 + """
138 + try:
139 + response = await self._a2a_client.get_task(task_id) # type: ignore
140 + return response # type: ignore
141 + except Exception as e:
142 + _PRINTER.print(f"Failed to get task {task_id}: {e}")
143 + raise RuntimeError(f"Failed to get task: {e}")
144 +
145 + async def wait_for_completion(self, task_id: str, poll_interval: int = 2, max_wait: int = 300) -> Dict[str, Any]:
146 + """Wait for a task to complete and return the final result.
147 +
148 + Args:
149 + task_id: The ID of the task to wait for
150 + poll_interval: How often to check task status (seconds)
151 + max_wait: Maximum time to wait (seconds)
152 +
153 + Returns:
154 + Dictionary containing the completed task information
155 + """
156 + import asyncio
157 +
158 + waited = 0
159 + while waited < max_wait:
160 + task_info = await self.get_task(task_id)
161 +
162 + if 'result' in task_info:
163 + task = task_info['result']
164 + status = task.get('status', {})
165 + state = status.get('state', 'unknown')
166 +
167 + if state in ['completed', 'failed', 'canceled']:
168 + _PRINTER.print(f"Task {task_id} finished with state: {state}")
169 + return task_info
170 + else:
171 + _PRINTER.print(f"Task {task_id} status: {state}")
172 +
173 + await asyncio.sleep(poll_interval)
174 + waited += poll_interval
175 +
176 + raise TimeoutError(f"Task {task_id} did not complete within {max_wait} seconds")
177 +
178 + async def close(self):
179 + """Close the HTTP client connection."""
180 + await self._http_client.aclose()
181 +
182 + async def __aenter__(self):
183 + """Async context manager entry."""
184 + return self
185 +
186 + async def __aexit__(self, exc_type, exc_val, exc_tb):
187 + """Async context manager exit."""
188 + await self.close()
189 +
190 +
191 +async def connect_to_agent(agent_url: str, timeout: int = 30) -> AgentConnection:
192 + """Create a connection to a remote agent.
193 +
194 + Args:
195 + agent_url: The base URL of the agent
196 + timeout: Request timeout in seconds
197 +
198 + Returns:
199 + AgentConnection instance
200 + """
201 + connection = AgentConnection(agent_url, timeout)
202 + # Verify connection by retrieving agent card
203 + await connection.get_agent_card()
204 + return connection
205 +
206 +
207 +def is_client_available() -> bool:
208 + """Check if FastA2A client is available."""
209 + return FASTA2A_CLIENT_AVAILABLE
python/helpers/fasta2a_server.py new
+325
@@ -0,0 +1,325 @@
1 +# noqa: D401 (docstrings) – internal helper
2 +import asyncio
3 +import uuid
4 +import atexit
5 +from typing import Any, List
6 +import contextlib
7 +
8 +from python.helpers import settings
9 +from starlette.middleware.base import BaseHTTPMiddleware
10 +from starlette.requests import Request
11 +from starlette.responses import PlainTextResponse
12 +from starlette.middleware import Middleware
13 +
14 +# Local imports
15 +from python.helpers.print_style import PrintStyle
16 +from agent import AgentContext, UserMessage
17 +from initialize import initialize_agent
18 +
19 +# Attempt to import FastA2A – fall back to stubs so linters stay quiet
20 +try:
21 + from fasta2a import Worker # type: ignore
22 + from fasta2a.broker import InMemoryBroker # type: ignore
23 + from fasta2a.storage import InMemoryStorage # type: ignore
24 + from fasta2a.schema import Message, Artifact, AgentProvider, Skill # type: ignore
25 +
26 + FASTA2A_AVAILABLE = True
27 +except ImportError: # pragma: no cover – library not installed
28 + FASTA2A_AVAILABLE = False
29 +
30 + class Worker: # type: ignore
31 + """Stub so type-checkers don’t complain when FastA2A is absent."""
32 +
33 + pass
34 +
35 + # Minimal stubs for type checkers
36 + Message = Artifact = AgentProvider = Skill = Any # type: ignore
37 + InMemoryBroker = InMemoryStorage = object # type: ignore
38 +
39 +_PRINTER = PrintStyle(italic=True, font_color="purple", padding=False)
40 +
41 +if FASTA2A_AVAILABLE:
42 + class AgentZeroWorker(Worker): # type: ignore[misc]
43 + """Agent Zero implementation of FastA2A Worker."""
44 +
45 + async def run_task(self, params: Any) -> None: # params: TaskSendParams
46 + """Execute a task by processing the message through Agent Zero."""
47 + try:
48 + task_id = params['id']
49 + context_id = params['context_id']
50 + message = params['message']
51 +
52 + _PRINTER.print(f"[A2A] Processing task {task_id} in context {context_id}")
53 +
54 + # Convert A2A message to Agent Zero format
55 + agent_message = self._convert_message(message)
56 +
57 + # Get or create Agent Zero context
58 + context = AgentContext.get(context_id)
59 + if not context:
60 + # Create new context for this A2A conversation
61 + cfg = initialize_agent()
62 + context = AgentContext(cfg, id=context_id)
63 +
64 + # Log user message so it appears instantly in UI chat window
65 + context.log.log(
66 + type="user", # type: ignore[arg-type]
67 + heading="Remote user message",
68 + content=agent_message.message,
69 + kvps={"from": "A2A"},
70 + temp=False,
71 + )
72 +
73 + # Process message through Agent Zero (includes response)
74 + task = context.communicate(agent_message)
75 + result_text = await task.result()
76 +
77 + # Build A2A message from result
78 + response_message: Message = { # type: ignore
79 + 'role': 'agent',
80 + 'parts': [{'kind': 'text', 'text': str(result_text)}],
81 + 'kind': 'message',
82 + 'message_id': str(uuid.uuid4())
83 + }
84 +
85 + await self.storage.update_task( # type: ignore[attr-defined]
86 + task_id=task_id,
87 + state='completed',
88 + new_messages=[response_message]
89 + )
90 +
91 + _PRINTER.print(f"[A2A] Completed task {task_id}")
92 +
93 + except Exception as e:
94 + _PRINTER.print(f"[A2A] Error processing task {params.get('id', 'unknown')}: {e}")
95 + await self.storage.update_task(
96 + task_id=params.get('id', 'unknown'),
97 + state='failed'
98 + )
99 +
100 + async def cancel_task(self, params: Any) -> None: # params: TaskIdParams
101 + """Cancel a running task."""
102 + task_id = params['id']
103 + _PRINTER.print(f"[A2A] Cancelling task {task_id}")
104 + await self.storage.update_task(task_id=task_id, state='canceled') # type: ignore[attr-defined]
105 +
106 + def build_message_history(self, history: List[Any]) -> List[Message]: # type: ignore
107 + # Not used in this simplified implementation
108 + return []
109 +
110 + def build_artifacts(self, result: Any) -> List[Artifact]: # type: ignore
111 + # No artifacts for now
112 + return []
113 +
114 + def _convert_message(self, a2a_message: Message) -> UserMessage: # type: ignore
115 + """Convert A2A message to Agent Zero UserMessage."""
116 + # Extract text from message parts
117 + text_parts = [part.get('text', '') for part in a2a_message.get('parts', []) if part.get('kind') == 'text']
118 + message_text = '\n'.join(text_parts)
119 +
120 + # Extract file attachments
121 + attachments = []
122 + for part in a2a_message.get('parts', []):
123 + if part.get('kind') == 'file':
124 + file_info = part.get('file', {})
125 + if 'uri' in file_info:
126 + attachments.append(file_info['uri'])
127 +
128 + return UserMessage(
129 + message=message_text,
130 + attachments=attachments
131 + )
132 +
133 +
134 +class DynamicA2AProxy:
135 + """Dynamic proxy for FastA2A server that allows reconfiguration."""
136 +
137 + _instance = None
138 +
139 + def __init__(self):
140 + self.app = None
141 + self._async_lock = asyncio.Lock()
142 + self._startup_done: bool = False
143 + self._worker_bg_task: asyncio.Task | None = None
144 +
145 + if FASTA2A_AVAILABLE:
146 + self._configure()
147 + self._register_shutdown()
148 +
149 + @staticmethod
150 + def get_instance():
151 + if DynamicA2AProxy._instance is None:
152 + DynamicA2AProxy._instance = DynamicA2AProxy()
153 + return DynamicA2AProxy._instance
154 +
155 + def _configure(self):
156 + """Configure the FastA2A application with Agent Zero integration."""
157 + try:
158 + # Import inside the method to handle missing dependencies gracefully
159 + from fasta2a import FastA2A # type: ignore
160 + from fasta2a.broker import InMemoryBroker # type: ignore
161 + from fasta2a.storage import InMemoryStorage # type: ignore
162 +
163 + storage = InMemoryStorage() # type: ignore[arg-type]
164 + broker = InMemoryBroker() # type: ignore[arg-type]
165 +
166 + # Define Agent Zero's skills
167 + skills: List[Skill] = [{ # type: ignore
168 + "id": "general_assistance",
169 + "name": "General AI Assistant",
170 + "description": "Provides general AI assistance including code execution, file management, web browsing, and problem solving",
171 + "tags": ["ai", "assistant", "code", "files", "web", "automation"],
172 + "examples": [
173 + "Write and execute Python code",
174 + "Manage files and directories",
175 + "Browse the web and extract information",
176 + "Solve complex problems step by step",
177 + "Install software and manage systems"
178 + ],
179 + "input_modes": ["text/plain", "application/octet-stream"],
180 + "output_modes": ["text/plain", "application/json"]
181 + }]
182 +
183 + provider: AgentProvider = { # type: ignore
184 + "organization": "Agent Zero",
185 + "url": "https://github.com/frdel/agent-zero"
186 + }
187 +
188 + # Authentication middleware (Bearer or X-API-KEY)
189 + class A2AAuthMiddleware(BaseHTTPMiddleware):
190 + async def dispatch(self, request: Request, call_next): # type: ignore[override]
191 + cfg = settings.get_settings()
192 + expected = cfg.get("a2a_token") or cfg.get("mcp_server_token")
193 + if not expected:
194 + return await call_next(request) # no auth configured
195 +
196 + auth_header = request.headers.get("Authorization", "")
197 + if auth_header.startswith("Bearer ") and auth_header.split(" ", 1)[1] == expected:
198 + return await call_next(request)
199 +
200 + api_key = request.headers.get("X-API-KEY") or request.query_params.get("api_key")
201 + if api_key == expected:
202 + return await call_next(request)
203 +
204 + # Fallback: check token in mount path (root_path contains the prefix stripped by DispatcherMiddleware)
205 + root_path = request.scope.get("root_path", "") # type: ignore[attr-defined]
206 + if root_path and f"/t-{expected}" in root_path:
207 + return await call_next(request)
208 +
209 + return PlainTextResponse("Unauthorized", 401)
210 +
211 + middleware_list = [Middleware(A2AAuthMiddleware)]
212 +
213 + self.app = FastA2A( # type: ignore
214 + storage=storage,
215 + broker=broker,
216 + name="Agent Zero",
217 + description=(
218 + "A general AI assistant that can execute code, manage files, browse the web, and "
219 + "solve complex problems in an isolated Linux environment."
220 + ),
221 + version="1.0.0",
222 + provider=provider,
223 + skills=skills,
224 + lifespan=None, # We manage lifespan manually
225 + middleware=middleware_list,
226 + )
227 +
228 + # Store for later lazy startup (needs active event-loop)
229 + self._storage = storage # type: ignore[attr-defined]
230 + self._broker = broker # type: ignore[attr-defined]
231 + self._worker = AgentZeroWorker(broker=broker, storage=storage) # type: ignore[attr-defined]
232 +
233 + _PRINTER.print("[A2A] FastA2A server configured successfully")
234 +
235 + except Exception as e:
236 + _PRINTER.print(f"[A2A] Failed to configure FastA2A server: {e}")
237 + self.app = None
238 +
239 + # ---------------------------------------------------------------------
240 + # Shutdown handling
241 + # ---------------------------------------------------------------------
242 +
243 + def _register_shutdown(self):
244 + """Register an atexit hook to gracefully stop worker & task manager."""
245 +
246 + def _sync_shutdown():
247 + try:
248 + if not self._startup_done or not FASTA2A_AVAILABLE:
249 + return
250 + loop = asyncio.new_event_loop()
251 + loop.run_until_complete(self._async_shutdown())
252 + loop.close()
253 + except Exception:
254 + pass # ignore errors during interpreter shutdown
255 +
256 + atexit.register(_sync_shutdown)
257 +
258 + async def _async_shutdown(self):
259 + """Async shutdown: cancel worker task & close task manager."""
260 + if self._worker_bg_task and not self._worker_bg_task.done():
261 + self._worker_bg_task.cancel()
262 + with contextlib.suppress(asyncio.CancelledError):
263 + await self._worker_bg_task
264 + try:
265 + await self.app.task_manager.__aexit__(None, None, None) # type: ignore[attr-defined]
266 + except Exception:
267 + pass
268 +
269 + async def _startup(self):
270 + """Ensure TaskManager and Worker are running inside current event-loop."""
271 + if self._startup_done or not FASTA2A_AVAILABLE:
272 + return
273 + self._startup_done = True
274 +
275 + # Start task manager
276 + await self.app.task_manager.__aenter__() # type: ignore[attr-defined]
277 +
278 + async def _worker_loop():
279 + async with self._worker.run(): # type: ignore[attr-defined]
280 + await asyncio.Event().wait()
281 +
282 + # fire-and-forget background task – keep reference
283 + self._worker_bg_task = asyncio.create_task(_worker_loop())
284 + _PRINTER.print("[A2A] Worker & TaskManager started")
285 +
286 + async def reconfigure(self):
287 + """Reconfigure the FastA2A server (placeholder for future use)."""
288 + async with self._async_lock:
289 + if FASTA2A_AVAILABLE:
290 + self._configure()
291 +
292 + async def __call__(self, scope, receive, send):
293 + """ASGI application interface."""
294 + if self.app is None:
295 + # FastA2A not available, return 503
296 + response = b'HTTP/1.1 503 Service Unavailable\r\n\r\nFastA2A not available'
297 + await send({
298 + 'type': 'http.response.start',
299 + 'status': 503,
300 + 'headers': [[b'content-type', b'text/plain']],
301 + })
302 + await send({
303 + 'type': 'http.response.body',
304 + 'body': response,
305 + })
306 + return
307 +
308 + # Lazy-start background components the first time we get a request
309 + if FASTA2A_AVAILABLE and not self._startup_done:
310 + await self._startup()
311 +
312 + # Delegate to FastA2A app
313 + await self.app(scope, receive, send)
314 +
315 +
316 +def is_available():
317 + """Check if FastA2A is available and properly configured."""
318 + return FASTA2A_AVAILABLE and DynamicA2AProxy.get_instance().app is not None
319 +
320 +
321 +def get_proxy():
322 + """Get the FastA2A proxy instance."""
323 + if not FASTA2A_AVAILABLE:
324 + return None
325 + return DynamicA2AProxy.get_instance()
python/helpers/settings.py
+22 -2
@@ -67,7 +67,7 @@ class Settings(TypedDict):
67 memory_memorize_enabled: bool
68 memory_memorize_consolidation: bool
69 memory_memorize_replace_threshold: float
70 -
70 +
71
72 api_keys: dict[str, str]
73
@@ -493,6 +493,25 @@ def convert_out(settings: Settings) -> SettingsOutput:
493 }
494 )
495
496 + # -------- A2A Section --------
497 + a2a_fields: list[SettingsField] = [
498 + {
499 + "id": "show_a2a_connection",
500 + "title": "Show A2A connection info",
501 + "description": "Display the URL (including token) other agents can use to connect via FastA2A.",
502 + "type": "button",
503 + "value": "Show",
504 + }
505 + ]
506 +
507 + a2a_section: SettingsSection = {
508 + "id": "a2a_server",
509 + "title": "A2A Connection",
510 + "description": "Share this connection string with other agents.",
511 + "fields": a2a_fields,
512 + "tab": "external",
513 + }
514 +
515 if runtime.is_dockerized():
516 auth_fields.append(
517 {
@@ -880,7 +899,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
899
900 # TTS fields
901 tts_fields: list[SettingsField] = []
883 -
902 +
903 tts_fields.append(
904 {
905 "id": "tts_kokoro",
@@ -1028,6 +1047,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
1047 speech_section,
1048 api_keys_section,
1049 auth_section,
1050 + a2a_section,
1051 mcp_client_section,
1052 mcp_server_section,
1053 backup_section,
python/tools/a2a_chat.py new
+57
@@ -0,0 +1,57 @@
1 +from python.helpers.tool import Tool, Response
2 +from python.helpers.print_style import PrintStyle
3 +
4 +try:
5 + from python.helpers.fasta2a_client import connect_to_agent, is_client_available # type: ignore
6 +except ImportError: # pragma: no cover – client helper missing
7 + is_client_available = lambda: False # type: ignore
8 +
9 +
10 +class A2AChatTool(Tool):
11 + """Communicate with another FastA2A-compatible agent."""
12 +
13 + async def execute(self, **kwargs):
14 + if not is_client_available():
15 + return Response(message="FastA2A client not available on this instance.", break_loop=False)
16 +
17 + agent_url: str | None = kwargs.get("agent_url") # required
18 + user_message: str | None = kwargs.get("message") # required
19 + attachments = kwargs.get("attachments", None) # optional list[str]
20 + reset = bool(kwargs.get("reset", False))
21 + if not agent_url or not isinstance(agent_url, str):
22 + return Response(message="agent_url argument missing", break_loop=False)
23 + if not user_message or not isinstance(user_message, str):
24 + return Response(message="message argument missing", break_loop=False)
25 +
26 + # Retrieve or create session cache on the Agent instance
27 + sessions: dict[str, str] = self.agent.get_data("_a2a_sessions") or {}
28 +
29 + # Handle reset flag – start fresh conversation
30 + if reset and agent_url in sessions:
31 + sessions.pop(agent_url, None)
32 +
33 + context_id = None if reset else sessions.get(agent_url)
34 + try:
35 + async with await connect_to_agent(agent_url) as conn:
36 + task_resp = await conn.send_message(user_message, attachments=attachments, context_id=context_id)
37 + task_id = task_resp.get("result", {}).get("id") # type: ignore[index]
38 + if not task_id:
39 + return Response(message="Remote agent failed to create task.", break_loop=False)
40 + final = await conn.wait_for_completion(task_id)
41 + new_context_id = final["result"].get("context_id") # type: ignore[index]
42 + if isinstance(new_context_id, str):
43 + sessions[agent_url] = new_context_id
44 + # persist back to agent data
45 + self.agent.set_data("_a2a_sessions", sessions)
46 + # Extract latest assistant text
47 + history = final["result"].get("history", [])
48 + assistant_text = ""
49 + if history:
50 + last_parts = history[-1].get("parts", [])
51 + assistant_text = "\n".join(
52 + p.get("text", "") for p in last_parts if p.get("kind") == "text"
53 + )
54 + return Response(message=assistant_text or "(no response)", break_loop=False)
55 + except Exception as e:
56 + PrintStyle.error(f"A2A chat error: {e}")
57 + return Response(message=f"A2A chat error: {e}", break_loop=False)
requirements.txt
+1
@@ -5,6 +5,7 @@ docker==7.1.0
5 duckduckgo-search==6.1.12
6 faiss-cpu==1.11.0
7 fastmcp==2.3.4
8 +fasta2a==0.5.0
9 flask[async]==3.0.3
10 flask-basicauth==0.2.0
11 flaredantic==0.1.4
run_ui.py
+32 -12
@@ -1,27 +1,33 @@
1 from datetime import timedelta
2 import os
3 import secrets
4 -import sys
4 import time
5 import socket
6 import struct
7 from functools import wraps
8 import threading
10 -import signal
11 -from typing import override
9 from flask import Flask, request, Response, session
10 from flask_basicauth import BasicAuth
11 import initialize
15 -from python.helpers import errors, files, git, mcp_server
12 +from python.helpers import files, git, mcp_server
13 from python.helpers.files import get_abs_path
14 from python.helpers import runtime, dotenv, process
15 from python.helpers.extract_tools import load_classes_from_folder
16 from python.helpers.api import ApiHandler
17 from python.helpers.print_style import PrintStyle
18
19 +# Try to import fasta2a_server, but handle gracefully if it fails
20 +try:
21 + from python.helpers import fasta2a_server
22 + FASTA2A_AVAILABLE = True
23 +except ImportError:
24 + FASTA2A_AVAILABLE = False
25 + fasta2a_server = None
26 +
27
28 # Set the new timezone to 'UTC'
29 os.environ["TZ"] = "UTC"
30 +os.environ["TOKENIZERS_PARALLELISM"] = "false"
31 # Apply the timezone change
32 if hasattr(time, 'tzset'):
33 time.tzset()
@@ -171,7 +177,7 @@ def run():
177 from werkzeug.serving import WSGIRequestHandler
178 from werkzeug.serving import make_server
179 from werkzeug.middleware.dispatcher import DispatcherMiddleware
174 - from a2wsgi import ASGIMiddleware, WSGIMiddleware
180 + from a2wsgi import ASGIMiddleware
181
182 PrintStyle().print("Starting server...")
183
@@ -214,13 +220,27 @@ def run():
220 for handler in handlers:
221 register_api_handler(webapp, handler)
222
217 - # add the webapp and mcp to the app
218 - app = DispatcherMiddleware(
219 - webapp,
220 - {
221 - "/mcp": ASGIMiddleware(app=mcp_server.DynamicMcpProxy.get_instance()), # type: ignore
222 - },
223 - )
223 + # add the webapp, mcp, and a2a to the app
224 + middleware_routes = {
225 + "/mcp": ASGIMiddleware(app=mcp_server.DynamicMcpProxy.get_instance()), # type: ignore
226 + }
227 +
228 + # Add A2A server if available
229 + if FASTA2A_AVAILABLE and fasta2a_server and fasta2a_server.is_available():
230 + a2a_proxy = fasta2a_server.get_proxy()
231 + if a2a_proxy:
232 + from python.helpers import settings as _s
233 + cfg = _s.get_settings()
234 + token = cfg.get("a2a_token") or cfg.get("mcp_server_token")
235 + mount_path = f"/a2a/t-{token}"
236 + middleware_routes[mount_path] = ASGIMiddleware(app=a2a_proxy) # type: ignore
237 + # Also mount at /a2a for agent card and convenience
238 + middleware_routes["/a2a"] = ASGIMiddleware(app=a2a_proxy) # type: ignore
239 + PrintStyle().print(f"FastA2A server enabled at {mount_path} and /a2a")
240 + else:
241 + PrintStyle().debug("FastA2A server not available")
242 +
243 + app = DispatcherMiddleware(webapp, middleware_routes) # type: ignore
244
245 PrintStyle().debug(f"Starting server at http://{host}:{port} ...")
246
test_fasta2a_client.py new
+56
@@ -0,0 +1,56 @@
1 +import asyncio
2 +import sys
3 +from typing import Any, Dict
4 +
5 +from python.helpers.fasta2a_client import (
6 + connect_to_agent,
7 + is_client_available,
8 +)
9 +
10 +
11 +async def chat_loop(base_url: str):
12 + if not is_client_available():
13 + print("FastA2A client library not available – install `fasta2a httpx` first.")
14 + return
15 +
16 + # Establish connection (agent card is fetched internally)
17 + async with await connect_to_agent(base_url) as conn:
18 + print(f"[✓] Connected to {base_url}")
19 + print('Type "exit" to quit.')
20 +
21 + while True:
22 + try:
23 + user_msg = input("You: ").strip()
24 + except (EOFError, KeyboardInterrupt):
25 + print()
26 + break
27 +
28 + if user_msg.lower() in {"exit", "quit"}:
29 + break
30 +
31 + try:
32 + # Send message & get immediate task response
33 + task_response: Dict[str, Any] = await conn.send_message(message=user_msg)
34 + task_id = task_response.get("result", {}).get("id")
35 + if not task_id:
36 + print("[!] No task ID returned – response: ", task_response)
37 + continue
38 +
39 + # Wait for the task to complete and fetch final result
40 + final = await conn.wait_for_completion(task_id)
41 + latest_history = final["result"].get("history", [])
42 + assistant_parts = latest_history[-1].get("parts", []) if latest_history else []
43 + assistant_text = "\n".join(
44 + p.get("text", "") for p in assistant_parts if p.get("kind") == "text"
45 + )
46 + print(f"Assistant: {assistant_text}\n")
47 + except Exception as e:
48 + print(f"[!] Error: {e}")
49 +
50 +
51 +if __name__ == "__main__":
52 + if len(sys.argv) < 2:
53 + print("Usage: python test_fasta2a_client.py <AGENT_BASE_URL>")
54 + sys.exit(1)
55 +
56 + asyncio.run(chat_loop(sys.argv[1]))
webui/components/settings/external/a2a-connection.html new
+47
@@ -0,0 +1,47 @@
1 +<html>
2 +
3 +<head>
4 + <title>Connection to A0 A2A Server</title>
5 +</head>
6 +
7 +<body>
8 + <div x-data>
9 + <p>Agent Zero A2A Server enables FastA2A protocol communication with other agents.</p>
10 + <p>Other agents can connect using the URL below (replace host if needed):</p>
11 +
12 + <h3>A2A Connection URL</h3>
13 + <div id="a2a-connection-example"></div>
14 +
15 + <script>
16 + setTimeout(() => {
17 + const url = window.location.origin;
18 + // Try to get a2a_token first, fallback to mcp_server_token
19 + let tokenField = null;
20 + try {
21 + const allFields = settingsModalProxy.settings.sections.flatMap(s => s.fields);
22 + tokenField = allFields.find(f => f.id === 'a2a_token') || allFields.find(f => f.id === 'mcp_server_token');
23 + } catch (e) { }
24 + const token = tokenField ? tokenField.value : '';
25 + const connectionUrl = `${url}/a2a/t-${token}`;
26 +
27 + const editor = ace.edit("a2a-connection-example");
28 + const dark = localStorage.getItem("darkMode");
29 + editor.setTheme(dark !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow");
30 + editor.session.setMode("ace/mode/text");
31 + editor.setValue(connectionUrl);
32 + editor.clearSelection();
33 + editor.setReadOnly(true);
34 + }, 0);
35 + </script>
36 + </div>
37 +
38 + <style>
39 + #a2a-connection-example {
40 + width: 100%;
41 + height: 3em;
42 + }
43 + </style>
44 +
45 +</body>
46 +
47 +</html>
webui/js/settings.js
+3 -1
@@ -1,4 +1,3 @@
1 -
1 const settingsModalProxy = {
2 isOpen: false,
3 settings: {},
@@ -291,6 +290,9 @@ const settingsModalProxy = {
290 openModal("settings/backup/backup.html");
291 } else if (field.id === "backup_restore") {
292 openModal("settings/backup/restore.html");
293 + } else if (field.id === "show_a2a_connection") {
294 + console.log('Opening A2A connection modal...');
295 + openModal("settings/external/a2a-connection.html");
296 }
297 }
298 };
webui/public/a2a_server.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.72"/>
3 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.72"/>
4 +</svg>