Skip to content

Commit da109d6

Browse files
committed
feat(workflow): Support loading workflows from YAML configurations
- Implement `Workflow.from_config` to instantiate workflows from configurations. - Resolve nested node and sub-agent `.yaml` file references recursively during configuration loading. - Add parsing support for JoinNode, FunctionNode, and ToolNode types in config utilities.
1 parent d611f11 commit da109d6

4 files changed

Lines changed: 277 additions & 11 deletions

File tree

src/google/adk/agents/config_agent_utils.py

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,34 @@
1414

1515
from __future__ import annotations
1616

17+
import contextvars
1718
import importlib
1819
import inspect
1920
import os
2021
from typing import Any
22+
from typing import Dict
2123
from typing import List
2224

2325
from typing_extensions import deprecated
2426
import yaml
2527

2628
from ..features import experimental
2729
from ..features import FeatureName
30+
from ..workflow._base_node import BaseNode
2831
from .agent_config import AgentConfig
2932
from .base_agent import BaseAgent
3033
from .base_agent_config import BaseAgentConfig
3134
from .common_configs import AgentRefConfig
3235
from .common_configs import CodeConfig
3336

37+
_loaded_nodes_cache: contextvars.ContextVar[Dict[str, BaseNode]] = (
38+
contextvars.ContextVar("loaded_nodes_cache")
39+
)
40+
3441

3542
@deprecated("from_config is deprecated and will be removed in future versions.")
3643
@experimental(FeatureName.AGENT_CONFIG)
37-
def from_config(config_path: str) -> BaseAgent:
44+
def from_config(config_path: str) -> BaseNode:
3845
"""Build agent from a configfile path.
3946
4047
Args:
@@ -49,36 +56,137 @@ def from_config(config_path: str) -> BaseAgent:
4956
ValueError: If agent type is unsupported.
5057
"""
5158
abs_path = os.path.abspath(config_path)
59+
60+
try:
61+
cache = _loaded_nodes_cache.get()
62+
except LookupError:
63+
cache = None
64+
65+
if cache is None:
66+
cache_dict = {}
67+
token = _loaded_nodes_cache.set(cache_dict)
68+
try:
69+
return _from_config(abs_path)
70+
finally:
71+
_loaded_nodes_cache.reset(token)
72+
else:
73+
if abs_path in cache:
74+
return cache[abs_path]
75+
node = _from_config(abs_path)
76+
cache[abs_path] = node
77+
return node
78+
79+
80+
def _from_config(abs_path: str) -> BaseNode:
5281
config = _load_config_from_path(abs_path)
5382
agent_config = config.root
5483

