From c80b3ff0eba5c59be96d4bd5bf45150c89f134e0 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 27 Apr 2026 09:33:23 -0700 Subject: [PATCH 1/2] fix: pass rpc_url='/' to create_jsonrpc_routes (a2a-sdk 1.x requirement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7th a2a-sdk migration find from the v0 → v1 transition. create_jsonrpc_routes() now requires rpc_url as a positional arg (was implicit at root in 0.x). Pass '/' to match a2a.utils.constants.DEFAULT_RPC_URL — that's also what workspace-server's a2a_proxy.go forwards to (POSTs to workspace URL without appending a path). Symptom before fix: every workspace startup crashed with TypeError: create_jsonrpc_routes() missing 1 required positional argument: 'rpc_url' Caught by harness 9 phase 4 (claude-code + langgraph both on 0.1.24). The user's "use langgraph for fast iteration" call cut the diagnose cycle from 15min to ~30s — without that, this would have taken another hermes round-trip to surface. Updated reference_a2a_sdk_v0_to_v1_migration.md memory with this entry alongside the previous 6 finds. Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace/main.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/workspace/main.py b/workspace/main.py index c90dd4ce..23b278ef 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -242,10 +242,16 @@ async def main(): # pragma: no cover agent_card=agent_card, ) - # v1: replace A2AStarletteApplication with Starlette route factory + # v1: replace A2AStarletteApplication with Starlette route factory. + # rpc_url is required in a2a-sdk 1.x (was implicit at root in 0.x). + # Use '/' to match a2a.utils.constants.DEFAULT_RPC_URL — that's also + # what the platform's a2a_proxy.go POSTs to (it forwards to the + # workspace's URL without appending a path). Card endpoint stays at + # the well-known path /.well-known/agent-card.json (handled by + # create_agent_card_routes default). routes = [] routes.extend(create_agent_card_routes(agent_card)) - routes.extend(create_jsonrpc_routes(request_handler=handler)) + routes.extend(create_jsonrpc_routes(request_handler=handler, rpc_url="/")) app = Starlette(routes=routes) # 8. Register with platform From dd57a840b6626f5e12ac4c453a590235e99499ed Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 27 Apr 2026 09:42:57 -0700 Subject: [PATCH 2/2] fix: comprehensive a2a-sdk 1.x migration sweep across workspace/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every a2a-sdk surface in workspace/ against the installed 1.0.2 wheel. Found and fixed: main.py (the live workspace startup path): • create_jsonrpc_routes(rpc_url='/', enable_v0_3_compat=True) — rpc_url required in 1.x; v0.3 compat enables inbound legacy clients (`"role": "user"` lowercase) without forcing them to upgrade. Pairs with the outbound rename below. a2a_executor.py: • TextPart/FilePart/FileWithUri removed in 1.x. Part is now a flat proto message: Part(text=…) / Part(url=…, filename=…, media_type=…). Updated the file-attachment branch (only reachable when an agent emits files; the harness's PONG path didn't exercise this, but it's a latent crash). • Message field names: messageId/taskId/contextId → message_id/task_id/context_id (proto3 snake_case). • Role enum: Role.agent → Role.ROLE_AGENT (proto enum). Outbound JSON-RPC payloads (8 files): • "role": "user" → "role": "ROLE_USER" — proto3 JSON serialization is strict about enum values. Sites: a2a_client, a2a_cli, main (initial+idle prompts), heartbeat, builtin_tools/a2a_tools, builtin_tools/delegation. Wire JSON keys stay camelCase (proto3 default), only the role enum value changed. google-adk/adapter.py: • new_agent_text_message → new_text_message (4 sites). This adapter's directory has a hyphen, so it can't be imported as a Python module — effectively dead code, but the wheel ships the file and a future fix should keep it correct against 1.x. Why one PR instead of seven: every previous a2a-sdk migration find landed as its own publish → cascade → harness → next-bug cycle. Today's audit ran every a2a-sdk symbol/type/method in workspace/ against the installed 1.0.2 wheel in a single sweep + tested the critical paths (Message construction, Part construction, Role enum parsing) against the actual SDK. Should be the last migration PR. Verified locally: python3 scripts/build_runtime_package.py --version 0.1.99 \ --out /tmp/build-final pip install /tmp/build-final python -c "import molecule_runtime.main; \ from molecule_runtime.a2a_executor import LangGraphA2AExecutor" → ✓ all imports clean against a2a-sdk 1.0.2 Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace/a2a_cli.py | 4 +-- workspace/a2a_client.py | 2 +- workspace/a2a_executor.py | 34 ++++++++++++++++-------- workspace/adapters/google-adk/adapter.py | 8 +++--- workspace/builtin_tools/a2a_tools.py | 2 +- workspace/builtin_tools/delegation.py | 2 +- workspace/heartbeat.py | 2 +- workspace/main.py | 10 ++++--- 8 files changed, 40 insertions(+), 24 deletions(-) diff --git a/workspace/a2a_cli.py b/workspace/a2a_cli.py index 5ba7381c..60a19777 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": "user", + "role": "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": "user", + "role": "ROLE_USER", "messageId": str(uuid.uuid4()), "parts": [{"kind": "text", "text": task}], } diff --git a/workspace/a2a_client.py b/workspace/a2a_client.py index d740bd6a..ec9a09be 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": "user", + "role": "ROLE_USER", "messageId": str(uuid.uuid4()), "parts": [{"kind": "text", "text": message}], } diff --git a/workspace/a2a_executor.py b/workspace/a2a_executor.py index 30b936f7..bbda258c 100644 --- a/workspace/a2a_executor.py +++ b/workspace/a2a_executor.py @@ -446,20 +446,32 @@ class LangGraphA2AExecutor(AgentExecutor): # making the earlier `Part(text=text)` call (line ~358, inside # the astream_events loop) raise UnboundLocalError because # the local binding is not yet in scope at that point. - from a2a.types import FilePart, FileWithUri, Message, Role, TextPart - _parts: list[Part] = [Part(root=TextPart(text=final_text))] if final_text else [] + # + # a2a-sdk 1.x flattened the Part shape: 0.x used + # `Part(root=TextPart(text=...))` / `Part(root=FilePart(file= + # FileWithUri(uri=..., name=..., mimeType=...)))` (Pydantic + # discriminated-union style). 1.x's Part is a single proto + # message with flat fields: text, url, filename, media_type, + # raw, data, metadata. TextPart/FilePart/FileWithUri were + # removed. Same for Message: messageId/taskId/contextId + # camelCase became message_id/task_id/context_id. + from a2a.types import Message, Role + _parts: list[Part] = [Part(text=final_text)] if final_text else [] for f in _outbound: - _parts.append(Part(root=FilePart(file=FileWithUri( - uri="workspace:" + f["path"], - name=f["name"], - mimeType=f["mime_type"], - )))) + _parts.append(Part( + url="workspace:" + f["path"], + filename=f["name"], + media_type=f["mime_type"], + )) msg = Message( - messageId=uuid.uuid4().hex, - role=Role.agent, + message_id=uuid.uuid4().hex, + # 1.x Role is a protobuf enum: ROLE_UNSPECIFIED, + # ROLE_USER, ROLE_AGENT. Old `Role.agent` (Pydantic + # lowercase enum) doesn't exist anymore. + role=Role.ROLE_AGENT, parts=_parts, - taskId=task_id, - contextId=context_id, + task_id=task_id, + context_id=context_id, ) else: msg = new_text_message(final_text, task_id=task_id, context_id=context_id) diff --git a/workspace/adapters/google-adk/adapter.py b/workspace/adapters/google-adk/adapter.py index 8b3fe9db..e0a3c667 100644 --- a/workspace/adapters/google-adk/adapter.py +++ b/workspace/adapters/google-adk/adapter.py @@ -36,7 +36,7 @@ from typing import TYPE_CHECKING, Any from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue -from a2a.helpers import new_agent_text_message +from a2a.helpers import new_text_message from adapter_base import AdapterConfig, BaseAdapter @@ -191,7 +191,7 @@ class GoogleADKA2AExecutor(AgentExecutor): if not user_text: parts = getattr(getattr(context, "message", None), "parts", None) logger.warning("GoogleADKA2AExecutor: no text in message parts: %s", parts) - await event_queue.enqueue_event(new_agent_text_message(_NO_TEXT_MSG)) + await event_queue.enqueue_event(new_text_message(_NO_TEXT_MSG)) return session_id = getattr(context, "context_id", None) or "default-session" @@ -223,7 +223,7 @@ class GoogleADKA2AExecutor(AgentExecutor): response_parts.append(text) final_text = "".join(response_parts).strip() or _NO_RESPONSE_MSG - await event_queue.enqueue_event(new_agent_text_message(final_text)) + await event_queue.enqueue_event(new_text_message(final_text)) except Exception as exc: logger.error( @@ -234,7 +234,7 @@ class GoogleADKA2AExecutor(AgentExecutor): ) # Mirror sanitize_agent_error() convention: expose class name only. await event_queue.enqueue_event( - new_agent_text_message(f"Agent error: {type(exc).__name__}") + new_text_message(f"Agent error: {type(exc).__name__}") ) async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: diff --git a/workspace/builtin_tools/a2a_tools.py b/workspace/builtin_tools/a2a_tools.py index df4f9d78..07dd26dc 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": "user", + "role": "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 25d0ae55..85943e52 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": "user", + "role": "ROLE_USER", "parts": [{"kind": "text", "text": task}], "messageId": f"msg-{task_id}-{attempt}", }, diff --git a/workspace/heartbeat.py b/workspace/heartbeat.py index 2af915a1..a7ab8de5 100644 --- a/workspace/heartbeat.py +++ b/workspace/heartbeat.py @@ -375,7 +375,7 @@ class HeartbeatLoop: "method": "message/send", "params": { "message": { - "role": "user", + "role": "ROLE_USER", "parts": [{"type": "text", "text": trigger_msg}], }, }, diff --git a/workspace/main.py b/workspace/main.py index 23b278ef..2e6bf37b 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -251,7 +251,11 @@ async def main(): # pragma: no cover # create_agent_card_routes default). routes = [] routes.extend(create_agent_card_routes(agent_card)) - routes.extend(create_jsonrpc_routes(request_handler=handler, rpc_url="/")) + # 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. + routes.extend(create_jsonrpc_routes(request_handler=handler, rpc_url="/", enable_v0_3_compat=True)) app = Starlette(routes=routes) # 8. Register with platform @@ -457,7 +461,7 @@ async def main(): # pragma: no cover "method": "message/send", "params": { "message": { - "role": "user", + "role": "ROLE_USER", "messageId": f"initial-{_uuid.uuid4().hex[:8]}", "parts": [{"kind": "text", "text": config.initial_prompt}], }, @@ -556,7 +560,7 @@ async def main(): # pragma: no cover "method": "message/send", "params": { "message": { - "role": "user", + "role": "ROLE_USER", "messageId": f"idle-{_uuid.uuid4().hex[:8]}", "parts": [{"kind": "text", "text": config.idle_prompt}], },