hermes-agent/plugins/platforms/a2a/tools.py
teknium1 9bf2dac6b9
fix(a2a): client tools take args-as-dict positional; accept agent_name alias
Live Tier-3 testing (CLI agent -> a2a tools -> live peer gateway -> model)
surfaced two bugs the kwarg-style unit tests masked:

1. registry.dispatch calls handlers as handler(args, **kwargs) — args is the
   whole dict positional. The handlers used keyword params (url=, agent=), so
   the dict bound to the first param and .strip() raised
   'dict object has no attribute strip'. Rewrote all three handlers to take
   args: dict (matching the spotify/google_meet convention). Added a
   registry-dispatch regression test that exercises the real call path the
   direct-kwarg tests never hit.

2. The model repeatedly reached for agent_name= instead of agent= (6 retries
   before success). Accept agent_name/name and message/text/task aliases so a
   reasonable guess succeeds first try.

Verified live: client agent discovers the peer's Agent Card, calls it, and
gets the reply back (PONG round-trip confirmed on both client audit log and
peer conversation log). 39 plugin tests pass.
2026-07-06 02:16:57 -07:00

315 lines
11 KiB
Python

"""
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
Peers are resolved from config.yaml under ``a2a_agents``::
a2a_agents:
researcher:
url: "http://localhost:9999"
auth: { type: bearer, token: "sk-..." }
timeout: 120
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.
"""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any, Optional
from . import protocol, security
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 120
# --------------------------------------------------------------------------
# Peer resolution
# --------------------------------------------------------------------------
def _load_config() -> dict:
try:
from hermes_cli.config import load_config
return load_config() or {}
except Exception:
return {}
def _resolve_peer(agent: str) -> Optional[dict]:
"""Resolve a peer name to {url, auth, timeout}, or treat ``agent`` as a URL."""
if agent.startswith("http://") or agent.startswith("https://"):
return {"url": agent, "auth": {}, "timeout": _DEFAULT_TIMEOUT}
cfg = _load_config()
peers = cfg.get("a2a_agents") or {}
entry = peers.get(agent)
if not entry:
return None
return {
"url": entry.get("url", ""),
"auth": entry.get("auth", {}) or {},
"timeout": int(entry.get("timeout", _DEFAULT_TIMEOUT)),
}
def _auth_header(auth: dict) -> dict:
if auth and auth.get("type") == "bearer" and auth.get("token"):
return {"Authorization": f"Bearer {auth['token']}"}
return {}
# --------------------------------------------------------------------------
# HTTP
# --------------------------------------------------------------------------
def _http_get_json(url: str, headers: dict, timeout: int) -> dict:
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (configured peers)
return json.loads(resp.read().decode("utf-8"))
def _http_post_json(url: str, body: dict, headers: dict, timeout: int) -> dict:
data = json.dumps(body).encode("utf-8")
hdrs = {"Content-Type": "application/json", **headers}
req = urllib.request.Request(url, data=data, headers=hdrs, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (configured peers)
return json.loads(resp.read().decode("utf-8"))
def _card_url(base_url: str) -> str:
return base_url.rstrip("/") + "/.well-known/agent.json"
def _rpc_url(base_url: str, card: Optional[dict]) -> str:
# Prefer the URL the card advertises; fall back to the base.
if card and isinstance(card.get("url"), str) and card["url"]:
return card["url"]
return base_url.rstrip("/")
# --------------------------------------------------------------------------
# Tool handlers
# --------------------------------------------------------------------------
def a2a_discover(args: dict, **_: Any) -> str:
"""Fetch and summarize the Agent Card at ``url``."""
url = str(args.get("url") or "").strip()
if not url:
return "Error: 'url' is required (e.g. http://localhost:9999)."
try:
card = _http_get_json(_card_url(url), {}, _DEFAULT_TIMEOUT)
except urllib.error.HTTPError as e:
return f"Error: discovery failed — HTTP {e.code} from {url}."
except Exception as e:
return f"Error: could not reach {url}{e}."
name = card.get("name", "?")
desc = card.get("description", "")
caps = card.get("capabilities", {}) or {}
skills = card.get("skills", []) or []
auth = "yes" if card.get("security") else "no"
lines = [
f"Agent: {name}",
f"Description: {desc}",
f"URL: {card.get('url', url)}",
f"Streaming: {bool(caps.get('streaming'))} Auth required: {auth}",
f"Skills ({len(skills)}):",
]
for s in skills[:20]:
lines.append(f" - {s.get('name', s.get('id', '?'))}: {s.get('description', '')}")
return "\n".join(lines)
def a2a_call(args: dict, **_: Any) -> str:
"""Send a task to a peer agent and return its reply.
``agent`` is a configured peer name (from ``a2a_agents``) or a direct URL.
``context_id`` continues a prior exchange (multi-turn) when provided.
"""
# Accept common aliases models reach for (observed live: 'agent_name').
agent = str(args.get("agent") or args.get("agent_name") or args.get("name") or "").strip()
message = str(args.get("message") or args.get("text") or args.get("task") or "").strip()
context_id = str(args.get("context_id") or args.get("contextId") or "").strip()
if not agent or not message:
return "Error: both 'agent' and 'message' are required."
peer = _resolve_peer(agent)
if not peer or not peer.get("url"):
return (
f"Error: unknown agent '{agent}'. Configure it under 'a2a_agents' in "
f"config.yaml or pass a full http(s):// URL."
)
base_url = peer["url"]
headers = _auth_header(peer["auth"])
timeout = peer["timeout"]
# Best-effort card fetch (to learn the rpc URL); non-fatal on failure.
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"]["message"]["contextId"] = context_id
security.audit("outbound", agent, rpc_body["id"], safe_message)
protocol.persist_message(ctx, "user", safe_message, rpc_body["id"])
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."
return f"Error: call to '{agent}' failed — HTTP {e.code}."
except Exception as e:
return f"Error: call to '{agent}' failed — {e}."
if "error" in resp:
err = resp["error"]
return f"Peer '{agent}' returned an 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"])
state = ""
if isinstance(result, dict):
state = (result.get("status") or {}).get("state", "")
header = f"[{agent} · context {reply_ctx}"
if state:
header += f" · {state}"
header += "]"
return f"{header}\n{reply or '(no text reply)'}"
def _reply_text_from_result(result: Any) -> str:
if not isinstance(result, dict):
return str(result)
# Artifacts first (final output), then status message (interim/clarify).
for artifact in result.get("artifacts", []) or []:
txt = protocol.extract_text(artifact)
if txt:
return txt
status = result.get("status", {}) or {}
msg = status.get("message")
if msg:
return protocol.extract_text(msg)
# Bare message result (message/send may return a Message instead of a Task)
return protocol.extract_text(result)
def a2a_list(args: dict | None = None, **_: Any) -> str:
"""List configured A2A peers and any persisted conversations."""
cfg = _load_config()
peers = cfg.get("a2a_agents") or {}
lines = []
if peers:
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})")
else:
lines.append("No peers configured. Add them under 'a2a_agents' in config.yaml.")
convos = protocol.list_conversations()
if convos:
lines.append("")
lines.append(f"Persisted conversations ({len(convos)}):")
for c in convos[:25]:
lines.append(f" - {c}")
return "\n".join(lines)
# --------------------------------------------------------------------------
# Tool schemas + registration
# --------------------------------------------------------------------------
_SCHEMAS = {
"a2a_discover": {
"type": "function",
"function": {
"name": "a2a_discover",
"description": (
"Fetch and summarize another agent's A2A Agent Card from a URL "
"(its name, description, capabilities, and skills). Use this to "
"find out what a remote agent can do before calling it."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Base URL of the remote A2A agent, e.g. http://localhost:9999"},
},
"required": ["url"],
},
},
},
"a2a_call": {
"type": "function",
"function": {
"name": "a2a_call",
"description": (
"Send a natural-language task to a remote A2A agent and return "
"its reply. The agent is a peer (any A2A-compliant framework), "
"not a sub-agent you control. Pass 'context_id' from a previous "
"reply to continue a multi-turn exchange."
),
"parameters": {
"type": "object",
"properties": {
"agent": {"type": "string", "description": "Configured peer name (from a2a_agents) or a full http(s):// URL."},
"message": {"type": "string", "description": "The task / message to send the peer, in natural language."},
"context_id": {"type": "string", "description": "Optional: context id from a prior reply, to continue the conversation."},
},
"required": ["agent", "message"],
},
},
},
"a2a_list": {
"type": "function",
"function": {
"name": "a2a_list",
"description": "List configured A2A peer agents and persisted A2A conversations.",
"parameters": {"type": "object", "properties": {}},
},
},
}
_HANDLERS = {
"a2a_discover": a2a_discover,
"a2a_call": a2a_call,
"a2a_list": a2a_list,
}
def register_tools(ctx) -> None:
"""Register the three client tools in the ``a2a`` toolset."""
for name, schema in _SCHEMAS.items():
ctx.register_tool(
name=name,
toolset="a2a",
schema=schema,
handler=_HANDLERS[name],
description=schema["function"]["description"],
emoji="\U0001f9e9", # puzzle piece
)