5584
# pylint: disable=unidiomatic-typecheck Needs exact class matching.
5685
if type(agent_config) is BaseAgentConfig:
5786
# Resolve the concrete agent config for user-defined agent classes.
58-
agent_class = _resolve_agent_class(agent_config.agent_class)
87+
agent_class = _resolve_node_class(agent_config.agent_class)
88+
89+
from ..workflow._function_node import FunctionNode
90+
from ..workflow._join_node import JoinNode
91+
from ..workflow._tool_node import _ToolNode
92+
93+
if issubclass(agent_class, (JoinNode, FunctionNode, _ToolNode)):
94+
if issubclass(agent_class, JoinNode):
95+
return agent_class(name=agent_config.name)
96+
97+
elif issubclass(agent_class, FunctionNode):
98+
func_code = agent_config.model_extra.get("func_code")
99+
if not func_code:
100+
raise ValueError(
101+
f"FunctionNode {agent_config.name} configuration is missing"
102+
" 'func_code'"
103+
)
104+
105+
func = resolve_fully_qualified_name(func_code)
106+
107+
kwargs = {
108+
"func": func,
109+
"name": agent_config.name,
110+
}
111+
if "rerun_on_resume" in agent_config.model_extra:
112+
kwargs["rerun_on_resume"] = agent_config.model_extra[
113+
"rerun_on_resume"
114+
]
115+
if "parameter_binding" in agent_config.model_extra:
116+
kwargs["parameter_binding"] = agent_config.model_extra[
117+
"parameter_binding"
118+
]
119+
120+
return agent_class(**kwargs)
121+
122+
elif issubclass(agent_class, _ToolNode):
123+
tool_code = agent_config.model_extra.get("tool_code")
124+
if not tool_code:
125+
raise ValueError(
126+
f"ToolNode {agent_config.name} configuration is missing"
127+
" 'tool_code'"
128+
)
129+
130+
from ..tools.base_tool import BaseTool
131+
from ..tools.function_tool import FunctionTool
132+
from ..tools.tool_configs import ToolArgsConfig
133+
from ..tools.tool_configs import ToolConfig
134+
from .llm_agent import LlmAgent
135+
136+
args = agent_config.model_extra.get("args")
137+
tool_args = ToolArgsConfig.model_validate(args) if args else None
138+
tool_config = ToolConfig(name=tool_code, args=tool_args)
139+
140+
resolved = LlmAgent._resolve_tools([tool_config], abs_path)
141+
if not resolved:
142+
raise ValueError(
143+
f"Failed to resolve tool {tool_code} for ToolNode"
144+
f" {agent_config.name}"
145+
)
146+
147+
tool = resolved[0]
148+
if not isinstance(tool, BaseTool):
149+
if callable(tool):
150+
tool = FunctionTool(tool)
151+
else:
152+
raise ValueError(
153+
f"Resolved tool {tool_code} is neither a BaseTool nor callable"
154+
)
155+
156+
return agent_class(tool=tool, name=agent_config.name)
157+
59158
agent_config = agent_class.config_type.model_validate(
60159
agent_config.model_dump()
61160
)
62161
return agent_class.from_config(agent_config, abs_path)
63162
else:
64163
# For built-in agent classes, no need to re-validate.
65-
agent_class = _resolve_agent_class(agent_config.agent_class)
164+
agent_class = _resolve_node_class(agent_config.agent_class)
66165
return agent_class.from_config(agent_config, abs_path)
67166

68167

69-
def _resolve_agent_class(agent_class: str) -> type[BaseAgent]:
168+
def _resolve_node_class(agent_class: str) -> type[BaseNode]:
70169
"""Resolve the agent class from its fully qualified name."""
71170
agent_class_name = agent_class or "LlmAgent"
72171
if "." not in agent_class_name:
73-
agent_class_name = f"google.adk.agents.{agent_class_name}"
172+
if agent_class_name == "Workflow":
173+
agent_class_name = "google.adk.workflow.Workflow"
174+
elif agent_class_name == "JoinNode":
175+
agent_class_name = "google.adk.workflow.JoinNode"
176+
elif agent_class_name == "FunctionNode":
177+
agent_class_name = "google.adk.workflow.FunctionNode"
178+
elif agent_class_name == "ToolNode":
179+
agent_class_name = "google.adk.workflow._tool_node._ToolNode"
180+
else:
181+
agent_class_name = f"google.adk.agents.{agent_class_name}"
74182

75183
agent_class = resolve_fully_qualified_name(agent_class_name)
76-
if inspect.isclass(agent_class) and issubclass(agent_class, BaseAgent):
184+
if inspect.isclass(agent_class) and issubclass(agent_class, BaseNode):
77185
return agent_class
78186

79187
raise ValueError(
80188
f"Invalid agent class `{agent_class_name}`. It must be a subclass of"
81-
" BaseAgent."
189+
" BaseNode."
82190
)
83191

84192

