Repository navigation
refactor(a2a)!: migrate A2A SDK from v0.3 to v1.1 #3923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
59dd6b3
a256a62
f53e6fa
1fb4c11
c271d23
94348fd
fb915de
9547776
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -68,8 +68,8 @@ docs = [ | |||||||||
| ] | ||||||||||
|
|
||||||||||
| a2a = [ | ||||||||||
| "a2a-sdk>=0.3.0,<0.4.0", | ||||||||||
| "a2a-sdk[sql]>=0.3.0,<0.4.0", | ||||||||||
| "a2a-sdk>=1.0.0,<2.0.0", | ||||||||||
| "a2a-sdk[sql]>=1.0.0,<2.0.0", | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The declared floor doesn't import —
Any install whose resolver lands on 1.0.x — an existing Suggestion: raise both bounds. Cost: a published lower bound can't be raised later without that itself being a breaking dependency change, so this is cheapest before release.
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed as suggested. |
||||||||||
| "uvicorn>=0.34.2,<1.0.0", | ||||||||||
| "httpx>=0.28.1,<1.0.0", | ||||||||||
| "fastapi>=0.133.0,<1.0.0", | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,11 @@ | ||
| """Conversion functions between Strands and A2A types.""" | ||
|
|
||
| from collections.abc import Sequence | ||
| from typing import cast | ||
| from uuid import uuid4 | ||
|
|
||
| from a2a.types import Message as A2AMessage | ||
| from a2a.types import Part, Role, TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent, TextPart | ||
| from a2a.types import Part, Role, TaskState | ||
|
|
||
| from ...agent.agent_result import AgentResult | ||
| from ...telemetry.metrics import EventLoopMetrics | ||
|
|
@@ -15,15 +16,25 @@ | |
|
|
||
| # Mapping from A2A TaskState to Strands stop_reason | ||
| _STATE_TO_STOP_REASON: dict[TaskState, StopReason] = { | ||
| TaskState.completed: "end_turn", | ||
| TaskState.failed: "end_turn", | ||
| TaskState.canceled: "end_turn", | ||
| TaskState.rejected: "end_turn", | ||
| TaskState.input_required: "interrupt", | ||
| TaskState.auth_required: "interrupt", | ||
| TaskState.TASK_STATE_COMPLETED: "end_turn", | ||
| TaskState.TASK_STATE_FAILED: "end_turn", | ||
| TaskState.TASK_STATE_CANCELED: "end_turn", | ||
| TaskState.TASK_STATE_REJECTED: "end_turn", | ||
| TaskState.TASK_STATE_INPUT_REQUIRED: "interrupt", | ||
| TaskState.TASK_STATE_AUTH_REQUIRED: "interrupt", | ||
| } | ||
|
|
||
|
|
||
| def _task_state_to_str(task_state: TaskState) -> str: | ||
| """Render a TaskState as the kebab-case wire string used by the A2A v0.3 JSON spec. | ||
|
|
||
| v1.0's protobuf enum names are SCREAMING_SNAKE_CASE (e.g. ``TASK_STATE_INPUT_REQUIRED``); | ||
| this keeps ``AgentResult.state["a2a_task_state"]`` stable for existing Strands callers. | ||
| """ | ||
| name: str = TaskState.Name(task_state) # type: ignore[attr-defined] | ||
| return name.removeprefix("TASK_STATE_").lower().replace("_", "-") | ||
|
|
||
|
|
||
| def convert_input_to_message(prompt: AgentInput) -> A2AMessage: | ||
| """Convert AgentInput to A2A Message. | ||
|
|
||
|
|
@@ -40,9 +51,8 @@ def convert_input_to_message(prompt: AgentInput) -> A2AMessage: | |
|
|
||
| if isinstance(prompt, str): | ||
| return A2AMessage( | ||
| kind="message", | ||
| role=Role.user, | ||
| parts=[Part(TextPart(kind="text", text=prompt))], | ||
| role=Role.ROLE_USER, | ||
| parts=[Part(text=prompt)], | ||
| message_id=message_id, | ||
| ) | ||
|
|
||
|
|
@@ -57,16 +67,14 @@ def convert_input_to_message(prompt: AgentInput) -> A2AMessage: | |
| content = cast(list[ContentBlock], msg.get("content", [])) | ||
| parts = convert_content_blocks_to_parts(content) | ||
| return A2AMessage( | ||
| kind="message", | ||
| role=Role.user, | ||
| role=Role.ROLE_USER, | ||
| parts=parts, | ||
| message_id=message_id, | ||
| ) | ||
| else: | ||
| parts = convert_content_blocks_to_parts(cast(list[ContentBlock], prompt)) | ||
| return A2AMessage( | ||
| kind="message", | ||
| role=Role.user, | ||
| role=Role.ROLE_USER, | ||
| parts=parts, | ||
| message_id=message_id, | ||
| ) | ||
|
|
@@ -86,29 +94,45 @@ def convert_content_blocks_to_parts(content_blocks: list[ContentBlock]) -> list[ | |
| parts = [] | ||
| for block in content_blocks: | ||
| if "text" in block: | ||
| parts.append(Part(TextPart(kind="text", text=block["text"]))) | ||
| parts.append(Part(text=block["text"])) | ||
| return parts | ||
|
|
||
|
|
||
| def _extract_task_state(response: A2AResponse) -> TaskState | None: | ||
| """Extract the task state from an A2A response. | ||
| """Extract the task state carried by a single A2A StreamResponse, if any. | ||
|
|
||
| Args: | ||
| response: A2A response (either A2AMessage or tuple of task and update event). | ||
| response: A single StreamResponse from the A2A event stream. | ||
|
|
||
| Returns: | ||
| The TaskState if available, None otherwise. | ||
| The TaskState if this response carries one (a ``task`` or ``status_update``), else None. | ||
| """ | ||
| if isinstance(response, tuple) and len(response) == 2: | ||
| _task, update_event = response | ||
| if isinstance(update_event, TaskStatusUpdateEvent): | ||
| if update_event.status and hasattr(update_event.status, "state"): | ||
| return update_event.status.state | ||
| if response.HasField("status_update"): | ||
| return response.status_update.status.state | ||
| if response.HasField("task"): | ||
| return response.task.status.state | ||
| return None | ||
|
|
||
|
|
||
| def convert_response_to_agent_result(response: A2AResponse) -> AgentResult: | ||
| """Convert A2A response to AgentResult. | ||
| def _parts_to_content(parts: Sequence[Part]) -> list[ContentBlock]: | ||
| """Convert a sequence of A2A text Parts into Strands ContentBlocks, dropping non-text parts.""" | ||
| return [{"text": part.text} for part in parts if part.HasField("text")] | ||
|
|
||
|
|
||
| def convert_responses_to_agent_result(responses: Sequence[A2AResponse]) -> AgentResult: | ||
| """Convert the full sequence of A2A StreamResponse events from one call into an AgentResult. | ||
|
|
||
| A2A v1.0 streams flat ``StreamResponse`` events (``task`` | ``message`` | ``status_update`` | | ||
| ``artifact_update``) rather than pairing a cumulative Task with each update, so the final | ||
| content is reconstructed across the whole stream: | ||
| - ``artifact_update`` parts accumulate: a compliant-streaming server sends incremental deltas, | ||
| while a non-compliant server that sends the full text once still works, as that is then the | ||
| only chunk. | ||
| - ``status_update`` messages are progress narration on non-compliant-streaming servers and | ||
| would duplicate the artifact content above, so they are only used when no artifact content | ||
| was found (e.g. a status-only terminal event such as a rejection or failure message). | ||
| - a bare ``task`` or ``message`` response (no separate update events) supplies content | ||
| directly. | ||
|
|
||
| Maps A2A task lifecycle states to appropriate Strands stop_reasons: | ||
| - completed → end_turn | ||
|
|
@@ -119,61 +143,50 @@ def convert_response_to_agent_result(response: A2AResponse) -> AgentResult: | |
| - auth_required → interrupt (agent needs authentication) | ||
|
|
||
| Args: | ||
| response: A2A response (either A2AMessage or tuple of task and update event). | ||
| responses: All StreamResponse events observed for one ``send_message`` call, in order. | ||
|
|
||
| Returns: | ||
| AgentResult with extracted content and metadata. | ||
| """ | ||
| content: list[ContentBlock] = [] | ||
| task_state = _extract_task_state(response) | ||
| artifact_content: list[ContentBlock] = [] | ||
| status_message_content: list[ContentBlock] = [] | ||
| task_content: list[ContentBlock] = [] | ||
| message_content: list[ContentBlock] = [] | ||
| task_state: TaskState | None = None | ||
|
|
||
| for response in responses: | ||
| state = _extract_task_state(response) | ||
| if state is not None: | ||
| task_state = state | ||
|
|
||
| if response.HasField("artifact_update"): | ||
| artifact_content.extend(_parts_to_content(response.artifact_update.artifact.parts)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
Suggestion: key accumulation on
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed as part of the same |
||
| elif response.HasField("status_update"): | ||
| if response.status_update.status.HasField("message"): | ||
| status_message_content = _parts_to_content(response.status_update.status.message.parts) | ||
| elif response.HasField("task"): | ||
| task_content = [ | ||
| content for artifact in response.task.artifacts for content in _parts_to_content(artifact.parts) | ||
| ] | ||
| elif response.HasField("message"): | ||
| message_content = _parts_to_content(response.message.parts) | ||
|
|
||
| content = artifact_content or status_message_content or task_content or message_content | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. concern — The docstring above treats status messages as duplicate narration. True for a terminal With
0.3 did not have this problem: Appending instead of falling back when the final state is an interrupt would restore it: content = artifact_content or task_content or message_content
if _STATE_TO_STOP_REASON.get(task_state) == "interrupt" or not content:
content = content + status_message_contentOn tests:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will make a combined fix for this as well as #3923 (comment)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 A terminal status message is silently dropped whenever any artifact content exists — the caller gets Reached from Suggestion: capture a status message whose
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replaced the flat Added a test covering artifact deltas + a terminal |
||
| stop_reason: StopReason = _STATE_TO_STOP_REASON.get(task_state, "end_turn") if task_state else "end_turn" | ||
|
|
||
| if isinstance(response, tuple) and len(response) == 2: | ||
| task, update_event = response | ||
|
|
||
| # Handle artifact updates | ||
| if isinstance(update_event, TaskArtifactUpdateEvent): | ||
| if update_event.artifact and hasattr(update_event.artifact, "parts") and update_event.artifact.parts: | ||
| for part in update_event.artifact.parts: | ||
| if hasattr(part, "root") and hasattr(part.root, "text"): | ||
| content.append({"text": part.root.text}) | ||
| # Handle status updates with messages | ||
| elif isinstance(update_event, TaskStatusUpdateEvent): | ||
| if ( | ||
| update_event.status | ||
| and hasattr(update_event.status, "message") | ||
| and update_event.status.message | ||
| and update_event.status.message.parts | ||
| ): | ||
| for part in update_event.status.message.parts: | ||
| if hasattr(part, "root") and hasattr(part.root, "text"): | ||
| content.append({"text": part.root.text}) | ||
|
|
||
| # Use task.artifacts when no content was extracted from the event | ||
| if not content and task and hasattr(task, "artifacts") and task.artifacts is not None: | ||
| for artifact in task.artifacts: | ||
| if hasattr(artifact, "parts") and artifact.parts: | ||
| for part in artifact.parts: | ||
| if hasattr(part, "root") and hasattr(part.root, "text"): | ||
| content.append({"text": part.root.text}) | ||
| elif isinstance(response, A2AMessage): | ||
| for part in response.parts: | ||
| if hasattr(part, "root") and hasattr(part.root, "text"): | ||
| content.append({"text": part.root.text}) | ||
|
|
||
| message: Message = { | ||
| "role": "assistant", | ||
| "content": content, | ||
| } | ||
|
|
||
| # Build state dict with A2A metadata | ||
| state: dict[str, str] = {} | ||
| state_dict: dict[str, str] = {} | ||
| if task_state is not None: | ||
| state["a2a_task_state"] = task_state.value | ||
| state_dict["a2a_task_state"] = _task_state_to_str(task_state) | ||
|
|
||
| return AgentResult( | ||
| stop_reason=stop_reason, | ||
| message=message, | ||
| metrics=EventLoopMetrics(), | ||
| state=state, | ||
| state=state_dict, | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
concern — this floor can't import. Two of the new imports do not exist at 1.0.0.
from a2a.server.routes import add_a2a_routes_to_fastapi(server.py:14). This lands ina2a/server/routes/fastapi_routes.pyin 1.1.0. At 1.0.0, 1.0.1, 1.0.2 and 1.0.3,routes/__init__.pyexports onlycreate_agent_card_routes,create_jsonrpc_routes,create_rest_routesand the two context builders.from a2a.helpers import new_data_part, ..., new_text_part(executor.py:21). Both are defined inproto_helpers.pyfrom 1.0.2, but only re-exported from thea2a.helperspackage in 1.0.3. At 1.0.2 the package__init__never imports them, sofrom a2a.helpers import new_text_partstill raises.So any resolver that picks 1.0.0–1.0.3 fails at
import strands.multiagent.a2awithImportError. Your environment presumably has 1.1.x installed, which is whyhatch testis green.Please bump both entries to
>=1.1.0,<2.0.0. The PR and issue titles are worth renaming too — this is a migration to 1.1, not 1.0.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, I totally missed it because after adding this constrain, the dependency manager installed the latest version
1.1.2so CI never caught it. I'll bump to>=1.1.0,<2.0.0would ensure the missing functions being present.I'll rename the issue and PR title and remove any false claim in the description.