forked from molecule-ai/molecule-core
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:
parent
49ded74876
commit
81c4c1321c
@ -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}],
|
||||
}
|
||||
|
||||
@ -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}],
|
||||
}
|
||||
|
||||
@ -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}],
|
||||
},
|
||||
|
||||
@ -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}",
|
||||
},
|
||||
|
||||
@ -375,7 +375,7 @@ class HeartbeatLoop:
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "ROLE_USER",
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": trigger_msg}],
|
||||
},
|
||||
},
|
||||
|
||||
@ -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}],
|
||||
},
|
||||
|
||||
66
workspace/tests/test_jsonrpc_wire_role_format.py
Normal file
66
workspace/tests/test_jsonrpc_wire_role_format.py
Normal 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)
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user