Files
hermes-agent/plugins/platforms/a2a/adapter.py
Teknium 4d1880e0bf fix(integration): restore subprocess encoding/stdin guards dropped in simplification
Simplification workers collapsed subprocess call sites into shared kwargs
helpers and dropped the Windows/TUI safety kwargs on the way:

- encoding='utf-8', errors='replace' restored on text=True runs in
  copilot_acp_client, hermes_cli/setup (vercel install), managed_uv
  (codesign steps), local_runtime/hardware._stdout, a2a adapter.
- stdin=subprocess.DEVNULL restored on copilot probe, verify/runner
  _SUBPROCESS_KW, iron_proxy._run, google_meet playwright/system_profiler,
  simplex convert, whatsapp _RUN_TEXT, mem0 ollama serve Popen.
  google_meet sudo/brew install keeps inherited stdin (user-confirmed,
  may prompt) — marked noqa: subprocess-stdin.
- Windows-safe SIGKILL: getattr(signal, 'SIGKILL', SIGTERM) in
  verify/runner; photon _kill call re-marked windows-footgun: ok
  (unreachable on win32).
- scripts/check_subprocess_stdin.py now recognizes **kwargs splats
  (**_KW / **_kw(...)) ONLY when the same-file definition provably sets
  stdin= — covers tui_gateway _capture_run_kwargs/run_kw. Parity test added.
2026-09-02 16:36:10 -07:00

925 lines
49 KiB
Python

