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:
parent
c80b3ff0eb
commit
dd57a840b6
@ -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}],
|
||||
}
|
||||
|
||||
@ -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}],
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}],
|
||||
},
|
||||
|
||||
@ -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}",
|
||||
},
|
||||
|
||||
@ -375,7 +375,7 @@ class HeartbeatLoop:
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"role": "ROLE_USER",
|
||||
"parts": [{"type": "text", "text": trigger_msg}],
|
||||
},
|
||||
},
|
||||
|
||||
@ -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}],
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user