Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions workspace/a2a_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}],
}
Expand Down Expand Up @@ -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}],
}
Expand Down
2 changes: 1 addition & 1 deletion workspace/a2a_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}],
}
Expand Down
2 changes: 1 addition & 1 deletion workspace/builtin_tools/a2a_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}],
},
Expand Down
2 changes: 1 addition & 1 deletion workspace/builtin_tools/delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
},
Expand Down
2 changes: 1 addition & 1 deletion workspace/heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ async def _check_delegations(self, client: httpx.AsyncClient):
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"parts": [{"type": "text", "text": trigger_msg}],
},
},
Expand Down
19 changes: 13 additions & 6 deletions workspace/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -461,7 +468,7 @@ def _do_send_sync():
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": f"initial-{_uuid.uuid4().hex[:8]}",
"parts": [{"kind": "text", "text": config.initial_prompt}],
},
Expand Down Expand Up @@ -560,7 +567,7 @@ async def _run_idle_loop():
"method": "message/send",
"params": {
"message": {
"role": "ROLE_USER",
"role": "user",
"messageId": f"idle-{_uuid.uuid4().hex[:8]}",
"parts": [{"kind": "text", "text": config.idle_prompt}],
},
Expand Down
66 changes: 66 additions & 0 deletions workspace/tests/test_jsonrpc_wire_role_format.py
Original file line number Diff line number Diff line change
@@ -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)
)
Loading