Skip to content

Commit 06bf446

Browse files
itsParvparvkamal1
authored andcommitted
feat(agents): add native RouterAgent for LLM-based conditional routing
Introduces RouterAgent, a new workflow agent that uses a classifier sub-agent to determine which specialist sub-agent should handle a given request. This eliminates manual if-else routing boilerplate. Key features: - Two-phase execution: classification then delegation - JSON-based route parsing with Markdown code-fence cleanup - Configurable routing_key and route mappings - default_route fallback for unmatched or malformed classifier output - Full resumability support via RouterAgentState - YAML config support via RouterAgentConfig Closes #1947
1 parent f973673 commit 06bf446

File tree

3 files changed

+294
-0
lines changed

3 files changed

+294
-0
lines changed

src/google/adk/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .loop_agent import LoopAgent
2323
from .mcp_instruction_provider import McpInstructionProvider
2424
from .parallel_agent import ParallelAgent
25+
from .router_agent import RouterAgent
2526
from .run_config import RunConfig
2627
from .sequential_agent import SequentialAgent
2728

@@ -33,6 +34,7 @@
3334
'LoopAgent',
3435
'McpInstructionProvider',
3536
'ParallelAgent',
37+
'RouterAgent',
3638
'SequentialAgent',
3739
'InvocationContext',
3840
'LiveRequest',
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Router agent implementation."""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import logging
21+
from typing import Any, AsyncGenerator, ClassVar, Dict, Type
22+
23+
from pydantic import Field
24+
from typing_extensions import override
25+
26+
from ..events.event import Event
27+
from ..features import experimental
28+
from ..features import FeatureName
29+
from ..utils.context_utils import Aclosing
30+
from .base_agent import BaseAgent
31+
from .base_agent import BaseAgentState
32+
from .base_agent_config import BaseAgentConfig
33+
from .invocation_context import InvocationContext
34+
from .router_agent_config import RouterAgentConfig
35+
36+
logger = logging.getLogger('google_adk.' + __name__)
37+
38+
39+
@experimental(FeatureName.AGENT_STATE)
40+
class RouterAgentState(BaseAgentState):
41+
"""State for RouterAgent."""
42+
43+
current_route: str = ''
44+
"""The targeted sub-agent name after classification."""
45+
46+
classifier_finished: bool = False
47+
"""Whether the classifier has completed executing."""
48+
49+
50+
class RouterAgent(BaseAgent):
51+
"""An agent that routes to a specific sub-agent based on a classifier's JSON
52+
output.
53+
54+
The RouterAgent operates in two phases:
55+
1. Classification: Runs a designated classifier sub-agent which outputs
56+
JSON containing a routing key.
57+
2. Delegation: Parses the classifier output and delegates execution to
58+
the matched target sub-agent.
59+
60+
Example usage:
61+
```python
62+
router = RouterAgent(
63+
name="my_router",
64+
classifier_agent_name="classifier",
65+
routing_key="intent",
66+
routes={
67+
"diet": "diet_specialist",
68+
"habitat": "habitat_specialist",
69+
},
70+
default_route="diet_specialist",
71+
sub_agents=[classifier, diet_specialist, habitat_specialist],
72+
)
73+
```
74+
"""
75+
76+
config_type: ClassVar[Type[BaseAgentConfig]] = RouterAgentConfig
77+
"""The config type for this agent."""
78+
79+
classifier_agent_name: str = Field(default='')
80+
"""The name of the sub-agent that acts as the routing classifier."""
81+
82+
routing_key: str = Field(default='route')
83+
"""The JSON key to extract the chosen route from the classifier's output."""
84+
85+
routes: Dict[str, str] = Field(default_factory=dict)
86+
"""A dictionary mapping the extracted route string to the target
87+
sub-agent name."""
88+
89+
default_route: str = Field(default='')
90+
"""The fallback sub-agent name if the route key doesn't match any mapping."""
91+
92+
@override
93+
async def _run_async_impl(
94+
self, ctx: InvocationContext
95+
) -> AsyncGenerator[Event, None]:
96+
if not self.sub_agents:
97+
return
98+
99+
# Initialize or resume the execution state from the agent state.
100+
agent_state = (
101+
self._load_agent_state(ctx, RouterAgentState) or RouterAgentState()
102+
)
103+
104+
# Phase 1: Run the classifier agent to determine the route.
105+
if not agent_state.classifier_finished:
106+
classifier_agent = self.find_sub_agent(self.classifier_agent_name)
107+
if not classifier_agent:
108+
raise ValueError(
109+
f"Classifier agent '{self.classifier_agent_name}' not found in"
110+
' sub_agents.'
111+
)
112+
113+
classifier_output = ''
114+
async with Aclosing(classifier_agent.run_async(ctx)) as agen:
115+
async for event in agen:
116+
# Capture the text output but intentionally do NOT yield it
117+
# so the internal routing logic remains hidden from the user.
118+
if event.content and getattr(event.content, 'parts', None):
119+
for part in event.content.parts:
120+
if getattr(part, 'text', None):
121+
classifier_output += part.text
122+
123+
# Parse the classifier output to find the route.
124+
target_agent_name = self._parse_classifier_output(classifier_output)
125+
126+
agent_state.current_route = target_agent_name
127+
agent_state.classifier_finished = True
128+
129+
if ctx.is_resumable:
130+
ctx.set_agent_state(self.name, agent_state=agent_state)
131+
yield self._create_agent_state_event(ctx)
132+
133+
# Phase 2: Run the selected route agent.
134+
target_agent = self.find_sub_agent(agent_state.current_route)
135+
if not target_agent:
136+
raise ValueError(
137+
f"Target route agent '{agent_state.current_route}' was not found"
138+
' in sub_agents.'
139+
)
140+
141+
async with Aclosing(target_agent.run_async(ctx)) as agen:
142+
async for event in agen:
143+
yield event
144+
145+
if ctx.is_resumable:
146+
ctx.set_agent_state(self.name, end_of_agent=True)
147+
yield self._create_agent_state_event(ctx)
148+
149+
def _parse_classifier_output(self, raw_output: str) -> str:
150+
"""Parses the classifier's raw text output into a target agent name.
151+
152+
Handles common LLM quirks like wrapping JSON in Markdown code fences.
153+
154+
Args:
155+
raw_output: The raw text output from the classifier agent.
156+
157+
Returns:
158+
The name of the target sub-agent to route to.
159+
160+
Raises:
161+
ValueError: If no route could be determined and no default_route
162+
is configured.
163+
"""
164+
try:
165+
# Clean Markdown JSON blocks if present.
166+
cleaned_output = raw_output.strip()
167+
if cleaned_output.startswith('```json'):
168+
cleaned_output = cleaned_output[7:-3]
169+
elif cleaned_output.startswith('```'):
170+
cleaned_output = cleaned_output[3:-3]
171+
172+
parsed_json = json.loads(cleaned_output.strip())
173+
route_value = parsed_json.get(self.routing_key, '')
174+
175+
target_agent_name = self.routes.get(route_value, self.default_route)
176+
177+
if not target_agent_name:
178+
raise ValueError(
179+
f"No route mapped for value '{route_value}' and no"
180+
' default_route provided.'
181+
)
182+
183+
return target_agent_name
184+
185+
except json.JSONDecodeError as e:
186+
logger.error(
187+
'Router classifier did not output valid JSON. Raw output: %s',
188+
raw_output,
189+
)
190+
if self.default_route:
191+
return self.default_route
192+
raise ValueError(
193+
'Classifier returned malformed JSON and no default_route was'
194+
f' configured. Raw output: {raw_output}'
195+
) from e
196+
197+
@override
198+
async def _run_live_impl(
199+
self, ctx: InvocationContext
200+
) -> AsyncGenerator[Event, None]:
201+
if False: # pylint: disable=using-constant-test
202+
yield # Make this a generator function.
203+
raise NotImplementedError(
204+
'Live mode routing is not currently supported for RouterAgent.'
205+
)
206+
207+
@override
208+
@classmethod
209+
@experimental(FeatureName.AGENT_CONFIG)
210+
def _parse_config(
211+
cls: type[RouterAgent],
212+
config: RouterAgentConfig,
213+
config_abs_path: str,
214+
kwargs: Dict[str, Any],
215+
) -> Dict[str, Any]:
216+
kwargs['classifier_agent_name'] = config.classifier_agent_name
217+
kwargs['routing_key'] = config.routing_key
218+
kwargs['routes'] = config.routes
219+
kwargs['default_route'] = config.default_route
220+
return kwargs
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Config definition for RouterAgent."""
16+
17+
from __future__ import annotations
18+
19+
from typing import Dict
20+
21+
from pydantic import ConfigDict
22+
from pydantic import Field
23+
24+
from ..agents.base_agent_config import BaseAgentConfig
25+
from ..features import experimental
26+
from ..features import FeatureName
27+
28+
29+
@experimental(FeatureName.AGENT_CONFIG)
30+
class RouterAgentConfig(BaseAgentConfig):
31+
"""The config for the YAML schema of a RouterAgent."""
32+
33+
model_config = ConfigDict(
34+
extra="forbid",
35+
)
36+
37+
agent_class: str = Field(
38+
default="RouterAgent",
39+
description=(
40+
"The value is used to uniquely identify the RouterAgent class."
41+
),
42+
)
43+
44+
classifier_agent_name: str = Field(
45+
description=(
46+
"The name of the sub-agent that acts as the routing classifier."
47+
),
48+
)
49+
50+
routing_key: str = Field(
51+
default="route",
52+
description=(
53+
"The JSON key to extract the chosen route from the classifier's"
54+
" output."
55+
),
56+
)
57+
58+
routes: Dict[str, str] = Field(
59+
default_factory=dict,
60+
description=(
61+
"A dictionary mapping the extracted route string to the target"
62+
" sub-agent name."
63+
),
64+
)
65+
66+
default_route: str = Field(
67+
default="",
68+
description=(
69+
"The fallback sub-agent name if the route key doesn't match any"
70+
" mapping."
71+
),
72+
)

0 commit comments

Comments
 (0)