@@ -118,7 +226,7 @@ def resolve_fully_qualified_name(name: str) -> Any:
118226
@experimental(FeatureName.AGENT_CONFIG)
119227
def resolve_agent_reference(
120228
ref_config: AgentRefConfig, referencing_agent_config_abs_path: str
121-
) -> BaseAgent:
229+
) -> BaseNode:
122230
"""Build an agent from a reference.
123231
124232
Args:
@@ -167,7 +275,7 @@ def _resolve_agent_code_reference(code: str) -> Any:
167275
if callable(obj):
168276
raise ValueError(f"Invalid agent reference to a callable: {code}")
169277

170-
if not isinstance(obj, BaseAgent):
278+
if not isinstance(obj, BaseNode):
171279
raise ValueError(f"Invalid agent reference to a non-agent instance: {code}")
172280

173281
return obj

src/google/adk/runners.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,8 +591,10 @@ async def _drive_root_node():
591591
async for event in agen:
592592
yield event
593593
finally:
594-
await self._cleanup_root_task(task, self.agent.name)
595-
await ic.plugin_manager.run_after_run_callback(invocation_context=ic)
594+
try:
595+
await self._cleanup_root_task(task, self.agent.name)
596+
finally:
597+
await ic.plugin_manager.run_after_run_callback(invocation_context=ic)
596598
if self.app and self.app.events_compaction_config:
597599
logger.debug('Running event compactor.')
598600
from google.adk.apps.compaction import _run_compaction_for_sliding_window

src/google/adk/workflow/_workflow.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
from dataclasses import field
2727
import logging
2828
from typing import Any
29+
from typing import ClassVar
2930
from typing import TYPE_CHECKING
3031

3132
from pydantic import Field
3233

34+
from ..agents.base_agent_config import BaseAgentConfig
3335
from ._base_node import BaseNode
3436
from ._base_node import START
3537
from ._dynamic_node_scheduler import DynamicNodeScheduler
@@ -154,6 +156,51 @@ class Workflow(BaseNode):
154156
- FINALIZE: collect terminal outputs
155157
"""
156158

159+
config_type: ClassVar[type[BaseAgentConfig]] = BaseAgentConfig
160+
161+
@classmethod
162+
def from_config(
163+
cls,
164+
config: Any,
165+
config_abs_path: str,
166+
) -> Workflow:
167+
"""Creates a Workflow from a config."""
168+
import os
169+
170+
from ..agents.config_agent_utils import from_config as load_agent_config
171+
172+
kwargs = {
173+
'name': config.name,
174+
'description': config.description,
175+
}
176+
177+
if config.model_extra:
178+
config_dir = os.path.dirname(config_abs_path)
179+
180+
def resolve_node_refs(val: Any) -> Any:
181+
if isinstance(val, str):
182+
if val == 'START':
183+
return 'START'
184+
if val.endswith('.yaml') or val.endswith('.yml'):
185+
return load_agent_config(os.path.join(config_dir, val))
186+
return val
187+
if isinstance(val, list):
188+
# Convert inner edge chains/tuples to tuples as required by EdgeItem
189+
return tuple(resolve_node_refs(x) for x in val)
190+
if isinstance(val, dict):
191+
return {k: resolve_node_refs(v) for k, v in val.items()}
192+
if isinstance(val, tuple):
193+
return tuple(resolve_node_refs(x) for x in val)
194+
return val
195+
196+
for key, value in config.model_extra.items():
197+
if key == 'edges' and isinstance(value, list):
198+
kwargs['edges'] = [resolve_node_refs(item) for item in value]
199+
elif key in cls.model_fields and key not in kwargs:
200+
kwargs[key] = value
201+
202+
return cls(**kwargs)
203+
157204
rerun_on_resume: bool = Field(default=True)
158205