"""
A2A inbound platform adapter — exposes Hermes as an A2A-discoverable agent.
Stdlib http.server in a daemon thread (no a2a-sdk, no asyncio dependency at
register() time). Serves the v1.0 Agent Card at GET /.well-known/agent-card.json
(legacy agent.json too), /metrics, and JSON-RPC at POST /: message/send,
message/stream (SSE), tasks/{get,list,cancel,subscribe},
tasks/pushNotificationConfig/{create,get,list,delete}. Push payloads are v1.0
StreamResponse objects, HMAC-signed; configs may arrive inline in message/send.
Each inbound task is filtered + framed (security.wrap_inbound) and routed into
the agent's LIVE gateway session via the normal MessageEvent path (full memory,
not a clone). The reply returns through ``adapter.send()``, which fulfils the
per-task Future the HTTP handler blocks on; ``on_processing_complete`` resolves
failures promptly. Every exchange is persisted and audit-logged.
Bind safety: with no token configured, the server binds 127.0.0.1 only.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import sqlite3
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from collections import deque
from concurrent.futures import Future
from concurrent.futures import TimeoutError as FuturesTimeout
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, Optional
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, ProcessingOutcome, SendResult
from gateway.config import Platform
from gateway.platforms._shared import profile_scoped as _profile_scoped
from . import protocol, security
logger = logging.getLogger(__name__)
_DEFAULT_PORT = 9900
_ORPHAN_TIMEOUT = 300 # seconds before a pending task is considered orphaned
_WATCHDOG_INTERVAL = 60 # seconds between orphaned task watchdog runs
_MAX_BODY = 1_048_576 # 1MB max request body — prevents DoS via memory exhaustion
_SSE_KEEPALIVE = 5 # seconds between SSE keepalive comments
_DEFAULT_DESCRIPTION = "Hermes Agent — a general-purpose agent reachable over A2A."
_ok = protocol.jsonrpc_result
_err = protocol.jsonrpc_error
# (adapter handler, v1.0 PascalCase method per §5.3/§9.4, *legacy slash aliases still accepted)
_METHOD_TABLE = (
("_rpc_message_send", "SendMessage", "message/send"),
("_rpc_message_stream", "SendStreamingMessage", "message/stream"),
("_rpc_tasks_get", "GetTask", "tasks/get"),
("_rpc_tasks_list", "ListTasks", "tasks/list"),
("_rpc_tasks_cancel", "CancelTask", "tasks/cancel"),
("_rpc_tasks_subscribe", "SubscribeToTask", "tasks/subscribe"),
("_rpc_push_config_create", "CreateTaskPushNotificationConfig", "tasks/pushNotificationConfig/create",
"tasks/pushNotificationConfig/set", "tasks/pushNotification/set"),
("_rpc_push_config_get", "GetTaskPushNotificationConfig", "tasks/pushNotificationConfig/get"),
("_rpc_push_config_list", "ListTaskPushNotificationConfigs", "tasks/pushNotificationConfig/list"),
("_rpc_push_config_delete", "DeleteTaskPushNotificationConfig", "tasks/pushNotificationConfig/delete"),
)
# JSON-RPC method -> (adapter handler name, is_v1)
_METHODS: dict[str, tuple[str, bool]] = {
m: (handler, i == 0) for handler, *methods in _METHOD_TABLE for i, m in enumerate(methods)
}
def _reply_timeout() -> float:
"""Seconds to wait for the agent to answer an inbound task."""
try:
return max(1.0, float(os.getenv("A2A_REPLY_TIMEOUT", "300")))
except (ValueError, TypeError):
return 300.0
def _to_int(value: Any, default: Any) -> Any:
try:
return int(value) if value is not None else default
except (TypeError, ValueError):
return default
def _default_agent_name() -> str:
# Scope-aware: in a secondary multiplex profile os.environ holds the DEFAULT profile's
# A2A_AGENT_NAME — use the hostname default rather than another profile's identity.
name = "" if _profile_scoped() else os.getenv("A2A_AGENT_NAME", "").strip()
if name:
return name
try:
import socket
return f"hermes-{socket.gethostname()}"
except Exception:
return "hermes-agent"
def _clean_slug(value: str) -> str:
"""Return a URL-safe-ish single-segment slug for a served agent."""
slug = str(value or "").strip().strip("/")
return "" if slug in ("", "default", "root") else slug.split("/")[0]
def _join_url(base: str, prefix: str) -> str:
base = (base or "").strip() or "/"
if not base.endswith("/"):
base += "/"
prefix = (prefix or "").strip("/")
return urllib.parse.urljoin(base, prefix + "/") if prefix else base
def _active_profile_name() -> str:
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
except Exception:
return os.getenv("HERMES_PROFILE", "default") or "default"
def _profile_home(profile: str) -> Optional[str]:
try:
from hermes_cli.profiles import get_profile_dir
return str(get_profile_dir(profile))
except Exception:
if not profile or profile == "default":
try:
from hermes_cli.config import get_hermes_home
return str(get_hermes_home())
except Exception:
return None
return os.path.expanduser(f"~/.hermes/profiles/{profile}")
def _safe_context_slug(value: str, max_len: int = 96) -> str:
"""Sanitize attacker-provided context ids before using in session titles."""
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "")).strip("-._")
return (slug or "ctx")[:max_len]
def _state_db(profile: str, sql: str, params: tuple, log_msg: str, *, commit: bool = False) -> str:
"""Run one statement against a profile's state.db; first column of the first row or ""."""
home = _profile_home(profile)
db = os.path.join(home, "state.db") if home else None
if not db or not os.path.exists(db):
return ""
try:
con = sqlite3.connect(db, timeout=5)
cur = con.execute(sql, params)
row = None if commit else cur.fetchone()
if commit:
con.commit()
con.close()
return str(row[0]) if row else ""
except Exception:
logger.debug(log_msg, exc_info=True)
return ""
class A2ARequestHandler(BaseHTTPRequestHandler):
"""HTTP handler for the A2A JSON-RPC surface. Module-level so routing is
unit-testable; all state lives on ``self.server.adapter`` (set in connect())."""
@property
def adapter(self) -> "A2AAdapter":
return self.server.adapter # type: ignore[attr-defined]
def log_message(self, format, *args): # noqa: A002,N802
logger.debug("A2A http: " + format, *args) # silence the default stderr access log
def _json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _error(self, http_code: int, req_id: Any, code: int, message: str):
self._json(http_code, _err(req_id, code, message))
def _client_ip(self) -> str:
return self.client_address[0] if self.client_address else ""
def _request_public_url(self) -> str:
"""Routable URL for this request: A2A_PUBLIC_URL > X-Forwarded-Host / Host
(scheme from X-Forwarded-Proto) > "" (caller falls back to bind host)."""
explicit = os.getenv("A2A_PUBLIC_URL", "").strip()
if explicit:
return explicit
host = self.headers.get("X-Forwarded-Host", "") or self.headers.get("Host", "")
if not host:
return ""
host = host.split(",")[0].strip()
scheme = (self.headers.get("X-Forwarded-Proto", "") or "http").split(",")[0].strip()
return f"{scheme}://{host}/"
def do_GET(self): # noqa: N802
adapter = self.adapter
route = adapter._route_for_path(self.path)
agent = route["agent"]
subpath = route["subpath"].rstrip("/") or "/"
if subpath in ("/.well-known/agent.json", "/.well-known/agent-card.json"):
self._json(200, adapter._build_card(self._request_public_url() or None, agent=agent))
elif subpath in ("/", "/health"):
payload = {"status": "ok", "agent": agent.get("name") or adapter.agent_name}
# Agent Cards are intentionally public; profile/tenant topology is not
# leaked on remote unauthenticated GETs.
sec = adapter._security_context
if sec.localhost_only() or sec.authenticate(self.headers.get("Authorization"), self._client_ip()) is not None:
payload["served_agents"] = adapter._served_agent_summary(public_url=self._request_public_url() or None)
self._json(200, payload)
elif subpath == "/metrics":
self._json(200, protocol.metrics.snapshot())
else:
self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802
adapter = self.adapter
# Identity comes from the presented credential (or the socket in
# localhost-only mode) — never from the request body.
identity = adapter._security_context.authenticate(self.headers.get("Authorization"), self._client_ip())
if identity is None:
return self._error(401, None, protocol.ERR_UNAUTHORIZED, "unauthorized")
try:
length = int(self.headers.get("Content-Length", 0))
if length > _MAX_BODY:
return self._error(413, None, protocol.ERR_PARSE, "payload too large")
raw = self.rfile.read(length) if length else b"{}"
req = json.loads(raw.decode("utf-8"))
except Exception:
return self._error(400, None, protocol.ERR_PARSE, "parse error")
if not isinstance(req, dict):
return self._error(400, None, protocol.ERR_INVALID_PARAMS, "JSON-RPC request must be an object")
req_id = req.get("id")
method = str(req.get("method", ""))
params = req["params"] if req.get("params") is not None else {}
if not isinstance(params, dict):
return self._error(200, req_id, protocol.ERR_INVALID_PARAMS, "params must be an object")
version = (self.headers.get("A2A-Version") or "").strip()
if version and version not in {"1.0", "1.0.0"}:
return self._error(200, req_id, protocol.ERR_INVALID_PARAMS, f"unsupported A2A-Version: {version}")
handler_name, is_v1 = _METHODS.get(method, ("", False))
route = adapter._route_for_request(self.path, params)
if route.get("error"):
return self._error(400, req_id, protocol.ERR_INVALID_PARAMS, route["error"])
agent = route["agent"]
if not adapter._rate_limiter.allow(identity):
protocol.metrics.rate_limit_triggers += 1
return self._error(429, req_id, protocol.ERR_RATE_LIMITED, "rate limit exceeded")
if not adapter._security_context.is_trusted_peer(identity):
return self._error(403, req_id, protocol.ERR_UNTRUSTED_PEER, f"peer '{identity}' not trusted")
if not handler_name:
return self._error(200, req_id, protocol.ERR_METHOD_NOT_FOUND, f"method not found: {method}")
if handler_name == "_rpc_message_send":
self._json(200, adapter._rpc_message_send(req_id, params, identity, agent=agent, v1_response=is_v1))
elif handler_name == "_rpc_message_stream":
adapter._rpc_message_stream(self, req_id, params, identity, agent=agent)
elif handler_name == "_rpc_tasks_subscribe":
adapter._rpc_tasks_subscribe(self, req_id, params, agent=agent)
else: # plain JSON task / push-config queries
self._json(200, getattr(adapter, handler_name)(req_id, params, agent=agent))
class A2AAdapter(BasePlatformAdapter):
"""Inbound A2A server adapter."""
def __init__(self, config, **kwargs):
super().__init__(config=config, platform=Platform("a2a"))
extra = getattr(config, "extra", {}) or {}
# Scope-aware: a secondary multiplex profile must not borrow the default profile's
# bridged A2A_PORT (falls closed to the module default). advertised_toolsets is
# deliberately left unscoped while its None-vs-empty-list semantics are in flux.
self._security_context = security.A2ASecurityContext.capture()
_port_env = None if _profile_scoped() else os.getenv("A2A_PORT")
self.port = int(_port_env or extra.get("port", _DEFAULT_PORT))
self.host = self._security_context.resolve_bind_host()
self.agent_name = _default_agent_name()
configured_toolsets = list(extra.get("advertised_toolsets") or []) or os.getenv("A2A_ADVERTISED_TOOLSETS", "").split(",")
self._advertised_toolsets = [t.strip() for t in configured_toolsets if str(t).strip()]
self._active_profile = _active_profile_name()
self._agents = self._load_served_agents(extra)
self._httpd: Optional[ThreadingHTTPServer] = None
self._server_thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._watchdog_stop = threading.Event()
self._watchdog_thread: Optional[threading.Thread] = None
# Per-adapter protocol state (not module-global).
self.tasks = protocol.TaskStore()
self._turns = protocol.TurnTracker()
self._rate_limiter = protocol.RateLimiter()
# Forwarded profile sessions: (profile, agent_slug, context_id) -> session_id.
self._profile_sessions: Dict[tuple[str, str, str], str] = {}
self._profile_session_locks: Dict[tuple[str, str, str], threading.Lock] = {}
self._profile_session_locks_guard = threading.Lock()
# Pending reply futures: task_id -> (context_id, Future). _pending_order keeps per-context
# FIFO so adapter.send() — which only knows the context — resolves the oldest task.
self._pending: Dict[str, tuple[str, Future]] = {}
self._pending_order: Dict[str, deque[str]] = {}
self._pending_lock = threading.Lock()
@property
def name(self) -> str:
return "A2A"
@property
def authorization_is_upstream(self) -> bool:
"""Every request is authenticated in ``do_POST`` before dispatch; without this
the gateway's ``A2A_ALLOWED_USERS`` allow-list would reject peers, whose identity
is a token-derived name or IP. Not fail-open: a wrong credential is still 401'd."""
return True
# ── Lifecycle ─────────────────────────────────────────────────────────
async def connect(self, **_kwargs) -> bool:
# **_kwargs: base lifecycle passes ``is_reconnect`` etc. Capture the gateway loop so
# the HTTP thread can marshal events onto it via run_coroutine_threadsafe.
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
try:
self._httpd = ThreadingHTTPServer((self.host, self.port), A2ARequestHandler)
except OSError as e:
logger.error("A2A: could not bind %s:%s — %s", self.host, self.port, e)
self._set_fatal_error("bind_failed", f"A2A bind failed: {e}", retryable=True)
return False
self._httpd.daemon_threads = True
self._httpd.adapter = self # type: ignore[attr-defined]
self._server_thread = threading.Thread(target=self._httpd.serve_forever, name="a2a-http", daemon=True)
self._server_thread.start()
self._watchdog_stop.clear() # disconnect sets it; reset for reconnection
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 self._security_context.localhost_only() else "REMOTE (bearer auth)"
logger.info("A2A: serving Agent Card + JSON-RPC on http://%s:%s (%s) as %r; %d routed agent(s)",
self.host, self.port, exposure, self.agent_name, len(self._agents))
# Plugin-registered native handlers (ctx.register_platform_handler).
self._wire_plugin_handlers(None)
return True
async def disconnect(self) -> None:
self._mark_disconnected()
self._watchdog_stop.set()
if self._httpd is not None:
try:
self._httpd.shutdown()
self._httpd.server_close()
except Exception:
pass
self._httpd = None
# Fail any in-flight replies so blocked HTTP threads don't hang.
with self._pending_lock:
for _ctx, fut in self._pending.values():
if not fut.done():
fut.set_result((protocol.STATE_FAILED, "[agent shutting down]"))
self._pending.clear()
self._pending_order.clear()
def _watchdog_loop(self) -> None:
"""Background thread that fails orphaned tasks (keeps them queryable)."""
while not self._watchdog_stop.wait(_WATCHDOG_INTERVAL):
try:
for tid in self.tasks.fail_orphans(_ORPHAN_TIMEOUT):
logger.warning("A2A: orphaned task %s marked failed (timeout %ds)", tid, _ORPHAN_TIMEOUT)
protocol.metrics.tasks_failed += 1
except Exception:
logger.debug("A2A: watchdog error", exc_info=True)
# ── Agent routing + Agent Cards ───────────────────────────────────────
def _load_served_agents(self, extra: dict) -> dict[str, dict]:
"""Served-agent routing from ``platforms.a2a.extra.agents`` (top-level ``a2a_served_agents``
fallback for scripts/tests). Root/default always maps to the live gateway session."""
raw = extra.get("agents") or extra.get("served_agents")
if raw is None:
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
cfg = cfg if isinstance(cfg, dict) else {}
except Exception:
cfg = {}
raw = cfg.get("a2a_served_agents") or (cfg.get("a2a") or {}).get("served_agents")
# Scope-aware like port: a secondary profile must not inherit A2A_AGENT_DESCRIPTION.
default_desc = _DEFAULT_DESCRIPTION if _profile_scoped() else os.getenv("A2A_AGENT_DESCRIPTION", _DEFAULT_DESCRIPTION)
agents: dict[str, dict] = {"": {
"slug": "", "path": "", "tenant": "", "profile": self._active_profile, "local": True,
"name": self.agent_name, "description": default_desc, "advertised_toolsets": self._advertised_toolsets,
}}
reserved = {"health", "metrics", ".well-known"}
tenants: dict[str, str] = {}
items = raw.items() if isinstance(raw, dict) else enumerate(raw or []) if isinstance(raw, list) else []
for key, val in items:
if not isinstance(val, dict):
continue
slug = _clean_slug(str(val.get("slug") or val.get("id") or key))
if not slug:
continue
path_segment = _clean_slug(str(val.get("path") or slug))
if not path_segment or path_segment in reserved:
logger.warning("A2A: ignoring served agent %r with reserved/invalid path %r", slug, path_segment)
continue
profile = str(val.get("profile") or slug).strip()
toolsets = val.get("advertised_toolsets") or val.get("toolsets") or val.get("capabilities") or []
if isinstance(toolsets, str):
toolsets = [t.strip() for t in toolsets.split(",") if t.strip()]
local = bool(val.get("local")) or profile in ("", "default", self._active_profile)
tenant = str(val.get("tenant") or slug).strip()
if tenant:
if tenant in tenants:
logger.warning("A2A: ignoring served agent %r with duplicate tenant %r already used by %r",
slug, tenant, tenants[tenant])
continue
tenants[tenant] = slug
agents[slug] = {
"slug": slug, "path": "/" + path_segment, "tenant": tenant, "profile": profile or slug, "local": local,
"name": str(val.get("name") or f"Hermes {slug}"),
"description": str(val.get("description") or f"Hermes profile '{profile or slug}' exposed over A2A."),
"advertised_toolsets": list(toolsets or []),
"timeout": int(val.get("timeout") or _reply_timeout()),
}
return agents
def _base_url(self, public_url: Optional[str]) -> str:
return (public_url or "").strip() or f"http://{self.host}:{self.port}/"
def _served_agent_summary(self, public_url: Optional[str] = None) -> list[dict]:
base = self._base_url(public_url)
return [
{"slug": a["slug"] or "default", "name": a.get("name"), "url": _join_url(base, a.get("path", "")),
"tenant": a.get("tenant") or None, "profile": a.get("profile"), "local": bool(a.get("local"))}
for a in self._agents.values()
]
def _route_for_path(self, raw_path: str) -> dict:
path = urllib.parse.urlsplit(raw_path or "/").path or "/"
# Longest prefix wins. Default/root agent is the fallback.
for agent in sorted(self._agents.values(), key=lambda a: len(a.get("path", "")), reverse=True):
prefix = agent.get("path", "") or ""
if prefix and (path == prefix or path.startswith(prefix + "/")):
return {"agent": agent, "subpath": path[len(prefix):] or "/"}
return {"agent": self._agents[""], "subpath": path}
def _route_for_request(self, raw_path: str, params: dict) -> dict:
route = self._route_for_path(raw_path)
agent = route["agent"]
tenant = str((params or {}).get("tenant") or "")
# If no URL prefix chose a non-default agent, allow v1.0 tenant routing.
if agent.get("slug") == "" and tenant:
matches = [a for a in self._agents.values() if a.get("tenant") == tenant]
if matches:
route = {"agent": matches[0], "subpath": route["subpath"]}
agent = matches[0]
expected = str(agent.get("tenant") or "")
if tenant and expected and tenant != expected:
return {"error": f"tenant {tenant!r} does not match routed agent {agent.get('slug') or 'default'}"}
return route
def _build_card(self, public_url: Optional[str] = None, agent: Optional[dict] = None) -> dict:
# Per-request public URL (X-Forwarded-Host / Host / A2A_PUBLIC_URL) beats
# the bind host so peers behind a reverse proxy can call back.
agent = agent or self._agents[""]
return protocol.build_agent_card(
name=agent.get("name") or self.agent_name,
url=_join_url(self._base_url(public_url), agent.get("path", "")),
description=agent.get("description") or _DEFAULT_DESCRIPTION,
skills=self._advertised_skills(agent),
streaming=bool(agent.get("local", True)),
push_notifications=True,
auth_required=not self._security_context.localhost_only(),
tenant=str(agent.get("tenant") or ""),
)
def _advertised_skills(self, agent: Optional[dict] = None) -> list[dict]:
"""Dynamic Agent Card skills from the live tool registry, restricted by
``advertised_toolsets`` / A2A_ADVERTISED_TOOLSETS; static fallback without a registry."""
configured = (agent or {}).get("advertised_toolsets") if agent else self._advertised_toolsets
try:
from tools.registry import registry as tool_registry
allowed = set(configured or []) or None
mapping = {
n: tool_registry.get_tool_names_for_toolset(n)
for n in tool_registry.get_registered_toolset_names()
if allowed is None or n in allowed
}
if mapping:
return protocol.skills_from_toolsets(mapping)
except Exception:
logger.debug("A2A: tool registry unavailable for Agent Card", exc_info=True)
return protocol.skills_from_toolsets(configured or [])
# ── Pending reply plumbing ────────────────────────────────────────────
def _add_pending(self, task_id: str, context_id: str) -> Future:
fut: Future = Future()
with self._pending_lock:
self._pending[task_id] = (context_id, fut)
self._pending_order.setdefault(context_id, deque()).append(task_id)
return fut
def _pop_pending(self, task_id: str) -> None:
with self._pending_lock:
entry = self._pending.pop(task_id, None)
order = self._pending_order.get(entry[0]) if entry else None
if order:
if task_id in order:
order.remove(task_id)
if not order:
self._pending_order.pop(entry[0], None)
def _resolve_locked(self, task_id: str, state: str, text: str) -> bool:
entry = self._pending.get(task_id)
if entry and not entry[1].done():
entry[1].set_result((state, text))
return True
return False
def _resolve_task(self, task_id: str, state: str, text: str) -> bool:
with self._pending_lock:
return self._resolve_locked(task_id, state, text)
def _resolve_oldest_for_context(self, context_id: str, state: str, text: str) -> bool:
with self._pending_lock:
return any(self._resolve_locked(tid, state, text) for tid in self._pending_order.get(context_id, ()))
def _scope_for_agent(self, agent: Optional[dict]) -> tuple[str, str]:
agent = agent or self._agents[""]
return str(agent.get("slug") or ""), str(agent.get("tenant") or "")
def _forward_lock(self, key: tuple[str, str, str]) -> threading.Lock:
with self._profile_session_locks_guard:
return self._profile_session_locks.setdefault(key, threading.Lock())
# ── Inbound task handling ─────────────────────────────────────────────
def _end_task(self, rec: dict, state: str, text: str, stored_reply: str = "") -> tuple[dict, None]:
"""Complete a task immediately (rejected / not ready) and build its terminal Task."""
self.tasks.complete(rec["task_id"], state, stored_reply)
if state == protocol.STATE_FAILED:
protocol.metrics.tasks_failed += 1
return protocol.build_task(rec["task_id"], rec["context_id"], state, text, created_at=rec["created_iso"]), None
def _prepare_task(self, params: dict, peer: str, agent: Optional[dict] = None) -> tuple[Optional[dict], Optional[dict]]:
"""Validate, register, and dispatch an inbound message (HTTP worker thread). Returns
(terminal_task, None) when it ends immediately, else (None, pending) with the future to wait on."""
agent = agent or self._agents[""]
text = protocol.extract_text(params)
context_id = protocol.extract_context_id(params) or protocol.new_context_id()
task_id = protocol.new_task_id()
turn = self._turns.track(context_id)
max_turns = protocol.max_pingpong_turns()
rec = self.tasks.create(task_id, context_id, peer, *self._scope_for_agent(agent))
if turn > max_turns:
protocol.metrics.anti_loop_triggers += 1
logger.warning("A2A: anti-loop triggered for context %s (turn %d > %d)", context_id, turn, max_turns)
return self._end_task(
rec, protocol.STATE_REJECTED,
f"Anti-loop protection: context {context_id} exceeded {max_turns} turns. "
f"Start a new context or increase A2A_MAX_PINGPONG_TURNS.",
)
if not text:
return self._end_task(rec, protocol.STATE_REJECTED, "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
self._register_inline_push(task_id, params, agent=agent)
if not agent.get("local", True):
reply, state = self._forward_to_profile(agent, peer, context_id, framed)
self._record_outcome(task_id, context_id, peer, state, reply)
return protocol.build_task(task_id, context_id, state, reply, created_at=rec["created_iso"]), None
if self._loop is None or self._message_handler is None:
return self._end_task(rec, protocol.STATE_FAILED, "Agent gateway not ready to accept A2A tasks.")
fut = self._add_pending(task_id, context_id)
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:
self._pop_pending(task_id)
msg = security.redact_outbound(f"Dispatch failed: {e}")
return self._end_task(rec, protocol.STATE_FAILED, msg, stored_reply=msg)
self.tasks.set_state(task_id, protocol.STATE_WORKING)
return None, {"task_id": task_id, "context_id": context_id, "peer": peer, "future": fut,
"created_iso": rec["created_iso"], "started": time.time()}
def _forward_to_profile(self, agent: dict, peer: str, context_id: str, framed_text: str) -> tuple[str, str]:
"""Forward a routed A2A task to another local Hermes profile via ``hermes chat``.
First contact creates a ``source=a2a`` session, records its id and titles it
deterministically; later turns ``--resume`` that concrete id (stable multi-turn continuity)."""
profile = str(agent.get("profile") or agent.get("slug") or "").strip()
slug = str(agent.get("slug") or profile or "agent")
safe_ctx = _safe_context_slug(context_id)
session_title = f"a2a-{slug}-{safe_ctx}"
key = (profile or "default", slug, safe_ctx)
timeout = int(agent.get("timeout") or _reply_timeout())
with self._forward_lock(key):
session_id = self._profile_sessions.get(key) or _state_db(
profile, "SELECT id FROM sessions WHERE title = ? ORDER BY started_at DESC LIMIT 1",
(session_title,), "A2A: could not lookup forwarded session",
)
cmd = ["hermes", "chat", "-q", framed_text, "-Q", "--source", "a2a"]
if session_id:
cmd.extend(["--resume", session_id])
env = os.environ.copy()
home = _profile_home(profile)
if home:
env["HERMES_HOME"] = home
env["HERMES_A2A_PEER"] = peer
start = time.time()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, check=False, stdin=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
return "[profile did not reply in time]", protocol.STATE_FAILED
except Exception as e:
return security.redact_outbound(f"Profile dispatch failed: {e}"), protocol.STATE_FAILED
if proc.returncode != 0:
msg = (proc.stderr or proc.stdout or f"profile exited {proc.returncode}").strip()
return security.redact_outbound(msg[-2000:]), protocol.STATE_FAILED
if not session_id:
session_id = _state_db(
profile, "SELECT id FROM sessions WHERE source = 'a2a' AND started_at >= ? ORDER BY started_at DESC LIMIT 1",
(start - 2.0,), "A2A: could not find latest forwarded session",
)
if session_id:
self._profile_sessions[key] = session_id
_state_db(profile, "UPDATE sessions SET title = ? WHERE id = ?", (session_title, session_id),
"A2A: could not title forwarded session", commit=True)
return security.redact_outbound((proc.stdout or "").strip()), protocol.STATE_COMPLETED
def _record_outcome(self, task_id: str, context_id: str, peer: str, state: str, reply: str,
started: Optional[float] = None) -> None:
"""Persist + audit + count a finished task, mark it terminal, and fire its push callback."""
protocol.persist_message(context_id, "agent", reply, task_id)
security.audit("outbound", peer, task_id, reply)
if state in (protocol.STATE_COMPLETED, protocol.STATE_INPUT_REQUIRED):
protocol.metrics.outbound_total += 1
protocol.metrics.tasks_completed += 1
if started is not None:
protocol.metrics.record_latency(time.time() - started)
else:
protocol.metrics.tasks_failed += 1
self.tasks.complete(task_id, state, reply)
self._send_push_notification(task_id, context_id, reply, state)
def _finalize_task(self, pending: dict, state: str, reply: str) -> tuple[str, str]:
"""Record the outcome of a dispatched task; returns (state, reply) after
redaction and input-required detection."""
task_id, context_id, peer = pending["task_id"], pending["context_id"], pending["peer"]
self._pop_pending(task_id)
reply = security.redact_outbound(reply or "")
# A leading marker flags a clarification request -> A2A input-required.
if state == protocol.STATE_COMPLETED:
stripped = reply.lstrip()
if stripped.upper().startswith(protocol.INPUT_REQUIRED_MARKER):
state = protocol.STATE_INPUT_REQUIRED
reply = stripped[len(protocol.INPUT_REQUIRED_MARKER):].strip()
self._record_outcome(task_id, context_id, peer, state, reply, started=pending["started"])
return state, reply
def _await_reply(self, pending: dict, keepalive=None) -> tuple[str, str]:
"""Block until the task's future resolves (or times out). ``keepalive`` runs every
_SSE_KEEPALIVE seconds while waiting; if it raises, the client is gone and we stop."""
fut: Future = pending["future"]
deadline = pending["started"] + _reply_timeout()
while True:
try:
return fut.result(timeout=_SSE_KEEPALIVE if keepalive else max(0.0, deadline - time.time()))
except FuturesTimeout:
if time.time() >= deadline:
return (protocol.STATE_FAILED, "[agent did not reply in time]")
if keepalive:
try:
keepalive()
except Exception:
return (protocol.STATE_FAILED, "[client disconnected]")
except Exception:
return (protocol.STATE_FAILED, "[agent did not reply in time]")
def _rpc_message_send(self, req_id: Any, params: dict, peer: str, agent: Optional[dict] = None, v1_response: bool = False) -> dict:
task, pending = self._prepare_task(params, peer, agent=agent)
if task is None:
state, reply = self._finalize_task(pending, *self._await_reply(pending))
task = protocol.build_task(pending["task_id"], pending["context_id"], state, reply, created_at=pending["created_iso"])
return _ok(req_id, protocol.send_message_response(task) if v1_response else task)
# ── Streaming (SSE) ───────────────────────────────────────────────────
@staticmethod
def _sse_headers(handler) -> None:
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache")
handler.end_headers()
# v1.0: stream closure signals the terminal state, so the socket must
# actually close once we emit the done event.
handler.close_connection = True
@staticmethod
def _sse_write(handler, chunk: str) -> None:
handler.wfile.write(chunk.encode("utf-8"))
handler.wfile.flush()
def _emit_terminal(self, handler, task_id: str, context_id: str, state: str, reply: str, req_id: Any = None) -> None:
"""Emit the final artifact/status events and close the stream (v1.0: closure
signals terminal state). ``req_id`` threads into the JSON-RPC SSE envelope (§9.4)."""
if reply and state == protocol.STATE_COMPLETED:
self._sse_write(handler, protocol.sse_data(protocol.artifact_update(task_id, context_id, reply), req_id))
self._sse_write(handler, protocol.sse_data(protocol.status_update(task_id, context_id, state), req_id))
else:
self._sse_write(handler, protocol.sse_data(protocol.status_update(task_id, context_id, state, reply), req_id))
self._sse_write(handler, protocol.sse_done())
def _rpc_message_stream(self, handler, req_id: Any, params: dict, peer: str, agent: Optional[dict] = None) -> None:
"""message/stream as an SSE response of JSON-RPC-wrapped StreamResponse events (§9.4)."""
protocol.metrics.streams_started += 1
self._sse_headers(handler)
try:
terminal, pending = self._prepare_task(params, peer, agent=agent)
if terminal is not None:
text = protocol.extract_text(terminal.get("status", {}).get("message", {}) or {})
return self._emit_terminal(handler, terminal["id"], terminal["contextId"], terminal["status"]["state"], text, req_id=req_id)
task_id, context_id = pending["task_id"], pending["context_id"]
submitted = protocol.build_task(task_id, context_id, protocol.STATE_SUBMITTED, created_at=pending["created_iso"])
self._sse_write(handler, protocol.sse_data(protocol.stream_task(submitted), req_id))
self._sse_write(handler, protocol.sse_data(protocol.status_update(task_id, context_id, protocol.STATE_WORKING), req_id))
state, reply = self._await_reply(pending, keepalive=lambda: self._sse_write(handler, ": keepalive\n\n"))
state, reply = self._finalize_task(pending, state, reply)
self._emit_terminal(handler, task_id, context_id, state, reply, req_id=req_id)
except (BrokenPipeError, ConnectionResetError):
logger.debug("A2A: stream client disconnected")
def _rpc_tasks_subscribe(self, handler, req_id: Any, params: dict, agent: Optional[dict] = None) -> None:
"""Reconnect to an existing task's stream (v1.0 SubscribeToTask)."""
task_id, rec, error = self._find_task(req_id, params, agent)
if error:
return handler._json(200, error)
self._sse_headers(handler)
try:
fut = self.tasks.watch(task_id, *self._scope_for_agent(agent))
if fut is None:
return self._sse_write(handler, protocol.sse_done())
deadline = time.time() + _reply_timeout()
while True:
try:
state, reply = fut.result(timeout=_SSE_KEEPALIVE)
break
except FuturesTimeout:
if time.time() >= deadline:
state, reply = rec["state"], rec.get("reply", "")
break
self._sse_write(handler, ": keepalive\n\n")
self._emit_terminal(handler, task_id, rec["context_id"], state, reply, req_id=req_id)
except (BrokenPipeError, ConnectionResetError):
logger.debug("A2A: subscribe client disconnected")
# ── Task queries ──────────────────────────────────────────────────────
def _find_task(self, req_id: Any, params: dict, agent: Optional[dict]) -> tuple[str, Optional[dict], Optional[dict]]:
"""(task_id, record, None) for a visible task, else (task_id, None, jsonrpc_error)."""
task_id = str(params.get("taskId") or params.get("id") or "")
rec = self.tasks.get(task_id, *self._scope_for_agent(agent))
if not rec:
return task_id, None, _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"task not found: {task_id}")
return task_id, rec, None
def _rpc_tasks_get(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict:
_task_id, rec, error = self._find_task(req_id, params, agent)
if error:
return error
history_len = _to_int(params.get("historyLength"), None)
return _ok(req_id, protocol.TaskStore.to_task(rec, history_length=history_len))
def _rpc_tasks_list(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict:
offset = _to_int(params.get("pageToken") or 0, 0)
page_size = _to_int(params.get("pageSize") or 50, 50)
agent_slug, tenant = self._scope_for_agent(agent)
recs, next_offset, total = self.tasks.list(
context_id=str(params.get("contextId") or ""), state=str(params.get("status") or params.get("state") or ""),
page_size=page_size, offset=max(0, offset), agent_slug=agent_slug, tenant=tenant, with_total=True,
)
include_artifacts = bool(params.get("includeArtifacts", False))
history_len = _to_int(params.get("historyLength"), None)
return _ok(req_id, {
"tasks": [protocol.TaskStore.to_task(r, history_length=history_len, include_artifacts=include_artifacts) for r in recs],
"nextPageToken": str(next_offset) if next_offset else "",
"pageSize": max(1, min(page_size, 100)),
"totalSize": total,
})
def _rpc_tasks_cancel(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict:
task_id, rec, error = self._find_task(req_id, params, agent)
if error:
return error
if rec["state"] in protocol.TERMINAL_STATES:
return _err(req_id, protocol.ERR_TASK_NOT_CANCELABLE, f"task {task_id} already {rec['state']}")
self.tasks.complete(task_id, protocol.STATE_CANCELED, "")
self._turns.reset(rec["context_id"])
self._resolve_task(task_id, protocol.STATE_CANCELED, "")
rec = self.tasks.get(task_id, *self._scope_for_agent(agent)) or rec
return _ok(req_id, protocol.TaskStore.to_task(rec))
# ── Push notifications ────────────────────────────────────────────────
def _register_inline_push(self, task_id: str, params: dict, agent: Optional[dict] = None) -> None:
"""v1.0: message/send can carry configuration.taskPushNotificationConfig."""
cfg = (params.get("configuration") or {}).get("taskPushNotificationConfig") or {}
if not isinstance(cfg, dict):
return
url = cfg.get("url") or (cfg.get("pushNotificationConfig") or {}).get("url") or ""
if url:
self.tasks.set_push_config(task_id, str(url), *self._scope_for_agent(agent))
def _rpc_push_config_create(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict:
task_id = str(params.get("taskId") or "")
cfg = params.get("pushNotificationConfig") or params.get("config") or {}
url = str((cfg or {}).get("url") or "")
if not task_id or not url:
return _err(req_id, protocol.ERR_INVALID_PARAMS, "taskId and pushNotificationConfig.url required")
stored = self.tasks.set_push_config(task_id, url, *self._scope_for_agent(agent))
if stored is None:
return _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"task not found: {task_id}")
return _ok(req_id, stored)
def _push_config_op(self, req_id: Any, params: dict, agent: Optional[dict], op, render) -> dict:
"""Shared get/list/delete: ``op(task_id, config_id, slug, tenant)`` falsy => not found."""
task_id = str(params.get("taskId") or "")
if not task_id:
return _err(req_id, protocol.ERR_INVALID_PARAMS, "taskId required")
config_id = str(params.get("id") or params.get("configId") or "")
found = op(task_id, config_id, *self._scope_for_agent(agent))
if not found:
return _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"push config not found for task: {task_id}")
return _ok(req_id, render(found))
def _rpc_push_config_get(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict:
return self._push_config_op(req_id, params, agent, self.tasks.get_push_config, lambda cfg: cfg)
def _rpc_push_config_list(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict:
task_id = str(params.get("taskId") or "")
if not task_id:
return _err(req_id, protocol.ERR_INVALID_PARAMS, "taskId required")
configs = self.tasks.list_push_configs(task_id, *self._scope_for_agent(agent))
return _ok(req_id, {"configs": configs, "nextPageToken": ""})
def _rpc_push_config_delete(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict:
return self._push_config_op(req_id, params, agent, self.tasks.delete_push_config, lambda _: {"deleted": True})
def _send_push_notification(self, task_id: str, context_id: str, reply: str, state: str) -> None:
"""POST a v1.0 StreamResponse payload to the task's registered callback.
The URL is SSRF-checked (internal/private/loopback blocked unless localhost-only mode)."""
callback_url = self.tasks.pop_push_url(task_id)
if not callback_url:
return
if not security.is_safe_callback_url(callback_url, localhost_mode=self._security_context.localhost_only()):
logger.warning("A2A: push notification for task %s blocked — unsafe callback URL: %s", task_id, callback_url)
protocol.metrics.push_failed += 1
return
payload = protocol.status_update(task_id, context_id, state, (reply or "")[:2000])
signature = self._security_context.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 200 <= resp.status < 300:
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(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None):
"""Fulfil the oldest pending reply Future for this context (``chat_id`` = A2A context id).
Only sends carrying ``metadata['notify']`` (the base adapter's final-reply marker,
``_mark_notify_metadata``) satisfy the caller; progress/status/preview sends must not."""
message_id = str(int(time.time() * 1000))
if not (metadata or {}).get("notify"):
logger.debug("A2A: ignoring non-final send for context %s", chat_id)
return SendResult(success=True, message_id=message_id)
if not self._resolve_oldest_for_context(chat_id, protocol.STATE_COMPLETED, content or ""):
logger.debug("A2A: send() for context %s had no pending waiter", chat_id) # late chunk / out-of-band
return SendResult(success=True, message_id=message_id)
async def send_typing(self, chat_id: str, metadata=None) -> None:
return None
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": f"a2a:{chat_id}", "type": "dm"}
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Resolve the task future when processing ends without a reply send
(failures, cancellations, empty runs) so the HTTP thread returns promptly."""
task_id = str(getattr(event, "message_id", "") or "")
if not task_id:
return
state, text = {
ProcessingOutcome.FAILURE: (protocol.STATE_FAILED, "[agent processing failed]"),
ProcessingOutcome.CANCELLED: (protocol.STATE_CANCELED, ""),
}.get(outcome, (protocol.STATE_COMPLETED, ""))
self._resolve_task(task_id, state, text)