Skip to content

Commit 0478b02

Browse files
sokolivaharanrk
authored andcommitted
fix(a2a): Support to_a2a(Workflow) and reject non-agent root nodes
### Link to Issue - Related: #5487 **Problem:** `to_a2a(workflow)` fails because `AgentCardBuilder` requires field `sub_agents` which `Workflow` does not have. **Solution:** - Make `to_a2a()` and `AgentCardBuilder` accept `Workflow` (the v2 graph orchestrator) as a root, not just `BaseAgent`. Previously crashed with`RuntimeError: 'Workflow' object has no attribute 'sub_agents'`. - Tighten the public type contract to `BaseAgent | Workflow` and reject other `BaseNode` subtypes (e.g. `FunctionNode`, `JoinNode`) at call time with `TypeError`. They previously produced a degenerate "custom agent" card silently. ### Testing Plan **tests/unittests/a2a/utils/test_agent_card_builder.py** — 9 new tests: - test_get_agent_type_workflow — returns 'graph_workflow' for the new v2 Workflow. - test_get_agent_skill_name_workflow — returns 'workflow' for Workflow. - test_init_rejects_function_node — AgentCardBuilder(agent=FunctionNode(...)) raises TypeError (regression coverage for the runtime guard). - test_init_rejects_arbitrary_object — AgentCardBuilder(agent="...") raises TypeError. - test_build_succeeds_for_llm_agent — regression coverage that the original BaseAgent path still works after the type narrowing. - test_build_succeeds_for_workflow_with_llm_agent_node — exact OP repro shape, end-to-end through build(). - test_build_succeeds_for_workflow_with_output_schema_node — covers the output_schema shape mentioned in the issue. - test_build_succeeds_for_empty_workflow — degenerate but valid case (no edges). - test_get_workflow_description_workflow_with_nodes — verifies graph nodes appear in the description string. - test_get_workflow_description_empty_workflow — returns None when no nodes. **tests/unittests/a2a/utils/test_agent_to_a2a.py** — 2 new tests, 1 rewritten: - test_to_a2a_succeeds_for_workflow (new) — end-to-end through the actual Starlette lifespan (the exact code path that crashed in the OP's repro). - test_to_a2a_rejects_function_node (new) — to_a2a(FunctionNode(...)) raises TypeError at call time. - test_to_a2a_rejects_non_agent_non_workflow (rewrote existing test_to_a2a_with_invalid_agent_type) — now asserts TypeError raised eagerly at to_a2a() call time instead of AttributeError raised lazily during request handling. This is a deliberate behavior change, not a regression: the old test encoded buggy lazy-failure UX where misuse only surfaced when a client hit the endpoint. **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. _Please include a summary of passed `pytest` results._ ran `uv run pytest tests/unittests` `6275 passed, 14 skipped, 25 xfailed, 10 xpassed, 2532 warnings ` ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have manually tested my changes end-to-end. - [x] Any dependent changes have been merged and published in downstream modules. Merge #5710 Change-Id: I2330b2f3036cfd99c89b5d5c492f1c46c275974f
1 parent bb16958 commit 0478b02

4 files changed

Lines changed: 290 additions & 51 deletions

File tree

src/google/adk/a2a/utils/agent_card_builder.py

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,27 @@
3232
from ...agents.parallel_agent import ParallelAgent
3333
from ...agents.sequential_agent import SequentialAgent
3434
from ...tools.example_tool import ExampleTool
35+
from ...workflow._base_node import BaseNode
36+
from ...workflow._base_node import START
37+
from ...workflow._workflow import Workflow
3538
from ..experimental import a2a_experimental
3639

3740
logger = logging.getLogger('google_adk.' + __name__)
3841

3942

