diff --git a/workspace/a2a_cli.py b/workspace/a2a_cli.py index 60a19777..5ba7381c 100644 --- a/workspace/a2a_cli.py +++ b/workspace/a2a_cli.py @@ -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}], } diff --git a/workspace/a2a_client.py b/workspace/a2a_client.py index ec9a09be..d740bd6a 100644 --- a/workspace/a2a_client.py +++ b/workspace/a2a_client.py @@ -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}], } diff --git a/workspace/builtin_tools/a2a_tools.py b/workspace/builtin_tools/a2a_tools.py index 07dd26dc..df4f9d78 100644 --- a/workspace/builtin_tools/a2a_tools.py +++ b/workspace/builtin_tools/a2a_tools.py @@ -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}], }, diff --git a/workspace/builtin_tools/delegation.py b/workspace/builtin_tools/delegation.py index 85943e52..25d0ae55 100644 --- a/workspace/builtin_tools/delegation.py +++ b/workspace/builtin_tools/delegation.py @@ -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}", }, diff --git a/workspace/heartbeat.py b/workspace/heartbeat.py index a7ab8de5..2af915a1 100644 --- a/workspace/heartbeat.py +++ b/workspace/heartbeat.py @@ -375,7 +375,7 @@ class HeartbeatLoop: "method": "message/send", "params": { "message": { - "role": "ROLE_USER", + "role": "user", "parts": [{"type": "text", "text": trigger_msg}], }, }, diff --git a/workspace/main.py b/workspace/main.py index 2e6bf37b..5c09b134 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -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}], }, diff --git a/workspace/tests/test_jsonrpc_wire_role_format.py b/workspace/tests/test_jsonrpc_wire_role_format.py new file mode 100644 index 00000000..1535952c --- /dev/null +++ b/workspace/tests/test_jsonrpc_wire_role_format.py @@ -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 : 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) + )