fix(runtime): use lowercase wire role for v0.3 JSON-RPC compat layer

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>
This commit is contained in:
Hongming Wang 2026-04-27 12:40:11 -07:00
parent 49ded74876
commit 81c4c1321c
7 changed files with 85 additions and 12 deletions

View File

@ -71,7 +71,7 @@ async def delegate(target_id: str, task: str, async_mode: bool = False):
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": str(uuid.uuid4()),
"parts": [{"kind": "text", "text": task}],
}
@ -108,7 +108,7 @@ async def delegate(target_id: str, task: str, async_mode: bool = False):
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": str(uuid.uuid4()),
"parts": [{"kind": "text", "text": task}],
}

View File

@ -71,7 +71,7 @@ async def send_a2a_message(target_url: str, message: str) -> str:
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": str(uuid.uuid4()),
"parts": [{"kind": "text", "text": message}],
}

View File

@ -57,7 +57,7 @@ async def delegate_task(workspace_id: str, task: str) -> str:
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": str(uuid.uuid4()),
"parts": [{"kind": "text", "text": task}],
},

View File

@ -269,7 +269,7 @@ async def _execute_delegation(task_id: str, workspace_id: str, task: str):
"id": f"delegation-{task_id}-{attempt}",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"parts": [{"kind": "text", "text": task}],
"messageId": f"msg-{task_id}-{attempt}",
},

View File

@ -375,7 +375,7 @@ class HeartbeatLoop:
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"parts": [{"type": "text", "text": trigger_msg}],
},
},

View File

@ -251,10 +251,17 @@ async def main(): # pragma: no cover
# create_agent_card_routes default).
routes = []
routes.extend(create_agent_card_routes(agent_card))
# enable_v0_3_compat=True so any external 0.3.x A2A client (still using
# `"role": "user"` lowercase + camelCase Pydantic field names) can talk
# to us without re-deploying. Internally our outbound payloads are now
# 1.x-shaped (ROLE_USER), but inbound is opt-in compatible.
# enable_v0_3_compat=True is the JSON-RPC wire-compat path: clients
# using v0.3-shaped payloads (`"role": "user"` lowercase + camelCase
# Pydantic field names) can talk to us without re-deploying. Outbound
# JSON-RPC wire payloads MUST also use v0.3 shape — the v0.3 compat
# adapter at /usr/local/lib/python3.11/site-packages/a2a/compat/v0_3/
# validates against Pydantic Role enum (`agent`|`user`) and rejects
# the protobuf-style `ROLE_USER` enum names with JSON-RPC -32600
# (Invalid Request). Native v1.x types (a2a.types.Role.ROLE_AGENT)
# are only for code that constructs Message objects in-process and
# hands them to the SDK, which serialises them correctly for the
# outbound wire format.
routes.extend(create_jsonrpc_routes(request_handler=handler, rpc_url="/", enable_v0_3_compat=True))
app = Starlette(routes=routes)
@ -461,7 +468,7 @@ async def main(): # pragma: no cover
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": f"initial-{_uuid.uuid4().hex[:8]}",
"parts": [{"kind": "text", "text": config.initial_prompt}],
},
@ -560,7 +567,7 @@ async def main(): # pragma: no cover
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": f"idle-{_uuid.uuid4().hex[:8]}",
"parts": [{"kind": "text", "text": config.idle_prompt}],
},

View File

@ -0,0 +1,66 @@
"""Pin the JSON-RPC wire-payload role string format.
The a2a-sdk 1.x migration sweep (PR #2184) over-corrected: it changed
every `"role": "user"` literal in JSON-RPC payload construction to
`"role": "ROLE_USER"` to match the protobuf enum names used by the
1.x native types (a2a.types.Role.ROLE_AGENT / ROLE_USER). That was
correct for in-process Message construction but WRONG for outbound
JSON-RPC wire payloads the workspace's own a2a-sdk runs requests
through the v0.3 compat adapter (because main.py sets
enable_v0_3_compat=True), and that adapter validates against the
v0.3 Pydantic Role enum (`agent`|`user` lowercase). Sending
"ROLE_USER" makes the receiver reject the request with JSON-RPC
-32600 (Invalid Request), which manifests on the canvas as
"Failed to deliver to <peer>: Invalid Request (code=-32600)".
This test does the cheapest possible drift detection: walk every
workspace/*.py file that constructs a JSON-RPC payload (those grep
positive for `"role":` as a dict key) and assert no
`"ROLE_USER"` / `"ROLE_AGENT"` string literals slip in. The native
Python `Role.ROLE_*` form (with the dot) is fine the SDK handles
serialization for those.
"""
from __future__ import annotations
import re
from pathlib import Path
WORKSPACE_ROOT = Path(__file__).resolve().parents[1]
# Files under workspace/ that emit JSON-RPC wire payloads (grep-positive
# for the `"role":` dict key). Keep narrow so the test stays fast.
WIRE_PAYLOAD_FILES = [
"a2a_client.py",
"a2a_cli.py",
"heartbeat.py",
"main.py",
"builtin_tools/a2a_tools.py",
"builtin_tools/delegation.py",
]
# String-literal patterns that signal the protobuf-enum-name leak.
# Match either "ROLE_USER" or 'ROLE_USER' but NOT Role.ROLE_USER (the
# legitimate Python type-level reference, no quotes around the enum
# name part).
FORBIDDEN_LITERAL = re.compile(r"""['"]ROLE_(USER|AGENT)['"]""")
def test_no_protobuf_enum_strings_in_jsonrpc_wire_payloads():
offenders: list[str] = []
for rel in WIRE_PAYLOAD_FILES:
path = WORKSPACE_ROOT / rel
if not path.exists():
continue
for lineno, line in enumerate(path.read_text().splitlines(), 1):
if FORBIDDEN_LITERAL.search(line):
offenders.append(f"{rel}:{lineno}: {line.strip()}")
assert not offenders, (
"JSON-RPC wire payloads must use the v0.3 compat-layer-accepted "
"lowercase role strings ('user' / 'agent'), not the protobuf "
"enum names ('ROLE_USER' / 'ROLE_AGENT'). The v0.3 compat "
"adapter validates against the Pydantic Role enum and rejects "
"the protobuf names with JSON-RPC -32600 (Invalid Request). "
"Offending lines:\n " + "\n ".join(offenders)
)