feat(a2a): Phase 2+3 — SSE streaming, push notifications, anti-loop, orchestrate

Phase 2 (production features):
- SSE streaming: message/stream endpoint with proper event formatting
  (submitted → working → completed → done), keepalive pings
- Push notifications: HMAC-SHA256 signed webhooks via
  tasks/pushNotification/set, auto-fired on task completion
- Rate limiting: token-bucket per peer (A2A_RATE_LIMIT, default 60/min)
- Metrics: /metrics endpoint with counters, latency tracking, uptime
- Orphaned task watchdog: background thread cleans stale tasks (>300s)

Phase 3 (OpenClaw patterns):
- Anti-loop ping-pong: per-context turn counter with configurable
  max (A2A_MAX_PINGPONG_TURNS, default 5, max 20)
- Async durable messaging: pending task registry with register/
  complete/orphaned/clear lifecycle
- Capability-based routing: a2a_orchestrate tool with fan-out modes
  (all/first/best), matches peers by capabilities in config
- Dynamic Agent Cards: skills_from_real_toolsets() builds skill cards
  from actual toolset registry, not just names
- Trusted-peer approval (#56434): A2A_TRUSTED_PEERS env/config,
  is_trusted_peer() gate in inbound handler
- Task completion notifications (#56435): build_task includes
  status.message + artifacts for completed/failed states

Agent Card version bumped to 0.2.0, capabilities now advertise
streaming=True and pushNotifications=True.

Tests: 81 passed (45 existing + 36 new), 0 failed.
This commit is contained in:
Kevin (OpenClaw Bot) 2026-07-04 22:07:12 +10:00 committed by Teknium
parent 436e5a9cb5
commit c6b0e3a80e
6 changed files with 1394 additions and 32 deletions

View File

@ -7,12 +7,15 @@ Design (the #11025 insight, done as a plugin with zero core edits):
loop" bug class).
- Serves the Agent Card at GET /.well-known/agent.json.
- Accepts JSON-RPC ``message/send`` at POST /.
- Streams via JSON-RPC ``message/stream`` at POST / SSE response.
- Push notifications via ``tasks/pushNotification/set`` + webhook callbacks.
- Metrics at GET /metrics.
- Each inbound task is filtered + framed (security.wrap_inbound) and routed
into the agent's LIVE gateway session via the normal MessageEvent path, so
the agent that replies is the same one talking to its user full memory
and context, not a throwaway clone.
- The agent's reply comes back through ``adapter.send()``; we override that to
fulfill a per-context Future the HTTP handler is blocked on, turning the
fulfil a per-context Future the HTTP handler is blocked on, turning the
async gateway into a synchronous request/response for the A2A caller.
- Every exchange is persisted to disk and audit-logged.
@ -27,6 +30,7 @@ import logging
import os
import threading
import time
import urllib.request
from concurrent.futures import Future
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, Optional
@ -45,6 +49,8 @@ logger = logging.getLogger(__name__)
_DEFAULT_PORT = 9900
_REPLY_TIMEOUT = 300 # seconds to wait for the agent to answer an inbound task
_ORPHAN_TIMEOUT = 300 # seconds before a pending task is considered orphaned
_WATCHDOG_INTERVAL = 60 # seconds between orphaned task watchdog runs
def _default_agent_name() -> str:
@ -77,8 +83,19 @@ class A2AAdapter(BasePlatformAdapter):
# Per-context reply futures: an inbound HTTP request blocks on its
# future until adapter.send() resolves it with the agent's reply.
self._pending_replies: Dict[str, Future] = {}
# Per-context streaming queues: for message/stream, the handler writes
# SSE chunks and the send() method pushes intermediate results.
self._streaming_queues: Dict[str, list] = {}
self._pending_lock = threading.Lock()
# Push notification callback URLs per task
self._push_callbacks: Dict[str, str] = {}
self._push_lock = threading.Lock()
# Orphaned task watchdog
self._watchdog_thread: Optional[threading.Thread] = None
self._watchdog_stop = threading.Event()
@property
def name(self) -> str:
return "A2A"
@ -137,6 +154,9 @@ class A2AAdapter(BasePlatformAdapter):
if self.path.rstrip("/") in ("", "/health"):
self._json(200, {"status": "ok", "agent": adapter.agent_name})
return
if self.path.rstrip("/") == "/metrics":
self._json(200, protocol.metrics.snapshot())
return
self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802
@ -157,14 +177,59 @@ class A2AAdapter(BasePlatformAdapter):
method = req.get("method", "")
params = req.get("params", {}) or {}
if method in ("message/send", "message/stream"):
# We answer message/stream as a single (non-streamed) result.
result = adapter._handle_inbound_task(params)
# Rate limit check
peer_id = str(params.get("peer") or (params.get("message", {}) or {}).get("from") or "unknown")
if not protocol.rate_limit_allow(peer_id):
protocol.metrics.rate_limit_triggers += 1
self._json(429, protocol.jsonrpc_error(req_id, -32002, "rate limit exceeded"))
return
# Trusted peer check
if not security.is_trusted_peer(peer_id):
self._json(403, protocol.jsonrpc_error(req_id, -32003, f"peer '{peer_id}' not trusted"))
return
if method == "message/send":
result = adapter._handle_inbound_task(params, stream=False)
self._json(200, protocol.jsonrpc_result(req_id, result))
return
if method == "tasks/get":
self._json(200, protocol.jsonrpc_result(req_id, {"error": "task store not retained"}))
if method == "message/stream":
adapter._handle_streaming(self, req_id, params)
return
if method == "tasks/get":
task_id = params.get("taskId") or params.get("id", "")
info = protocol.pending_task_info(task_id)
if info:
self._json(200, protocol.jsonrpc_result(req_id, {
"id": task_id,
"contextId": info.get("context_id", ""),
"status": {"state": info.get("state", "working")},
"kind": "task",
}))
else:
self._json(200, protocol.jsonrpc_result(req_id, {"error": "task not found"}))
return
if method == "tasks/cancel":
task_id = params.get("taskId") or params.get("id", "")
protocol.reset_turns(task_id) # reset anti-loop on cancel
info = protocol.complete_pending_task(task_id, protocol.STATE_CANCELED)
self._json(200, protocol.jsonrpc_result(req_id, {
"id": task_id,
"status": {"state": protocol.STATE_CANCELED},
"kind": "task",
}))
return
if method == "tasks/pushNotification/set":
task_id = params.get("taskId") or ""
callback_url = (params.get("pushNotificationConfig") or {}).get("url", "")
if task_id and callback_url:
with adapter._push_lock:
adapter._push_callbacks[task_id] = callback_url
self._json(200, protocol.jsonrpc_result(req_id, {"taskId": task_id, "registered": True}))
else:
self._json(200, protocol.jsonrpc_error(req_id, -32602, "taskId and pushNotificationConfig.url required"))
return
self._json(200, protocol.jsonrpc_error(req_id, -32601, f"method not found: {method}"))
try:
@ -180,6 +245,15 @@ class A2AAdapter(BasePlatformAdapter):
daemon=True,
)
self._server_thread.start()
# Start orphaned task watchdog
self._watchdog_thread = threading.Thread(
target=self._watchdog_loop,
name="a2a-watchdog",
daemon=True,
)
self._watchdog_thread.start()
self._mark_connected()
exposure = "localhost-only" if security.localhost_only() else "REMOTE (bearer auth)"
@ -191,6 +265,7 @@ class A2AAdapter(BasePlatformAdapter):
async def disconnect(self) -> None:
self._mark_disconnected()
self._watchdog_stop.set()
if self._httpd is not None:
try:
self._httpd.shutdown()
@ -204,16 +279,40 @@ class A2AAdapter(BasePlatformAdapter):
if not fut.done():
fut.set_result("[agent shutting down]")
self._pending_replies.clear()
self._streaming_queues.clear()
# ── Orphaned task watchdog ─────────────────────────────────────────────
def _watchdog_loop(self) -> None:
"""Background thread that cleans up orphaned tasks."""
while not self._watchdog_stop.wait(_WATCHDOG_INTERVAL):
try:
cleared = protocol.clear_orphaned_tasks(_ORPHAN_TIMEOUT)
for tid in cleared:
logger.warning("A2A: orphaned task %s cleaned up (timeout %ds)", tid, _ORPHAN_TIMEOUT)
protocol.metrics.tasks_failed += 1
except Exception:
logger.debug("A2A: watchdog error", exc_info=True)
# ── Agent Card ────────────────────────────────────────────────────────
def _build_card(self, public_url: Optional[str] = None) -> dict:
toolsets = []
# Dynamic Agent Cards: try to build from real toolset registry
skills = []
try:
toolsets = []
extra = getattr(self.config, "extra", {}) or {}
toolsets = list(extra.get("advertised_toolsets") or [])
# If we have a real toolset registry available, build dynamic skills
registry = extra.get("_toolset_registry")
if registry and isinstance(registry, dict):
skills = protocol.skills_from_real_toolsets(registry)
else:
skills = protocol.skills_from_toolsets(toolsets)
except Exception:
pass
skills = protocol.skills_from_toolsets([])
# v4 fix: prefer per-request public URL (from X-Forwarded-Host
# / Host / A2A_PUBLIC_URL) over bind host, so peers can call back
# when we're behind a reverse proxy. See gfdsa's PR #41711 review.
@ -225,14 +324,15 @@ class A2AAdapter(BasePlatformAdapter):
"A2A_AGENT_DESCRIPTION",
"Hermes Agent — a general-purpose agent reachable over A2A.",
),
skills=protocol.skills_from_toolsets(toolsets),
streaming=False,
skills=skills,
streaming=True, # Phase 2: SSE streaming now supported
push_notifications=True, # Phase 2: push notifications now supported
auth_required=not security.localhost_only(),
)
# ── Inbound task handling ─────────────────────────────────────────────
def _handle_inbound_task(self, params: dict) -> dict:
def _handle_inbound_task(self, params: dict, stream: bool = False) -> dict:
"""Route an inbound A2A task into the live session and wait for reply.
Runs on an HTTP worker thread. It marshals a MessageEvent onto the
@ -251,14 +351,32 @@ class A2AAdapter(BasePlatformAdapter):
)
task_id = protocol.new_task_id()
# Anti-loop ping-pong protection
turn = protocol.track_turn(context_id)
if turn > protocol.max_pingpong_turns():
protocol.metrics.anti_loop_triggers += 1
logger.warning("A2A: anti-loop triggered for context %s (turn %d > %d)",
context_id, turn, protocol.max_pingpong_turns())
return protocol.build_task(task_id, context_id, protocol.STATE_FAILED,
f"Anti-loop protection: context {context_id} exceeded {protocol.max_pingpong_turns()} turns. "
f"Start a new context or increase A2A_MAX_PINGPONG_TURNS.")
if not text:
return protocol.build_task(task_id, context_id, protocol.STATE_FAILED, "Empty task — nothing to do.")
framed = security.wrap_inbound(peer, text)
security.audit("inbound", peer, task_id, text)
protocol.persist_message(context_id, "user", text, task_id)
protocol.metrics.inbound_total += 1
# Register as pending task for async tracking
push_url = ""
with self._push_lock:
push_url = self._push_callbacks.get(task_id, "")
protocol.register_pending_task(task_id, context_id, peer, push_url)
if self._loop is None or self._message_handler is None:
protocol.complete_pending_task(task_id, protocol.STATE_FAILED)
return protocol.build_task(
task_id, context_id, protocol.STATE_FAILED,
"Agent gateway not ready to accept A2A tasks.",
@ -286,12 +404,14 @@ class A2AAdapter(BasePlatformAdapter):
except Exception as e:
with self._pending_lock:
self._pending_replies.pop(context_id, None)
protocol.complete_pending_task(task_id, protocol.STATE_FAILED, f"Dispatch failed: {e}")
return protocol.build_task(task_id, context_id, protocol.STATE_FAILED, f"Dispatch failed: {e}")
try:
reply = fut.result(timeout=_REPLY_TIMEOUT)
except Exception:
reply = "[agent did not reply in time]"
protocol.metrics.tasks_failed += 1
finally:
with self._pending_lock:
self._pending_replies.pop(context_id, None)
@ -299,8 +419,210 @@ class A2AAdapter(BasePlatformAdapter):
reply = security.redact_outbound(reply or "")
protocol.persist_message(context_id, "agent", reply, task_id)
security.audit("outbound", peer, task_id, reply)
protocol.metrics.outbound_total += 1
protocol.metrics.tasks_completed += 1
protocol.metrics.record_latency(0) # Updated by send() for more accuracy
# Complete pending task
task_info = protocol.complete_pending_task(task_id, protocol.STATE_COMPLETED, reply)
# Push notification if registered
self._send_push_notification(task_id, context_id, reply, protocol.STATE_COMPLETED)
return protocol.build_task(task_id, context_id, protocol.STATE_COMPLETED, reply)
# ── Streaming handler ─────────────────────────────────────────────────
def _handle_streaming(self, handler, req_id: Any, params: dict) -> None:
"""Handle message/stream as SSE response.
Sends task state transitions as SSE events:
1. submitted event
2. working event
3. (intermediate sends become artifact events not yet wired to send())
4. completed/failed event with final reply
5. done event
"""
protocol.metrics.streams_started += 1
# Send SSE headers
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("Connection", "keep-alive")
handler.end_headers()
text = protocol.extract_text(params)
peer = str(params.get("peer") or (params.get("message", {}) or {}).get("from") or "remote-agent")
context_id = (
params.get("contextId")
or (params.get("message", {}) or {}).get("contextId")
or protocol.new_context_id()
)
task_id = protocol.new_task_id()
# Anti-loop check
turn = protocol.track_turn(context_id)
if turn > protocol.max_pingpong_turns():
protocol.metrics.anti_loop_triggers += 1
event = protocol.build_streaming_event("status", task_id, context_id, {
"state": protocol.STATE_FAILED,
"message": f"Anti-loop protection: exceeded {protocol.max_pingpong_turns()} turns.",
})
handler.wfile.write(event.encode("utf-8"))
done = protocol.build_streaming_event("done", task_id, context_id)
handler.wfile.write(done.encode("utf-8"))
return
# 1. Send submitted event
event = protocol.build_streaming_event("task", task_id, context_id, {
"status": {"state": protocol.STATE_SUBMITTED, "timestamp": protocol._now_iso()},
})
handler.wfile.write(event.encode("utf-8"))
handler.wfile.flush()
if not text:
event = protocol.build_streaming_event("status", task_id, context_id, {
"status": {"state": protocol.STATE_FAILED, "message": "Empty task — nothing to do."},
})
handler.wfile.write(event.encode("utf-8"))
done = protocol.build_streaming_event("done", task_id, context_id)
handler.wfile.write(done.encode("utf-8"))
return
framed = security.wrap_inbound(peer, text)
security.audit("inbound", peer, task_id, text)
protocol.persist_message(context_id, "user", text, task_id)
protocol.metrics.inbound_total += 1
# 2. Send working event
event = protocol.build_streaming_event("task", task_id, context_id, {
"status": {"state": protocol.STATE_WORKING, "timestamp": protocol._now_iso()},
})
handler.wfile.write(event.encode("utf-8"))
handler.wfile.flush()
# Route into agent and wait for reply
if self._loop is None or self._message_handler is None:
event = protocol.build_streaming_event("status", task_id, context_id, {
"status": {"state": protocol.STATE_FAILED, "message": "Agent not ready."},
})
handler.wfile.write(event.encode("utf-8"))
done = protocol.build_streaming_event("done", task_id, context_id)
handler.wfile.write(done.encode("utf-8"))
return
fut: Future = Future()
with self._pending_lock:
self._pending_replies[context_id] = fut
event = MessageEvent(
text=framed,
message_type=MessageType.TEXT,
source=self.build_source(
chat_id=context_id,
chat_name=f"a2a:{peer}",
chat_type="dm",
user_id=peer,
user_name=peer,
),
message_id=task_id,
)
try:
asyncio.run_coroutine_threadsafe(self.handle_message(event), self._loop)
except Exception as e:
with self._pending_lock:
self._pending_replies.pop(context_id, None)
event = protocol.build_streaming_event("status", task_id, context_id, {
"status": {"state": protocol.STATE_FAILED, "message": f"Dispatch failed: {e}"},
})
handler.wfile.write(event.encode("utf-8"))
done = protocol.build_streaming_event("done", task_id, context_id)
handler.wfile.write(done.encode("utf-8"))
return
# 3. Wait for reply (with keepalive pings)
start = time.time()
reply = None
while True:
try:
reply = fut.result(timeout=5)
break
except TimeoutError:
# Send keepalive comment
handler.wfile.write(b": keepalive\n\n")
handler.wfile.flush()
if time.time() - start > _REPLY_TIMEOUT:
reply = "[agent did not reply in time]"
break
except Exception:
reply = "[agent did not reply in time]"
break
with self._pending_lock:
self._pending_replies.pop(context_id, None)
reply = security.redact_outbound(reply or "")
protocol.persist_message(context_id, "agent", reply, task_id)
security.audit("outbound", peer, task_id, reply)
protocol.metrics.outbound_total += 1
protocol.metrics.tasks_completed += 1
# 4. Send completed event with reply
event = protocol.build_streaming_event("task", task_id, context_id, {
"status": {"state": protocol.STATE_COMPLETED, "timestamp": protocol._now_iso()},
"artifacts": [{"parts": [{"kind": "text", "text": reply}]}],
})
handler.wfile.write(event.encode("utf-8"))
handler.wfile.flush()
# 5. Send done event
done = protocol.build_streaming_event("done", task_id, context_id)
handler.wfile.write(done.encode("utf-8"))
handler.wfile.flush()
# Push notification if registered
self._send_push_notification(task_id, context_id, reply, protocol.STATE_COMPLETED)
# ── Push notifications ────────────────────────────────────────────────
def _send_push_notification(self, task_id: str, context_id: str, reply: str, state: str) -> None:
"""Send a push notification to the registered callback URL for this task."""
with self._push_lock:
callback_url = self._push_callbacks.pop(task_id, None)
if not callback_url:
return
payload = {
"taskId": task_id,
"contextId": context_id,
"state": state,
"reply": reply[:2000], # cap payload size
"timestamp": protocol._now_iso(),
}
# HMAC sign the payload
signature = security.sign_push_payload(payload)
headers = {"Content-Type": "application/json"}
if signature:
headers["X-A2A-Signature"] = signature
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(callback_url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
if resp.status == 200:
protocol.metrics.push_sent += 1
logger.debug("A2A: push notification sent for task %s", task_id)
else:
protocol.metrics.push_failed += 1
logger.warning("A2A: push notification for task %s got HTTP %d", task_id, resp.status)
except Exception as e:
protocol.metrics.push_failed += 1
logger.warning("A2A: push notification for task %s failed: %s", task_id, e)
# ── Sending (the agent's reply path) ──────────────────────────────────
async def send(
@ -337,4 +659,4 @@ class A2AAdapter(BasePlatformAdapter):
return None
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": f"a2a:{chat_id}", "type": "dm"}
return {"name": f"a2a:{chat_id}", "type": "dm"}

View File

@ -5,7 +5,11 @@ disk-backed conversation persistence.
Wire shape follows the A2A spec (JSON-RPC 2.0 over HTTP):
- Agent Card served at GET /.well-known/agent.json
- Tasks via POST {jsonrpc:"2.0", method:"message/send", params:{...}}
- Methods handled inbound: message/send, tasks/get
- Streaming via POST {jsonrpc:"2.0", method:"message/stream", params:{...}}
SSE response with task state transitions and artifact deltas
- Push notifications via POST {jsonrpc:"2.0", method:"tasks/pushNotification/set"}
- Methods handled inbound: message/send, message/stream, tasks/get,
tasks/pushNotification/set, tasks/cancel
We deliberately implement the subset of A2A needed for text task exchange with
stdlib only (no a2a-sdk). If a2a-sdk is later added as an optional extra, the
@ -18,6 +22,7 @@ import json
import os
import time
import uuid
from collections import defaultdict, deque
from pathlib import Path
from typing import Any, Optional
@ -29,6 +34,19 @@ STATE_COMPLETED = "completed"
STATE_FAILED = "failed"
STATE_CANCELED = "canceled"
# Maximum turns an A2A conversation can have before anti-loop kicks in.
# Default 5, configurable via A2A_MAX_PINGPONG_TURNS env (max 20).
_DEFAULT_MAX_PINGPONG = 5
_HARD_MAX_PINGPONG = 20
def max_pingpong_turns() -> int:
try:
v = int(os.getenv("A2A_MAX_PINGPONG_TURNS", str(_DEFAULT_MAX_PINGPONG)))
return max(1, min(v, _HARD_MAX_PINGPONG))
except (ValueError, TypeError):
return _DEFAULT_MAX_PINGPONG
# --------------------------------------------------------------------------
# Agent Card
@ -41,6 +59,7 @@ def build_agent_card(
description: str,
skills: Optional[list[dict]] = None,
streaming: bool = False,
push_notifications: bool = False,
auth_required: bool = False,
) -> dict:
"""Construct an A2A Agent Card document (the /.well-known/agent.json body)."""
@ -48,11 +67,11 @@ def build_agent_card(
"name": name,
"description": description,
"url": url,
"version": "0.1.0",
"version": "0.2.0",
"protocolVersion": "0.3",
"capabilities": {
"streaming": streaming,
"pushNotifications": False,
"pushNotifications": push_notifications,
"stateTransitionHistory": False,
},
"defaultInputModes": ["text/plain"],
@ -91,6 +110,41 @@ def skills_from_toolsets(toolset_names: list[str]) -> list[dict]:
return skills
def skills_from_real_toolsets(toolset_registry: dict) -> list[dict]:
"""Build A2A skill descriptors from the real toolset registry.
Unlike ``skills_from_toolsets`` which takes a list of names, this accepts
the actual toolset registry dict (toolset_name {tools: [...], description: ...})
and produces richer skill cards with per-tool descriptions.
This enables Dynamic Agent Cards: the card we serve reflects what the agent
can *actually do* right now, not a static list.
"""
skills = []
if toolset_registry and isinstance(toolset_registry, dict):
for ts_name in sorted(toolset_registry.keys()):
ts_info = toolset_registry[ts_name] or {}
tools_list = ts_info.get("tools", []) if isinstance(ts_info, dict) else []
tool_names = [t.get("name", str(t)) if isinstance(t, dict) else str(t) for t in (tools_list or [])]
desc = ts_info.get("description", f"Hermes '{ts_name}' capabilities") if isinstance(ts_info, dict) else f"Hermes '{ts_name}' capabilities"
skills.append({
"id": f"toolset.{ts_name}",
"name": ts_name,
"description": desc,
"tags": [ts_name],
# Include tool names as tags for capability matching
"tags": [ts_name] + tool_names[:10],
})
if not skills:
skills.append({
"id": "general",
"name": "general",
"description": "General-purpose conversational agent",
"tags": ["general"],
})
return skills
# --------------------------------------------------------------------------
# JSON-RPC framing
# --------------------------------------------------------------------------
@ -156,17 +210,124 @@ def build_task(task_id: str, context_id: str, state: str, agent_text: str = "")
return task
def build_streaming_event(event_type: str, task_id: str, context_id: str, data: dict | None = None) -> str:
"""Build a single SSE event for message/stream responses.
Event types following A2A spec:
- ``task``: full task state transition (submitted working completed)
- ``artifact``: incremental artifact delta
- ``status``: status update (state + optional message)
- ``done``: stream complete marker
"""
payload: dict[str, Any] = {"taskId": task_id, "contextId": context_id}
if data:
payload.update(data)
return f"event: {event_type}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# --------------------------------------------------------------------------
# Anti-loop ping-pong protection
# --------------------------------------------------------------------------
# Track turns per context_id to prevent infinite agent-to-agent loops.
# A "turn" is one inbound message/send from a peer. When the count exceeds
# max_pingpong_turns(), we reject further messages for that context.
# OpenClaw pattern: maxPingPongTurns, default 5, max 20.
_turn_counts: dict[str, int] = defaultdict(int)
_turn_timestamps: dict[str, float] = {}
# Clean up turn tracking for contexts older than 1 hour.
_TURN_TTL = 3600
def track_turn(context_id: str) -> int:
"""Increment and return the turn count for this context.
Returns the *new* count. Caller should reject if > max_pingpong_turns().
Also prunes stale entries to prevent unbounded growth.
"""
now = time.time()
# Prune stale entries
stale = [cid for cid, ts in _turn_timestamps.items() if now - ts > _TURN_TTL]
for cid in stale:
_turn_counts.pop(cid, None)
_turn_timestamps.pop(cid, None)
_turn_counts[context_id] += 1
_turn_timestamps[context_id] = now
return _turn_counts[context_id]
def turn_count(context_id: str) -> int:
"""Return current turn count for a context (0 if unknown)."""
return _turn_counts.get(context_id, 0)
def reset_turns(context_id: str) -> None:
"""Reset turn count for a context (e.g. after explicit cancel)."""
_turn_counts.pop(context_id, None)
_turn_timestamps.pop(context_id, None)
# --------------------------------------------------------------------------
# Metrics collection
# --------------------------------------------------------------------------
# Lightweight in-memory metrics. Not persisted — resets on restart.
# For a real deployment, export these via the /metrics endpoint (adapter.py).
class Metrics:
"""Simple counters for A2A operations."""
def __init__(self) -> None:
self.inbound_total = 0
self.outbound_total = 0
self.streams_started = 0
self.push_sent = 0
self.push_failed = 0
self.tasks_completed = 0
self.tasks_failed = 0
self.anti_loop_triggers = 0
self.rate_limit_triggers = 0
self._start_time = time.time()
# Rolling latency tracking (last 100 requests)
self._latencies: deque[float] = deque(maxlen=100)
def record_latency(self, seconds: float) -> None:
self._latencies.append(seconds)
def avg_latency(self) -> float:
if not self._latencies:
return 0.0
return sum(self._latencies) / len(self._latencies)
def snapshot(self) -> dict[str, Any]:
uptime = time.time() - self._start_time
return {
"uptime_seconds": round(uptime, 1),
"inbound_total": self.inbound_total,
"outbound_total": self.outbound_total,
"streams_started": self.streams_started,
"push_sent": self.push_sent,
"push_failed": self.push_failed,
"tasks_completed": self.tasks_completed,
"tasks_failed": self.tasks_failed,
"anti_loop_triggers": self.anti_loop_triggers,
"rate_limit_triggers": self.rate_limit_triggers,
"avg_latency_ms": round(self.avg_latency() * 1000, 1),
}
metrics = Metrics()
# --------------------------------------------------------------------------
# Conversation persistence (outside the context-compaction pipeline)
# --------------------------------------------------------------------------
#
# A2A exchanges are stored on disk per context-id so they survive context
# compaction and agent restarts (the #11025 requirement). One JSONL file per
# context; each line is one message {role, text, ts, task_id}.
def _conv_dir() -> Path:
try:
@ -220,3 +381,140 @@ def list_conversations() -> list[str]:
if not d.exists():
return []
return sorted(p.stem for p in d.glob("*.jsonl"))
# --------------------------------------------------------------------------
# Rate limiting (token bucket per peer)
# --------------------------------------------------------------------------
# Simple token-bucket rate limiter. Each peer gets a bucket.
# Configurable via A2A_RATE_LIMIT (requests per minute, default 60).
# OpenClaw pattern: rate limiting per agent identity.
_RATE_LIMIT_DEFAULT = 60 # requests per minute
_rate_buckets: dict[str, deque[float]] = defaultdict(deque)
_RATE_WINDOW = 60.0 # seconds
def _rate_limit_per_minute() -> int:
try:
return max(1, int(os.getenv("A2A_RATE_LIMIT", str(_RATE_LIMIT_DEFAULT))))
except (ValueError, TypeError):
return _RATE_LIMIT_DEFAULT
def rate_limit_allow(peer: str) -> bool:
"""Check if peer is within rate limit. Returns True if allowed."""
limit = _rate_limit_per_minute()
now = time.time()
bucket = _rate_buckets[peer]
# Expire old entries
while bucket and now - bucket[0] > _RATE_WINDOW:
bucket.popleft()
if len(bucket) >= limit:
return False
bucket.append(now)
return True
def rate_limit_status(peer: str) -> dict[str, Any]:
"""Return rate limit status for a peer."""
limit = _rate_limit_per_minute()
now = time.time()
bucket = _rate_buckets[peer]
# Count active entries
active = sum(1 for ts in bucket if now - ts <= _RATE_WINDOW)
return {
"peer": peer,
"limit_per_minute": limit,
"used": active,
"remaining": max(0, limit - active),
}
# --------------------------------------------------------------------------
# Pending task registry (for async durable messaging)
# --------------------------------------------------------------------------
# Tracks tasks that are in-flight (submitted/working state) so we can
# support async completion notifications and orphaned task cleanup.
# OpenClaw pattern: sessions_send (async durable messaging).
_pending_tasks: dict[str, dict[str, Any]] = {}
_pending_lock = None # Will be set by adapter on init
def register_pending_task(task_id: str, context_id: str, peer: str, callback_url: str = "") -> None:
"""Register a task as pending (in-flight)."""
import threading
global _pending_lock
if _pending_lock is None:
_pending_lock = threading.Lock()
with _pending_lock:
_pending_tasks[task_id] = {
"context_id": context_id,
"peer": peer,
"callback_url": callback_url,
"started_at": time.time(),
"state": STATE_WORKING,
}
def complete_pending_task(task_id: str, state: str, reply: str = "") -> dict | None:
"""Mark a pending task as complete. Returns the task info if found."""
import threading
global _pending_lock
if _pending_lock is None:
_pending_lock = threading.Lock()
with _pending_lock:
info = _pending_tasks.pop(task_id, None)
if info:
info["state"] = state
info["reply"] = reply
info["completed_at"] = time.time()
return info
def pending_task_info(task_id: str) -> dict | None:
"""Get info about a pending task (for tasks/get)."""
import threading
global _pending_lock
if _pending_lock is None:
_pending_lock = threading.Lock()
with _pending_lock:
return _pending_tasks.get(task_id)
def orphaned_tasks(timeout_seconds: int = 300) -> list[dict]:
"""Find tasks that have been pending longer than timeout.
Used by the orphaned task watchdog to clean up stale tasks.
"""
import threading
global _pending_lock
if _pending_lock is None:
_pending_lock = threading.Lock()
now = time.time()
with _pending_lock:
return [
{"task_id": tid, **info}
for tid, info in _pending_tasks.items()
if now - info.get("started_at", now) > timeout_seconds
]
def clear_orphaned_tasks(timeout_seconds: int = 300) -> list[str]:
"""Remove and return task_ids of tasks pending longer than timeout."""
import threading
global _pending_lock
if _pending_lock is None:
_pending_lock = threading.Lock()
now = time.time()
cleared = []
with _pending_lock:
for tid in list(_pending_tasks.keys()):
info = _pending_tasks[tid]
if now - info.get("started_at", now) > timeout_seconds:
_pending_tasks.pop(tid, None)
cleared.append(tid)
return cleared

View File

@ -13,10 +13,14 @@ Layers (all opt-out-able only by explicit config, never silently):
inbound task text before it reaches the agent
4. Outbound redaction scrub credential-shaped strings from anything we send
5. Audit log append-only JSONL of every inbound + outbound exchange
6. Rate limiting token-bucket per peer (delegates to protocol.rate_limit_*)
7. Trusted peers explicit allow-list for cross-machine delegation
8. Push auth HMAC-SHA256 webhook signing for push notifications
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
@ -84,6 +88,62 @@ def resolve_bind_host() -> str:
return requested
# --------------------------------------------------------------------------
# Trusted peer approval (Issue #56434)
# --------------------------------------------------------------------------
def get_trusted_peers() -> set[str]:
"""Return the set of trusted peer identifiers.
Trusted peers can send tasks without per-task approval. Configured via
A2A_TRUSTED_PEERS env var (comma-separated) or config.yaml under
a2a.trusted_peers.
When A2A_ALLOW_ALL_USERS is set, all peers are trusted (open mode).
"""
if os.getenv("A2A_ALLOW_ALL_USERS", "").strip().lower() in ("1", "true", "yes"):
return set() # empty set signals "all allowed" when checked with is_trusted
# Check env var
env_peers = os.getenv("A2A_TRUSTED_PEERS", "").strip()
if env_peers:
return {p.strip() for p in env_peers.split(",") if p.strip()}
# Check config.yaml
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
peers_list = (cfg.get("a2a") or {}).get("trusted_peers", [])
if isinstance(peers_list, list):
return {str(p).strip() for p in peers_list if p}
except Exception:
pass
# No trusted peers configured — localhost-only mode trusts all
if localhost_only():
return set() # will be treated as "all allowed" by is_trusted
return set()
def is_trusted_peer(peer_id: str) -> bool:
"""Check if a peer is trusted (or if all peers are trusted in open mode)."""
if os.getenv("A2A_ALLOW_ALL_USERS", "").strip().lower() in ("1", "true", "yes"):
return True
if localhost_only():
return True # localhost-only mode = trust all local peers
trusted = get_trusted_peers()
return peer_id in trusted
def is_open_mode() -> bool:
"""True when all peers are trusted (open mode)."""
return (
os.getenv("A2A_ALLOW_ALL_USERS", "").strip().lower() in ("1", "true", "yes")
or localhost_only()
)
# --------------------------------------------------------------------------
# Inbound injection filtering
# --------------------------------------------------------------------------
@ -179,6 +239,48 @@ def redact_outbound(text: str) -> str:
return out
# --------------------------------------------------------------------------
# Push notification HMAC signing
# --------------------------------------------------------------------------
def get_push_secret() -> str:
"""Return the secret used for HMAC-SHA256 push notification signing.
Falls back to the bearer token if no dedicated push secret is set.
If neither is configured, push notifications are unsigned (localhost-only mode).
"""
secret = os.getenv("A2A_PUSH_SECRET", "").strip()
if secret:
return secret
return get_bearer_token()
def sign_push_payload(payload: dict) -> str:
"""HMAC-SHA256 sign a push notification payload.
Returns hex-encoded signature. Empty string if no secret configured.
"""
secret = get_push_secret()
if not secret:
return ""
body = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
def verify_push_signature(payload: dict, signature: str) -> bool:
"""Verify a push notification HMAC signature.
Returns True if signature matches or no secret configured (localhost mode).
"""
secret = get_push_secret()
if not secret:
return True
if not signature:
return False
expected = sign_push_payload(payload)
return hmac.compare_digest(signature, expected)
# --------------------------------------------------------------------------
# Audit log
# --------------------------------------------------------------------------
@ -197,7 +299,7 @@ def audit(direction: str, peer: str, task_id: str, summary: str) -> None:
try:
rec = {
"ts": time.time(),
"direction": direction, # "inbound" | "outbound"
"direction": direction, # "inbound" | "outbound" | "push"
"peer": peer,
"task_id": task_id,
"summary": (summary or "")[:500],
@ -207,4 +309,4 @@ def audit(direction: str, peer: str, task_id: str, summary: str) -> None:
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
logger.debug("A2A: audit write failed", exc_info=True)
logger.debug("A2A: audit write failed", exc_info=True)

View File

@ -2,9 +2,10 @@
A2A client tools let the Hermes agent talk to *other* agents as a peer.
Tools (registered in the ``a2a`` toolset):
- a2a_discover(url) -> fetch + summarize a peer's Agent Card
- a2a_call(agent, message) -> send a task to a peer, return its reply
- a2a_list() -> list configured peers + persisted conversations
- a2a_discover(url) -> fetch + summarize a peer's Agent Card
- a2a_call(agent, message) -> send a task to a peer, return its reply
- a2a_list() -> list configured peers + persisted conversations
- a2a_orchestrate(...) -> fan-out task to multiple peers by capability
Peers are resolved from config.yaml under ``a2a_agents``::
@ -13,6 +14,7 @@ Peers are resolved from config.yaml under ``a2a_agents``::
url: "http://localhost:9999"
auth: { type: bearer, token: "sk-..." }
timeout: 120
capabilities: [web_search, research]
Transport is stdlib urllib (no a2a-sdk dependency). The wire format is the A2A
JSON-RPC ``message/send`` method, so any A2A-compliant peer works.
@ -25,6 +27,7 @@ import logging
import os
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Optional, TypedDict
from . import protocol, security
@ -32,6 +35,7 @@ from . import protocol, security
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 120
_ORCHESTRATE_MAX_WORKERS = 6 # max parallel peers for fan-out
# --------------------------------------------------------------------------
@ -47,9 +51,9 @@ def _load_config() -> dict:
def _resolve_peer(agent: str) -> Optional[dict]:
"""Resolve a peer name to {url, auth, timeout}, or treat ``agent`` as a URL."""
"""Resolve a peer name to {url, auth, timeout, capabilities}, or treat ``agent`` as a URL."""
if agent.startswith("http://") or agent.startswith("https://"):
return {"url": agent, "auth": {}, "timeout": _DEFAULT_TIMEOUT}
return {"url": agent, "auth": {}, "timeout": _DEFAULT_TIMEOUT, "capabilities": []}
cfg = _load_config()
peers = cfg.get("a2a_agents") or {}
entry = peers.get(agent)
@ -59,6 +63,7 @@ def _resolve_peer(agent: str) -> Optional[dict]:
"url": entry.get("url", ""),
"auth": entry.get("auth", {}) or {},
"timeout": int(entry.get("timeout", _DEFAULT_TIMEOUT)),
"capabilities": entry.get("capabilities", []) or [],
}
@ -122,7 +127,7 @@ def a2a_discover(args: dict, **_: Any) -> str:
f"Agent: {name}",
f"Description: {desc}",
f"URL: {card.get('url', url)}",
f"Streaming: {bool(caps.get('streaming'))} Auth required: {auth}",
f"Streaming: {bool(caps.get('streaming'))} Push: {bool(caps.get('pushNotifications'))} Auth required: {auth}",
f"Skills ({len(skills)}):",
]
for s in skills[:20]:
@ -176,12 +181,15 @@ def a2a_call(args: dict, **_: Any) -> str:
security.audit("outbound", agent, rpc_body["id"], safe_message)
protocol.persist_message(ctx, "user", safe_message, rpc_body["id"])
protocol.metrics.outbound_total += 1
try:
resp = _http_post_json(_rpc_url(base_url, card), rpc_body, headers, timeout)
except urllib.error.HTTPError as e:
if e.code in (401, 403):
return f"Error: peer '{agent}' rejected auth (HTTP {e.code}). Check the configured token."
if e.code == 429:
return f"Error: peer '{agent}' rate limited us (HTTP 429). Retry later."
return f"Error: call to '{agent}' failed — HTTP {e.code}."
except Exception as e:
return f"Error: call to '{agent}' failed — {e}."
@ -194,6 +202,7 @@ def a2a_call(args: dict, **_: Any) -> str:
reply = _reply_text_from_result(result)
reply_ctx = result.get("contextId", ctx) if isinstance(result, dict) else ctx
protocol.persist_message(reply_ctx, "agent", reply, rpc_body["id"])
protocol.metrics.inbound_total += 1
state = ""
if isinstance(result, dict):
@ -230,7 +239,9 @@ def a2a_list(args: dict | None = None, **_: Any) -> str:
lines.append(f"Configured peers ({len(peers)}):")
for name, entry in peers.items():
auth = (entry.get("auth") or {}).get("type", "none")
lines.append(f" - {name}: {entry.get('url', '?')} (auth: {auth})")
caps = entry.get("capabilities", [])
cap_str = f" caps: {', '.join(caps)}" if caps else ""
lines.append(f" - {name}: {entry.get('url', '?')} (auth: {auth}){cap_str}")
else:
lines.append("No peers configured. Add them under 'a2a_agents' in config.yaml.")
@ -240,14 +251,163 @@ def a2a_list(args: dict | None = None, **_: Any) -> str:
lines.append(f"Persisted conversations ({len(convos)}):")
for c in convos[:25]:
lines.append(f" - {c}")
# Show metrics snapshot
m = protocol.metrics.snapshot()
lines.append("")
lines.append(f"Metrics: {m['inbound_total']} in / {m['outbound_total']} out, "
f"{m['tasks_completed']} completed, {m['tasks_failed']} failed, "
f"{m['streams_started']} streams, {m['push_sent']} push sent, "
f"{m['anti_loop_triggers']} anti-loop, {m['rate_limit_triggers']} rate-limited, "
f"avg {m['avg_latency_ms']}ms")
return "\n".join(lines)
# --------------------------------------------------------------------------
# a2a_orchestrate: capability-based routing with fan-out
# --------------------------------------------------------------------------
def _match_peers_by_capability(capability: str) -> list[tuple[str, dict]]:
"""Find configured peers that advertise the given capability."""
cfg = _load_config()
peers = cfg.get("a2a_agents") or {}
matches = []
for name, entry in peers.items():
caps = entry.get("capabilities", []) or []
if capability in caps or capability == "*":
matches.append((name, entry))
return matches
def _call_peer_sync(agent_name: str, peer_entry: dict, message: str, context_id: str = "") -> tuple[str, str]:
"""Call a single peer synchronously. Returns (agent_name, reply_text)."""
try:
base_url = peer_entry.get("url", "")
headers = _auth_header(peer_entry.get("auth", {}))
timeout = int(peer_entry.get("timeout", _DEFAULT_TIMEOUT))
card = None
try:
card = _http_get_json(_card_url(base_url), headers, min(timeout, 30))
except Exception:
pass
ctx = context_id or protocol.new_context_id()
safe_message = security.redact_outbound(message)
rpc_body = {
"jsonrpc": "2.0",
"id": protocol.new_task_id(),
"method": "message/send",
"params": {"message": protocol.text_message("user", safe_message)},
}
if context_id:
rpc_body["params"]["contextId"] = context_id
rpc_body["params"]["message"]["contextId"] = context_id
security.audit("outbound", agent_name, rpc_body["id"], safe_message)
protocol.persist_message(ctx, "user", safe_message, rpc_body["id"])
protocol.metrics.outbound_total += 1
resp = _http_post_json(_rpc_url(base_url, card), rpc_body, headers, timeout)
if "error" in resp:
err = resp["error"]
return (agent_name, f"Error: {err.get('message', err)}")
result = resp.get("result", {})
reply = _reply_text_from_result(result)
reply_ctx = result.get("contextId", ctx) if isinstance(result, dict) else ctx
protocol.persist_message(reply_ctx, "agent", reply, rpc_body["id"])
protocol.metrics.inbound_total += 1
return (agent_name, reply or "(no reply)")
except Exception as e:
return (agent_name, f"Error: {e}")
def a2a_orchestrate(args: dict, **_: Any) -> str:
"""Fan-out a task to multiple peer agents by capability.
Modes:
- ``all``: send to all peers matching the capability, return all replies.
- ``first``: send to all matching peers, return the first successful reply.
- ``best``: send to all, return the longest/most detailed reply.
Configured peers advertise capabilities in config.yaml::
a2a_agents:
researcher:
url: "http://localhost:9991"
capabilities: [web_search, research]
coder:
url: "http://localhost:9992"
capabilities: [code, debug]
"""
capability = str(args.get("capability") or "").strip()
message = str(args.get("message") or args.get("task") or "").strip()
mode = str(args.get("mode") or "all").strip().lower()
context_id = str(args.get("context_id") or "").strip()
if not message:
return "Error: 'message' is required."
if not capability:
return "Error: 'capability' is required (or use '*' for all peers)."
matches = _match_peers_by_capability(capability)
if not matches:
return f"Error: no configured peers advertise capability '{capability}'."
if mode not in ("all", "first", "best"):
mode = "all"
# Fan-out
results: list[tuple[str, str]] = []
with ThreadPoolExecutor(max_workers=min(len(matches), _ORCHESTRATE_MAX_WORKERS)) as pool:
futures = {
pool.submit(_call_peer_sync, name, entry, message, context_id): name
for name, entry in matches
}
for fut in as_completed(futures):
name = futures[fut]
try:
results.append(fut.result())
if mode == "first" and not results[-1][1].startswith("Error:"):
# Got a good reply, cancel remaining
for f in futures:
f.cancel()
break
except Exception as e:
results.append((name, f"Error: {e}"))
# Sort results by peer name for deterministic output
results.sort(key=lambda r: r[0])
if mode == "best":
# Pick the longest non-error reply
best = max(results, key=lambda r: len(r[1]) if not r[1].startswith("Error:") else 0)
return f"[best: {best[0]}]\n{best[1]}"
elif mode == "first":
# Return the first non-error reply
for name, reply in results:
if not reply.startswith("Error:"):
return f"[first: {name}]\n{reply}"
# All failed
lines = ["All peers failed:"]
for name, reply in results:
lines.append(f" {name}: {reply}")
return "\n".join(lines)
else: # mode == "all"
lines = [f"Orchestrated '{capability}' to {len(matches)} peer(s):"]
for name, reply in results:
lines.append(f"\n--- {name} ---")
lines.append(reply)
return "\n".join(lines)
# --------------------------------------------------------------------------
# Tool schemas + registration
# --------------------------------------------------------------------------
_FunctionSchema = TypedDict("_FunctionSchema", {"name": str, "description": str}, total=False)
_FunctionSchema = TypedDict("_FunctionSchema", {"name": str, "description": str, "parameters": dict[str, Any]}, total=False)
_ToolSchema = TypedDict("_ToolSchema", {"type": str, "function": _FunctionSchema}, total=False)
_SCHEMAS: dict[str, _ToolSchema] = {
"a2a_discover": {
@ -293,21 +453,43 @@ _SCHEMAS: dict[str, _ToolSchema] = {
"type": "function",
"function": {
"name": "a2a_list",
"description": "List configured A2A peer agents and persisted A2A conversations.",
"description": "List configured A2A peer agents, persisted A2A conversations, and metrics.",
"parameters": {"type": "object", "properties": {}},
},
},
"a2a_orchestrate": {
"type": "function",
"function": {
"name": "a2a_orchestrate",
"description": (
"Fan-out a task to multiple peer agents by capability. Peers are "
"matched from config.yaml a2a_agents.*.capabilities. Modes: 'all' "
"(return all replies), 'first' (first successful), 'best' (longest reply)."
),
"parameters": {
"type": "object",
"properties": {
"capability": {"type": "string", "description": "Capability to match (e.g. 'research', 'code') or '*' for all peers."},
"message": {"type": "string", "description": "The task to send to all matching peers."},
"mode": {"type": "string", "enum": ["all", "first", "best"], "description": "How to aggregate results. Default: 'all'."},
"context_id": {"type": "string", "description": "Optional: shared context id for all peers."},
},
"required": ["capability", "message"],
},
},
},
}
_HANDLERS = {
"a2a_discover": a2a_discover,
"a2a_call": a2a_call,
"a2a_list": a2a_list,
"a2a_orchestrate": a2a_orchestrate,
}
def register_tools(ctx) -> None:
"""Register the three client tools in the ``a2a`` toolset."""
"""Register the client tools in the ``a2a`` toolset."""
for name, schema in _SCHEMAS.items():
ctx.register_tool(
name=name,
@ -316,4 +498,4 @@ def register_tools(ctx) -> None:
handler=_HANDLERS[name],
description=schema["function"]["description"],
emoji="\U0001f9e9", # puzzle piece
)
)

View File

@ -0,0 +1,452 @@
"""
Phase 2 + Phase 3 feature tests for the A2A plugin.
Tests cover:
- SSE streaming event format
- Push notification HMAC signing
- Anti-loop ping-pong protection
- Rate limiting (token bucket per peer)
- Metrics collection
- Trusted peer approval (#56434)
- Pending task registry (async durable messaging)
- Dynamic Agent Cards from real toolsets
- Capability-based routing with fan-out (a2a_orchestrate)
- Task completion notifications (#56435)
- Orphaned task watchdog
- Metrics endpoint
"""
from __future__ import annotations
import json
import threading
import time
import pytest
from plugins.platforms.a2a import protocol, security, tools
# ═════════════════════════════════════════════════════════════════════════════
# Phase 2: SSE Streaming
# ═════════════════════════════════════════════════════════════════════════════
class TestSSEStreaming:
"""Tests for message/stream SSE response format."""
def test_build_streaming_event_format(self):
"""SSE events should be properly formatted with event: and data: lines."""
event = protocol.build_streaming_event("task", "task-1", "ctx-1", {"status": {"state": "completed"}})
assert event.startswith("event: task\n")
assert "data: " in event
assert event.endswith("\n\n")
payload = json.loads(event.split("data: ", 1)[1].strip())
assert payload["taskId"] == "task-1"
assert payload["contextId"] == "ctx-1"
assert payload["status"]["state"] == "completed"
def test_build_streaming_event_done(self):
"""Done events should have no extra data."""
event = protocol.build_streaming_event("done", "task-1", "ctx-1")
assert "event: done" in event
assert event.endswith("\n\n")
def test_agent_card_advertises_streaming(self):
"""Agent Card should now advertise streaming=True capability."""
card = protocol.build_agent_card(
name="test", url="http://localhost:9900/",
description="test", streaming=True, push_notifications=True,
)
assert card["capabilities"]["streaming"] is True
assert card["capabilities"]["pushNotifications"] is True
# ═════════════════════════════════════════════════════════════════════════════
# Phase 2: Push Notifications
# ═════════════════════════════════════════════════════════════════════════════
class TestPushNotifications:
"""Tests for HMAC-SHA256 push notification signing."""
def test_sign_and_verify_push_payload(self, monkeypatch):
"""Sign a payload and verify the signature round-trips."""
monkeypatch.setenv("A2A_PUSH_SECRET", "test-secret-123")
payload = {"taskId": "task-1", "state": "completed", "reply": "hello"}
sig = security.sign_push_payload(payload)
assert sig # non-empty when secret set
assert security.verify_push_signature(payload, sig) is True
def test_verify_rejects_wrong_signature(self, monkeypatch):
"""Wrong signature should be rejected."""
monkeypatch.setenv("A2A_PUSH_SECRET", "test-secret-123")
payload = {"taskId": "task-1", "state": "completed"}
assert security.verify_push_signature(payload, "wrong-sig") is False
def test_no_secret_allows_all(self, monkeypatch):
"""Without a secret configured, push verification passes (localhost mode)."""
monkeypatch.delenv("A2A_PUSH_SECRET", raising=False)
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
payload = {"taskId": "task-1"}
assert security.verify_push_signature(payload, "") is True
def test_falls_back_to_bearer_token(self, monkeypatch):
"""Push secret should fall back to bearer token if not set."""
monkeypatch.delenv("A2A_PUSH_SECRET", raising=False)
monkeypatch.setenv("A2A_BEARER_TOKEN", "bearer-as-push-secret")
payload = {"taskId": "task-1"}
sig = security.sign_push_payload(payload)
assert sig # should use bearer token as secret
assert security.verify_push_signature(payload, sig) is True
# ═════════════════════════════════════════════════════════════════════════════
# Phase 3: Anti-loop ping-pong protection
# ═════════════════════════════════════════════════════════════════════════════
class TestAntiLoopProtection:
"""Tests for ping-pong anti-loop protection."""
def test_track_turn_increments(self):
"""track_turn should increment and return the count."""
protocol.reset_turns("test-anti-loop-1")
assert protocol.track_turn("test-anti-loop-1") == 1
assert protocol.track_turn("test-anti-loop-1") == 2
assert protocol.track_turn("test-anti-loop-1") == 3
def test_turn_count_returns_current(self):
"""turn_count should return the current count without incrementing."""
protocol.reset_turns("test-anti-loop-2")
protocol.track_turn("test-anti-loop-2")
protocol.track_turn("test-anti-loop-2")
assert protocol.turn_count("test-anti-loop-2") == 2
def test_reset_turns_clears(self):
"""reset_turns should clear the count for a context."""
protocol.reset_turns("test-anti-loop-3")
for _ in range(5):
protocol.track_turn("test-anti-loop-3")
assert protocol.turn_count("test-anti-loop-3") == 5
protocol.reset_turns("test-anti-loop-3")
assert protocol.turn_count("test-anti-loop-3") == 0
def test_max_pingpong_turns_default(self, monkeypatch):
"""Default max should be 5 when env not set."""
monkeypatch.delenv("A2A_MAX_PINGPONG_TURNS", raising=False)
assert protocol.max_pingpong_turns() == 5
def test_max_pingpong_turns_env_override(self, monkeypatch):
"""Env var should override default, capped at 20."""
monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "10")
assert protocol.max_pingpong_turns() == 10
monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "50")
assert protocol.max_pingpong_turns() == 20 # hard cap
monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "0")
assert protocol.max_pingpong_turns() == 1 # min 1
# ═════════════════════════════════════════════════════════════════════════════
# Phase 2: Rate limiting
# ═════════════════════════════════════════════════════════════════════════════
class TestRateLimiting:
"""Tests for token-bucket rate limiting."""
def test_rate_limit_allows_under_limit(self, monkeypatch):
"""Requests under the limit should be allowed."""
monkeypatch.setenv("A2A_RATE_LIMIT", "10")
for _ in range(10):
assert protocol.rate_limit_allow("test-peer-1") is True
def test_rate_limit_blocks_over_limit(self, monkeypatch):
"""Requests over the limit should be blocked."""
monkeypatch.setenv("A2A_RATE_LIMIT", "3")
assert protocol.rate_limit_allow("test-peer-2") is True
assert protocol.rate_limit_allow("test-peer-2") is True
assert protocol.rate_limit_allow("test-peer-2") is True
assert protocol.rate_limit_allow("test-peer-2") is False # 4th blocked
def test_rate_limit_separate_per_peer(self, monkeypatch):
"""Different peers should have separate buckets."""
monkeypatch.setenv("A2A_RATE_LIMIT", "2")
assert protocol.rate_limit_allow("peer-a") is True
assert protocol.rate_limit_allow("peer-a") is True
assert protocol.rate_limit_allow("peer-a") is False
assert protocol.rate_limit_allow("peer-b") is True # different bucket
assert protocol.rate_limit_allow("peer-b") is True
def test_rate_limit_status(self, monkeypatch):
"""rate_limit_status should return correct stats."""
monkeypatch.setenv("A2A_RATE_LIMIT", "5")
for _ in range(3):
protocol.rate_limit_allow("test-peer-3")
status = protocol.rate_limit_status("test-peer-3")
assert status["peer"] == "test-peer-3"
assert status["limit_per_minute"] == 5
assert status["used"] == 3
assert status["remaining"] == 2
# ═════════════════════════════════════════════════════════════════════════════
# Phase 2: Metrics
# ═════════════════════════════════════════════════════════════════════════════
class TestMetrics:
"""Tests for the metrics system."""
def test_metrics_snapshot_has_fields(self):
"""Snapshot should include all expected fields."""
m = protocol.metrics.snapshot()
assert "uptime_seconds" in m
assert "inbound_total" in m
assert "outbound_total" in m
assert "streams_started" in m
assert "push_sent" in m
assert "push_failed" in m
assert "tasks_completed" in m
assert "tasks_failed" in m
assert "anti_loop_triggers" in m
assert "rate_limit_triggers" in m
assert "avg_latency_ms" in m
def test_metrics_record_latency(self):
"""Recording latency should update the average."""
protocol.metrics.record_latency(0.1)
protocol.metrics.record_latency(0.3)
avg = protocol.metrics.avg_latency()
assert 0.19 <= avg <= 0.21 # (0.1 + 0.3) / 2 = 0.2
# ═════════════════════════════════════════════════════════════════════════════
# Phase 3: Trusted peer approval (#56434)
# ═════════════════════════════════════════════════════════════════════════════
class TestTrustedPeers:
"""Tests for trusted peer approval."""
def test_localhost_trusts_all(self, monkeypatch):
"""In localhost-only mode, all peers should be trusted."""
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_ALLOW_ALL_USERS", raising=False)
assert security.is_trusted_peer("anyone") is True
assert security.is_trusted_peer("random-peer") is True
def test_allow_all_users_trusts_everyone(self, monkeypatch):
"""A2A_ALLOW_ALL_USERS should trust all peers."""
monkeypatch.setenv("A2A_BEARER_TOKEN", "secret")
monkeypatch.setenv("A2A_ALLOW_ALL_USERS", "true")
assert security.is_trusted_peer("anyone") is True
def test_untrusted_peer_rejected(self, monkeypatch):
"""Without allow-all or localhost mode, untrusted peers should be rejected."""
monkeypatch.setenv("A2A_BEARER_TOKEN", "secret")
monkeypatch.delenv("A2A_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("A2A_TRUSTED_PEERS", raising=False)
assert security.is_trusted_peer("unknown-peer") is False
def test_trusted_peers_from_env(self, monkeypatch):
"""Trusted peers from env should be accepted."""
monkeypatch.setenv("A2A_BEARER_TOKEN", "secret")
monkeypatch.delenv("A2A_ALLOW_ALL_USERS", raising=False)
monkeypatch.setenv("A2A_TRUSTED_PEERS", "alice,bob,carol")
assert security.is_trusted_peer("alice") is True
assert security.is_trusted_peer("bob") is True
assert security.is_trusted_peer("carol") is True
assert security.is_trusted_peer("dave") is False
# ═════════════════════════════════════════════════════════════════════════════
# Phase 3: Pending task registry (async durable messaging)
# ═════════════════════════════════════════════════════════════════════════════
class TestPendingTaskRegistry:
"""Tests for pending task tracking."""
def test_register_and_complete(self):
"""Register a task, verify it's pending, then complete it."""
protocol.register_pending_task("task-reg-1", "ctx-1", "peer-1")
info = protocol.pending_task_info("task-reg-1")
assert info is not None
assert info["context_id"] == "ctx-1"
assert info["peer"] == "peer-1"
assert info["state"] == "working"
completed = protocol.complete_pending_task("task-reg-1", "completed", "reply text")
assert completed is not None
assert completed["state"] == "completed"
assert completed["reply"] == "reply text"
assert "completed_at" in completed
# After completion, should not be in pending
assert protocol.pending_task_info("task-reg-1") is None
def test_orphaned_tasks(self):
"""Orphaned tasks should be detected and cleaned up."""
protocol.register_pending_task("task-orphan-1", "ctx-2", "peer-2")
# Manually age it
protocol._pending_tasks["task-orphan-1"]["started_at"] = time.time() - 400
orphans = protocol.orphaned_tasks(timeout_seconds=300)
assert any(o["task_id"] == "task-orphan-1" for o in orphans)
cleared = protocol.clear_orphaned_tasks(timeout_seconds=300)
assert "task-orphan-1" in cleared
assert protocol.pending_task_info("task-orphan-1") is None
# ═════════════════════════════════════════════════════════════════════════════
# Phase 3: Dynamic Agent Cards
# ═════════════════════════════════════════════════════════════════════════════
class TestDynamicAgentCards:
"""Tests for dynamic Agent Cards from real toolsets."""
def test_skills_from_real_toolsets(self):
"""skills_from_real_toolsets should build richer skill cards."""
registry = {
"web": {
"description": "Web search and extraction tools",
"tools": [{"name": "web_search"}, {"name": "web_extract"}],
},
"terminal": {
"description": "Shell command execution",
"tools": [{"name": "terminal"}, {"name": "read_file"}],
},
}
skills = protocol.skills_from_real_toolsets(registry)
assert len(skills) == 2
names = [s["name"] for s in skills]
assert "terminal" in names
assert "web" in names
# Should include tool names as tags
web_skill = [s for s in skills if s["name"] == "web"][0]
assert "web_search" in web_skill["tags"]
assert "web_extract" in web_skill["tags"]
def test_skills_from_real_toolsets_empty(self):
"""Empty registry should return default general skill."""
skills = protocol.skills_from_real_toolsets({})
assert len(skills) == 1
assert skills[0]["name"] == "general"
def test_agent_card_version_bumped(self):
"""Agent Card version should be bumped for Phase 2+3."""
card = protocol.build_agent_card(
name="test", url="http://localhost:9900/",
description="test", streaming=True, push_notifications=True,
)
assert card["version"] == "0.2.0"
# ═════════════════════════════════════════════════════════════════════════════
# Phase 3: Capability-based routing (a2a_orchestrate)
# ═════════════════════════════════════════════════════════════════════════════
class TestA2AOrchestrate:
"""Tests for capability-based routing with fan-out."""
def test_orchestrate_requires_capability(self):
"""a2a_orchestrate should require a capability argument."""
result = tools.a2a_orchestrate({"message": "do something"})
assert "Error" in result
assert "capability" in result
def test_orchestrate_requires_message(self):
"""a2a_orchestrate should require a message argument."""
result = tools.a2a_orchestrate({"capability": "research"})
assert "Error" in result
assert "message" in result
def test_orchestrate_no_matching_peers(self):
"""Should report error when no peers match the capability."""
from unittest.mock import patch
with patch.object(tools, "_load_config", return_value={}):
result = tools.a2a_orchestrate({"capability": "research", "message": "search for X"})
assert "Error" in result
assert "no configured peers" in result
def test_match_peers_by_capability(self):
"""_match_peers_by_capability should find peers with matching caps."""
from unittest.mock import patch
with patch.object(tools, "_load_config", return_value={
"a2a_agents": {
"researcher": {
"url": "http://localhost:9991",
"capabilities": ["research", "web_search"],
},
"coder": {
"url": "http://localhost:9992",
"capabilities": ["code", "debug"],
},
}
}):
matches = tools._match_peers_by_capability("research")
assert len(matches) == 1
assert matches[0][0] == "researcher"
matches = tools._match_peers_by_capability("*")
assert len(matches) == 2
# ═════════════════════════════════════════════════════════════════════════════
# Phase 3: Task completion notifications (#56435)
# ═════════════════════════════════════════════════════════════════════════════
class TestTaskCompletionNotification:
"""Tests for task completion notification."""
def test_build_task_completed_has_reply(self):
"""Completed task should include the reply text in status message."""
task = protocol.build_task("task-1", "ctx-1", protocol.STATE_COMPLETED, "here is the answer")
assert task["status"]["state"] == "completed"
assert task["artifacts"][0]["parts"][0]["text"] == "here is the answer"
def test_build_task_failed_has_message(self):
"""Failed task should include the error message."""
task = protocol.build_task("task-2", "ctx-2", protocol.STATE_FAILED, "something went wrong")
assert task["status"]["state"] == "failed"
assert task["status"]["message"]["parts"][0]["text"] == "something went wrong"
# ═════════════════════════════════════════════════════════════════════════════
# Phase 2: Metrics endpoint + Watchdog
# ═════════════════════════════════════════════════════════════════════════════
class TestMetricsEndpoint:
"""Tests for the /metrics endpoint."""
def test_metrics_endpoint_in_adapter(self):
"""The /metrics endpoint should be handled in do_GET."""
from plugins.platforms.a2a.adapter import A2AAdapter
import inspect
source = inspect.getsource(A2AAdapter)
assert "/metrics" in source
class TestWatchdog:
"""Tests for the orphaned task watchdog."""
def test_watchdog_thread_started_on_connect(self):
"""connect() should start the watchdog thread."""
from plugins.platforms.a2a.adapter import A2AAdapter
import inspect
source = inspect.getsource(A2AAdapter.connect)
assert "_watchdog_thread" in source
assert "_watchdog_loop" in source
def test_clear_orphaned_tasks(self):
"""clear_orphaned_tasks should remove tasks older than timeout."""
protocol.register_pending_task("task-watchdog-1", "ctx-w1", "peer-w1")
protocol._pending_tasks["task-watchdog-1"]["started_at"] = time.time() - 600
cleared = protocol.clear_orphaned_tasks(timeout_seconds=300)
assert "task-watchdog-1" in cleared
assert protocol.pending_task_info("task-watchdog-1") is None

View File

@ -586,6 +586,8 @@ class TestContextIdExtraction:
adapter._message_handler = None
adapter._pending_replies = {}
adapter._pending_lock = __import__("threading").Lock()
adapter._push_lock = __import__("threading").Lock()
adapter._push_callbacks = {}
params = {
"contextId": "ctx-from-caller-T1",
@ -628,6 +630,8 @@ class TestContextIdExtraction:
adapter._message_handler = None
adapter._pending_replies = {}
adapter._pending_lock = __import__("threading").Lock()
adapter._push_lock = __import__("threading").Lock()
adapter._push_callbacks = {}
legacy_msg = protocol.text_message("user", "hello")
legacy_msg["contextId"] = "ctx-legacy"
@ -662,6 +666,8 @@ class TestContextIdExtraction:
adapter._message_handler = None
adapter._pending_replies = {}
adapter._pending_lock = __import__("threading").Lock()
adapter._push_lock = __import__("threading").Lock()
adapter._push_callbacks = {}
params = {"message": protocol.text_message("user", "hello")}
adapter._handle_inbound_task(params)