4043
@a2a_experimental
4144
class AgentCardBuilder:
42-
"""Builder class for creating agent cards from ADK agents.
45+
"""Builder class for creating agent cards from ADK agents or workflows.
4346
44-
This class provides functionality to convert ADK agents into A2A agent cards,
45-
including extracting skills, capabilities, and metadata from various agent
46-
types.
47+
This class provides functionality to convert an ADK BaseAgent (e.g. LlmAgent)
48+
or a Workflow into an A2A agent card, including extracting skills,
49+
capabilities, and metadata.
4750
"""
4851

4952
def __init__(
5053
self,
5154
*,
52-
agent: BaseAgent,
55+
agent: BaseAgent | Workflow,
5356
rpc_url: Optional[str] = None,
5457
capabilities: Optional[AgentCapabilities] = None,
5558
doc_url: Optional[str] = None,
@@ -59,6 +62,11 @@ def __init__(
5962
):
6063
if not agent:
6164
raise ValueError('Agent cannot be None or empty.')
65+
if not isinstance(agent, (BaseAgent, Workflow)):
66+
raise TypeError(
67+
'AgentCardBuilder requires a BaseAgent or Workflow, got '
68+
f'{type(agent).__name__}.'
69+
)
6270

6371
self._agent = agent
6472
self._rpc_url = rpc_url or 'http://localhost:80/a2a'
@@ -96,8 +104,17 @@ async def build(self) -> AgentCard:
96104

97105

98106
# Module-level helper functions
99-
async def _build_primary_skills(agent: BaseAgent) -> List[AgentSkill]:
100-
"""Build skills for any agent type."""
107+
def _iter_child_nodes(agent: BaseNode) -> List[BaseNode]:
108+
"""Returns the immediate child nodes of an agent or a workflow."""
109+
if isinstance(agent, BaseAgent):
110+
return list(agent.sub_agents)
111+
if isinstance(agent, Workflow) and agent.graph is not None:
112+
return [n for n in agent.graph.nodes if n.name != START.name]
113+
return []
114+
115+
116+
async def _build_primary_skills(agent: BaseNode) -> List[AgentSkill]:
117+
"""Build skills for any node type."""
101118
if isinstance(agent, LlmAgent):
102119
return await _build_llm_agent_skills(agent)
103120
else:
@@ -140,10 +157,10 @@ async def _build_llm_agent_skills(agent: LlmAgent) -> List[AgentSkill]:
140157
return skills
141158

142159

143-
async def _build_sub_agent_skills(agent: BaseAgent) -> List[AgentSkill]:
144-
"""Build skills for all sub-agents."""
160+
async def _build_sub_agent_skills(agent: BaseNode) -> List[AgentSkill]:
161+
"""Build skills for all child nodes (sub-agents or workflow nodes)."""
145162
sub_agent_skills = []
146-
for sub_agent in agent.sub_agents:
163+
for sub_agent in _iter_child_nodes(agent):
147164
try:
148165
sub_skills = await _build_primary_skills(sub_agent)
149166
for skill in sub_skills:
@@ -225,8 +242,8 @@ def _build_code_executor_skill(agent: LlmAgent) -> AgentSkill:
225242
)
226243

227244

228-
async def _build_non_llm_agent_skills(agent: BaseAgent) -> List[AgentSkill]:
229-
"""Build skills for non-LLM agents."""
245+
async def _build_non_llm_agent_skills(agent: BaseNode) -> List[AgentSkill]:
246+
"""Build skills for non-LLM agents and workflow nodes."""
230247
skills = []
231248

232249
# 1. Agent skill (main agent skill)
@@ -249,8 +266,8 @@ async def _build_non_llm_agent_skills(agent: BaseAgent) -> List[AgentSkill]:
249266
)
250267
)
251268

252-
# 2. Sub-agent orchestration skill (for agents with sub-agents)
253-
if agent.sub_agents:
269+
# 2. Orchestration skill (for agents/workflows with child nodes)
270+
if _iter_child_nodes(agent):
254271
orchestration_skill = _build_orchestration_skill(agent, agent_type)
255272
if orchestration_skill:
256273
skills.append(orchestration_skill)
@@ -259,11 +276,11 @@ async def _build_non_llm_agent_skills(agent: BaseAgent) -> List[AgentSkill]:
259276

260277

261278
def _build_orchestration_skill(
262-
agent: BaseAgent, agent_type: str
279+
agent: BaseNode, agent_type: str
263280
) -> Optional[AgentSkill]:
264-
"""Build orchestration skill for agents with sub-agents."""
281+
"""Build orchestration skill for agents/workflows with child nodes."""
265282
sub_agent_descriptions = []
266-
for sub_agent in agent.sub_agents:
283+
for sub_agent in _iter_child_nodes(agent):
267284
description = sub_agent.description or 'No description'
268285
sub_agent_descriptions.append(f'{sub_agent.name}: {description}')
269286

@@ -281,7 +298,7 @@ def _build_orchestration_skill(
281298
)
282299

283300

284-
def _get_agent_type(agent: BaseAgent) -> str:
301+
def _get_agent_type(agent: BaseNode) -> str:
285302
"""Get the agent type for tagging."""
286303
if isinstance(agent, LlmAgent):
287304
return 'llm'
@@ -291,21 +308,23 @@ def _get_agent_type(agent: BaseAgent) -> str:
291308
return 'parallel_workflow'
292309
elif isinstance(agent, LoopAgent):
293310
return 'loop_workflow'
311+
elif isinstance(agent, Workflow):
312+
return 'graph_workflow'
294313
else:
295314
return 'custom_agent'
296315

297316

298-
def _get_agent_skill_name(agent: BaseAgent) -> str:
317+
def _get_agent_skill_name(agent: BaseNode) -> str:
299318
"""Get the skill name based on agent type."""
300319
if isinstance(agent, LlmAgent):
301320
return 'model'
302-
elif isinstance(agent, (SequentialAgent, ParallelAgent, LoopAgent)):
321+
elif isinstance(agent, (SequentialAgent, ParallelAgent, LoopAgent, Workflow)):
303322
return 'workflow'
304323
else:
305324
return 'custom'
306325

307326

308-
def _build_agent_description(agent: BaseAgent) -> str:
327+
def _build_agent_description(agent: BaseNode) -> str:
309328
"""Build agent description from agent.description and workflow-specific descriptions."""
310329
description_parts = []
311330

@@ -382,9 +401,9 @@ def _replace_pronouns(text: str) -> str:
382401
)
383402

384403

385-
def _get_workflow_description(agent: BaseAgent) -> Optional[str]:
386-
"""Get workflow-specific description for non-LLM agents."""
387-
if not agent.sub_agents:
404+
def _get_workflow_description(agent: BaseNode) -> Optional[str]:
405+
"""Get workflow-specific description for non-LLM agents and workflows."""
406+
if not _iter_child_nodes(agent):
388407
return None
389408

390409
if isinstance(agent, SequentialAgent):
@@ -393,6 +412,8 @@ def _get_workflow_description(agent: BaseAgent) -> Optional[str]:
393412
return _build_parallel_description(agent)
394413
elif isinstance(agent, LoopAgent):
395414
return _build_loop_description(agent)
415+
elif isinstance(agent, Workflow):
416+
return _build_graph_workflow_description(agent)
396417

397418
return None
398419

@@ -448,13 +469,32 @@ def _build_loop_description(agent: LoopAgent) -> str:
448469
)
449470

450471

451-
def _get_default_description(agent: BaseAgent) -> str:
472+
def _build_graph_workflow_description(workflow: Workflow) -> str:
473+
"""Build description for a graph-based Workflow."""
474+
child_nodes = _iter_child_nodes(workflow)
475+
descriptions = []
476+
for node in child_nodes:
477+
node_description = (
478+
node.description.rstrip('.')
479+
if node.description
480+
else f'execute the {node.name} node'
481+
)
482+
descriptions.append(f'{node.name}: {node_description}')
483+
return (
484+
'This workflow orchestrates the following nodes: '
485+
+ '; '.join(descriptions)
486+
+ '.'
487+
)
488+
489+
490+
def _get_default_description(agent: BaseNode) -> str:
452491
"""Get default description based on agent type."""
453492
agent_type_descriptions = {
454493
LlmAgent: 'An LLM-based agent',
455494
SequentialAgent: 'A sequential workflow agent',
456495
ParallelAgent: 'A parallel workflow agent',
457496
LoopAgent: 'A loop workflow agent',
497+
Workflow: 'A graph-based workflow agent',
458498
}
459499

460500
for agent_type, description in agent_type_descriptions.items():
@@ -492,7 +532,7 @@ def _extract_inputs_from_examples(examples: Optional[list[dict]]) -> list[str]:
492532

493533

494534
async def _extract_examples_from_agent(
495-
agent: BaseAgent,
535+
agent: BaseNode,
496536
) -> Optional[List[Dict]]:
497537
"""Extract examples from example_tool if configured; otherwise, from agent instruction."""
498538
if not isinstance(agent, LlmAgent):
@@ -558,7 +598,7 @@ def _extract_examples_from_instruction(
558598
return examples if examples else None
559599

560600

561-
def _get_input_modes(agent: BaseAgent) -> Optional[List[str]]:
601+
def _get_input_modes(agent: BaseNode) -> Optional[List[str]]:
562602
"""Get input modes based on agent model."""
563603
if not isinstance(agent, LlmAgent):
564604
return None
@@ -568,7 +608,7 @@ def _get_input_modes(agent: BaseAgent) -> Optional[List[str]]:
568608
return None
569609

570610

571-
def _get_output_modes(agent: BaseAgent) -> Optional[List[str]]:
611+
def _get_output_modes(agent: BaseNode) -> Optional[List[str]]:
572612
"""Get output modes from Agent.generate_content_config.response_modalities."""
573613
if not isinstance(agent, LlmAgent):
574614
return None

src/google/adk/a2a/utils/agent_to_a2a.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from ...memory.in_memory_memory_service import InMemoryMemoryService
3535
from ...runners import Runner
3636
from ...sessions.in_memory_session_service import InMemorySessionService
37+
from ...workflow._workflow import Workflow
3738
from ..executor.a2a_agent_executor import A2aAgentExecutor
3839
from ..executor.config import A2aAgentExecutorConfig
3940
from ..experimental import a2a_experimental
@@ -77,7 +78,7 @@ def _load_agent_card(
7778

7879
@a2a_experimental
7980
def to_a2a(
80-
agent: BaseAgent,
81+
agent: BaseAgent | Workflow,
8182
*,
8283
host: str = "localhost",
8384
port: int = 8000,
@@ -89,10 +90,11 @@ def to_a2a(
8990
lifespan: Callable[[Starlette], AsyncIterator[None]] | None = None,
9091
agent_executor_factory: Callable[[Runner], A2aAgentExecutor] | None = None,
9192
) -> Starlette:
92-
"""Convert an ADK agent to a A2A Starlette application.
93+
"""Convert an ADK BaseAgent or Workflow to an A2A Starlette application.
9394
9495
Args:
95-
agent: The ADK agent to convert
96+
agent: The ADK BaseAgent (e.g. LlmAgent) or Workflow to
97+
convert.
9698
host: The host for the A2A RPC URL (default: "localhost")
9799
port: The port for the A2A RPC URL (default: 8000)
98100
protocol: The protocol for the A2A RPC URL (default: "http")
@@ -152,16 +154,20 @@ async def lifespan(app):
152154
adk_logger.setLevel(logging.INFO)
153155

154156
def create_runner() -> Runner:
155-
"""Create a runner for the agent."""
156-
return Runner(
157-
app_name=agent.name or "adk_agent",
158-
agent=agent,
157+
"""Create a runner for the agent or workflow."""
158+
runner_kwargs = {
159+
"app_name": agent.name or "adk_agent",
159160
# Use minimal services - in a real implementation these could be configured
160-
artifact_service=InMemoryArtifactService(),
161-
session_service=InMemorySessionService(),
162-
memory_service=InMemoryMemoryService(),
163-
credential_service=InMemoryCredentialService(),
164-
)
161+
"artifact_service": InMemoryArtifactService(),
162+
"session_service": InMemorySessionService(),
163+
"memory_service": InMemoryMemoryService(),
164+
"credential_service": InMemoryCredentialService(),
165+
}
166+
if isinstance(agent, Workflow):
167+
runner_kwargs["node"] = agent
168+
else:
169+
runner_kwargs["agent"] = agent
170+
return Runner(**runner_kwargs)
165171

166172
# Create A2A components
167173
if task_store is None:

0 commit comments

Comments
 (0)