fix: comprehensive a2a-sdk 1.x migration sweep across workspace/

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-27 09:42:57 -07:00
parent c80b3ff0eb
commit dd57a840b6
8 changed files with 40 additions and 24 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": "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}],
}

View File

@ -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}],
}

View File

@ -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)

View File

@ -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:

View File

@ -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}],
},

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": "user",
"role": "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": "user",
"role": "ROLE_USER",
"parts": [{"type": "text", "text": trigger_msg}],
},
},

View File

@ -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}],
},