Manual-test failure surfaced what was hidden behind the MCP-path bug:
once delegate_task could actually fire, every cross-workspace call
came back as JSON-RPC -32600 "Invalid Request" with the underlying
pydantic ValidationError:
params.message.role
Input should be 'agent' or 'user' [type=enum,
input_value='ROLE_USER', input_type=str]
PR #2184's a2a-sdk 1.x migration sweep over-corrected: it changed
every `"role": "user"` literal in JSON-RPC payload construction to
`"role": "ROLE_USER"` to match the protobuf enum names of the 1.x
native types (a2a.types.Role.ROLE_USER / ROLE_AGENT). That was
correct for in-process Message construction (which the SDK
serialises before wire transmission) but WRONG for the 8 sites that
hand-build JSON-RPC payloads. The workspace's own a2a-sdk runs
inbound requests through the v0.3 compat adapter
(/usr/local/lib/python3.11/site-packages/a2a/compat/v0_3/) because
main.py sets enable_v0_3_compat=True for backwards compatibility,
and that adapter validates against the v0.3 Pydantic Role enum
(`agent` | `user` lowercase). The protobuf-style names blow it up.
Reverted the 8 wire-payload sites to lowercase:
- workspace/a2a_client.py:74
- workspace/a2a_cli.py:74, 111
- workspace/heartbeat.py:378
- workspace/main.py:464, 563
- workspace/builtin_tools/a2a_tools.py:60
- workspace/builtin_tools/delegation.py:272
Native-type usage at workspace/a2a_executor.py:471 (`Role.ROLE_AGENT`)
stays — that's an in-process Message construction; the SDK handles
wire serialisation correctly.
Updated the misleading comment at main.py:255-257 (which said
"outbound payloads are now 1.x-shaped (ROLE_USER)") to spell out
the actual rule: outbound JSON-RPC wire payloads MUST use v0.3
shape, native types are only for in-process construction.
New regression test test_jsonrpc_wire_role_format.py greps the 6
wire-payload-emitting files for any "ROLE_USER" / "ROLE_AGENT"
string literal and fails loud — cheapest possible drift detector.
Why E2E missed it: the priority-runtimes harness sends a single
message canvas → workspace, but the canvas already used lowercase
"user" (it never went through the migration sweep). The bug only
surfaces on workspace → workspace delegation, which the harness
doesn't exercise. Same gap as #131 (extend smoke to call main()
against a stub).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.4 KiB
Python
91 lines
3.4 KiB
Python
"""A2A communication tools — framework-agnostic delegation and peer discovery.
|
|
|
|
These are plain async functions that any adapter can wrap in its native tool format.
|
|
The LangChain @tool versions are in tools/delegation.py.
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
|
|
import httpx
|
|
|
|
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
|
WORKSPACE_ID = os.environ.get("WORKSPACE_ID", "")
|
|
|
|
|
|
async def list_peers() -> list[dict]:
|
|
"""Get this workspace's peers from the platform registry."""
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
try:
|
|
resp = await client.get(f"{PLATFORM_URL}/registry/{WORKSPACE_ID}/peers")
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
return []
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
async def delegate_task(workspace_id: str, task: str) -> str:
|
|
"""Send a task to a peer workspace via A2A and return the response text."""
|
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
# Discover target URL
|
|
try:
|
|
resp = await client.get(
|
|
f"{PLATFORM_URL}/registry/discover/{workspace_id}",
|
|
headers={"X-Workspace-ID": WORKSPACE_ID},
|
|
)
|
|
if resp.status_code != 200:
|
|
return f"Error: cannot reach workspace {workspace_id} (status {resp.status_code})"
|
|
target_url = resp.json().get("url", "")
|
|
if not target_url:
|
|
return f"Error: workspace {workspace_id} has no URL"
|
|
except Exception as e:
|
|
return f"Error discovering workspace: {e}"
|
|
|
|
# Send A2A message. X-Workspace-ID identifies us as the source —
|
|
# without it the platform's a2a_receive logger writes
|
|
# source_id=NULL and the recipient's My Chat tab renders the
|
|
# delegation as if a human user typed it. Same hazard fixed
|
|
# in heartbeat.py / a2a_client.py / main.py initial+idle flows.
|
|
try:
|
|
a2a_resp = await client.post(
|
|
target_url,
|
|
headers={"X-Workspace-ID": WORKSPACE_ID},
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": str(uuid.uuid4()),
|
|
"method": "message/send",
|
|
"params": {
|
|
"message": {
|
|
"role": "user",
|
|
"messageId": str(uuid.uuid4()),
|
|
"parts": [{"kind": "text", "text": task}],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
data = a2a_resp.json()
|
|
if "result" in data:
|
|
parts = data["result"].get("parts", [])
|
|
return parts[0].get("text", "(no text)") if parts else str(data["result"])
|
|
elif "error" in data:
|
|
return f"Error: {data['error'].get('message', str(data['error']))}"
|
|
return str(data)
|
|
except Exception as e:
|
|
return f"Error sending A2A message: {e}"
|
|
|
|
|
|
async def get_peers_summary() -> str:
|
|
"""Return a formatted string of available peers for system prompts."""
|
|
peers = await list_peers()
|
|
if not peers:
|
|
return "No peers available."
|
|
lines = []
|
|
for p in peers:
|
|
name = p.get("name", "Unknown")
|
|
pid = p.get("id", "")
|
|
role = p.get("role", "")
|
|
status = p.get("status", "")
|
|
lines.append(f"- {name} (ID: {pid}) — {role} [{status}]")
|
|
return "Available peers:\n" + "\n".join(lines)
|