159206
edges: list[EdgeItem] = Field(

tests/unittests/agents/test_agent_config.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,112 @@ def fake_from_config(path: str):
465465
)
466466
assert result == "sentinel"
467467
assert recorded["path"] == expected_path
468+
469+
470+
def test_workflow_config_resolution(tmp_path: Path):
471+
"""Ensure Workflow with relative edges/sub-agent configs resolves and loads correctly."""
472+
sub_agent_dir = tmp_path / "sub_agents"
473+
sub_agent_dir.mkdir()
474+
475+
coder_yaml = """\
476+
name: coder_agent
477+
model: gemini-2.5-flash
478+
description: writes code
479+
instruction: write code
480+
"""
481+
reviewer_yaml = """\
482+
name: reviewer_agent
483+
model: gemini-2.5-flash
484+
description: reviews code
485+
instruction: review code
486+
"""
487+
(sub_agent_dir / "coder_agent.yaml").write_text(dedent(coder_yaml))
488+
(sub_agent_dir / "reviewer_agent.yaml").write_text(dedent(reviewer_yaml))
489+
490+
workflow_yaml = """\
491+
agent_class: Workflow
492+
name: test_workflow
493+
description: sequential workflow
494+
edges:
495+
- - START
496+
- sub_agents/coder_agent.yaml
497+
- sub_agents/reviewer_agent.yaml
498+
"""
499+
config_file = tmp_path / "workflow.yaml"
500+
config_file.write_text(dedent(workflow_yaml))
501+
502+
from google.adk.workflow._workflow import Workflow
503+
504+
agent = config_agent_utils.from_config(str(config_file))
505+
506+
assert isinstance(agent, Workflow)
507+
assert agent.name == "test_workflow"
508+
assert len(agent.edges) == 1
509+
chain = agent.edges[0]
510+
assert isinstance(chain, tuple)
511+
assert len(chain) == 3
512+
assert chain[0] == "START"
513+
assert chain[1].name == "coder_agent"
514+
assert chain[2].name == "reviewer_agent"
515+
516+
517+
def fake_fn(input_str: str) -> str:
518+
return input_str.upper()
519+
520+
521+
def fake_tool_func(query: str) -> str:
522+
return f"result for {query}"
523+
524+
525+
def test_load_join_node_yaml(tmp_path: Path):
526+
yaml_content = """\
527+
agent_class: JoinNode
528+
name: join_barrier
529+
"""
530+
config_file = tmp_path / "join_barrier.yaml"
531+
config_file.write_text(dedent(yaml_content))
532+
533+
from google.adk.workflow._join_node import JoinNode
534+
535+
node = config_agent_utils.from_config(str(config_file))
536+
537+
assert isinstance(node, JoinNode)
538+
assert node.name == "join_barrier"
539+
540+
541+
def test_load_function_node_yaml(tmp_path: Path):
542+
yaml_content = """\
543+
agent_class: FunctionNode
544+
name: upper_fn_node
545+
func_code: tests.unittests.agents.test_agent_config.fake_fn
546+
"""
547+
config_file = tmp_path / "upper_fn_node.yaml"
548+
config_file.write_text(dedent(yaml_content))
549+
550+
from google.adk.workflow._function_node import FunctionNode
551+
552+
node = config_agent_utils.from_config(str(config_file))
553+
554+
assert isinstance(node, FunctionNode)
555+
assert node.name == "upper_fn_node"
556+
assert node._func == fake_fn
557+
558+
559+
def test_load_tool_node_yaml(tmp_path: Path):
560+
yaml_content = """\
561+
agent_class: ToolNode
562+
name: search_tool_node
563+
tool_code: tests.unittests.agents.test_agent_config.fake_tool_func
564+
"""
565+
config_file = tmp_path / "search_tool_node.yaml"
566+
config_file.write_text(dedent(yaml_content))
567+
568+
from google.adk.tools.function_tool import FunctionTool
569+
from google.adk.workflow._tool_node import _ToolNode
570+
571+
node = config_agent_utils.from_config(str(config_file))
572+
573+
assert isinstance(node, _ToolNode)
574+
assert node.name == "search_tool_node"
575+
assert isinstance(node.tool, FunctionTool)
576+
assert node.tool.func == fake_tool_func

0 commit comments

Comments
 (0)