Skip to content

Commit dc4975d

Browse files
GWealecopybara-github
authored andcommitted
fix: relax runner app-name enforcement
- let _enforce_app_name_alignment warn instead of raising while caching the hint that now augments the existing “Session not found …” error - tighten _infer_agent_origin so it ignores hidden folders (like .venv) - make AgentTool reuse the parent runner’s app_name, stopping internal runners from conflicting in multi-agent setups PiperOrigin-RevId: 822205860
1 parent aeaec85 commit dc4975d

File tree

5 files changed

+317
-40
lines changed

5 files changed

+317
-40
lines changed

src/google/adk/cli/utils/agent_loader.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import importlib
18+
import importlib.util
1819
import logging
1920
import os
2021
from pathlib import Path
@@ -205,22 +206,30 @@ def _perform_load(self, agent_name: str) -> Union[BaseAgent, App]:
205206
envs.load_dotenv_for_agent(actual_agent_name, str(agents_dir))
206207

207208
if root_agent := self._load_from_module_or_package(actual_agent_name):
208-
self._ensure_app_name_matches(
209-
maybe_app=root_agent,
209+
self._record_origin_metadata(
210+
loaded=root_agent,
210211
expected_app_name=actual_agent_name,
212+
module_name=actual_agent_name,
211213
agents_dir=agents_dir,
212214
)
213215
return root_agent
214216

215217
if root_agent := self._load_from_submodule(actual_agent_name):
216-
self._ensure_app_name_matches(
217-
maybe_app=root_agent,
218+
self._record_origin_metadata(
219+
loaded=root_agent,
218220
expected_app_name=actual_agent_name,
221+
module_name=f"{actual_agent_name}.agent",
219222
agents_dir=agents_dir,
220223
)
221224
return root_agent
222225

223226
if root_agent := self._load_from_yaml_config(actual_agent_name, agents_dir):
227+
self._record_origin_metadata(
228+
loaded=root_agent,
229+
expected_app_name=actual_agent_name,
230+
module_name=None,
231+
agents_dir=agents_dir,
232+
)
224233
return root_agent
225234

226235
# If no root_agent was found by any pattern
@@ -250,32 +259,42 @@ def _perform_load(self, agent_name: str) -> Union[BaseAgent, App]:
250259
f" root_agent is exposed.{hint}"
251260
)
252261

253-
def _ensure_app_name_matches(
262+
def _record_origin_metadata(
254263
self,
255264
*,
256-
maybe_app: Union[BaseAgent, App],
265+
loaded: Union[BaseAgent, App],
257266
expected_app_name: str,
267+
module_name: Optional[str],
258268
agents_dir: str,
259269
) -> None:
260-
"""Raises a detailed error when App.name does not match its directory."""
270+
"""Annotates loaded agent/App with its origin for later diagnostics."""
261271

262-
if not isinstance(maybe_app, App):
263-
return
264-
265-
# Built-in apps live under double-underscore directories.
272+
# Do not attach metadata for built-in agents (double underscore names).
266273
if expected_app_name.startswith("__"):
267274
return
268275

269-
if maybe_app.name == expected_app_name:
270-
return
276+
origin_path: Optional[Path] = None
277+
if module_name:
278+
spec = importlib.util.find_spec(module_name)
279+
if spec and spec.origin:
280+
module_origin = Path(spec.origin).resolve()
281+
origin_path = (
282+
module_origin.parent if module_origin.is_file() else module_origin
283+
)
271284

272-
raise ValueError(
273-
"App name mismatch detected. The App defined at "
274-
f"'{agents_dir}/{expected_app_name}' declares name "
275-
f"'{maybe_app.name}', but ADK expects it to match the directory "
276-
f"name '{expected_app_name}'. Rename the App or the folder so they "
277-
"match, then reload."
278-
)
285+
if origin_path is None:
286+
candidate = Path(agents_dir, expected_app_name)
287+
origin_path = candidate if candidate.exists() else Path(agents_dir)
288+
289+
def _attach_metadata(target: Union[BaseAgent, App]) -> None:
290+
setattr(target, "_adk_origin_app_name", expected_app_name)
291+
setattr(target, "_adk_origin_path", origin_path)
292+
293+
if isinstance(loaded, App):
294+
_attach_metadata(loaded)
295+
_attach_metadata(loaded.root_agent)
296+
else:
297+
_attach_metadata(loaded)
279298

280299
@override
281300
def load_agent(self, agent_name: str) -> Union[BaseAgent, App]:

src/google/adk/runners.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ def __init__(
155155
self._agent_origin_app_name,
156156
self._agent_origin_dir,
157157
) = self._infer_agent_origin(self.agent)
158+
self._app_name_alignment_hint: Optional[str] = None
158159
self._enforce_app_name_alignment()
159160

160161
def _validate_runner_params(
@@ -230,35 +231,47 @@ def _infer_agent_origin(
230231
module_path = Path(module_file).resolve()
231232
project_root = Path.cwd()
232233
try:
233-
module_path.relative_to(project_root)
234+
relative_path = module_path.relative_to(project_root)
234235
except ValueError:
235236
return None, module_path.parent
236-
237-
current = module_path.parent
238-
while current != project_root and current.parent != current:
239-
parent = current.parent
240-
if parent.name == 'agents':
241-
return current.name, current
242-
current = parent
243-
244-
return None, module_path.parent
237+
origin_dir = module_path.parent
238+
if 'agents' not in relative_path.parts:
239+
return None, origin_dir
240+
origin_name = origin_dir.name
241+
if origin_name.startswith('.'):
242+
return None, origin_dir
243+
return origin_name, origin_dir
245244

246245
def _enforce_app_name_alignment(self) -> None:
247246
origin_name = self._agent_origin_app_name
248247
origin_dir = self._agent_origin_dir
249248
if not origin_name or origin_name.startswith('__'):
249+
self._app_name_alignment_hint = None
250250
return
251251
if origin_name == self.app_name:
252+
self._app_name_alignment_hint = None
252253
return
253254
origin_location = str(origin_dir) if origin_dir else origin_name
254-
message = (
255-
'App name mismatch detected. The runner is configured with '
256-
f'app name "{self.app_name}", but the root agent was loaded from '
257-
f'"{origin_location}", which implies app name "{origin_name}". '
258-
'Rename the App or its directory so the names match before running '
259-
'the agent.'
255+
mismatch_details = (
256+
'The runner is configured with app name '
257+
f'"{self.app_name}", but the root agent was loaded from '
258+
f'"{origin_location}", which implies app name "{origin_name}".'
259+
)
260+
resolution = (
261+
'Ensure the runner app_name matches that directory or pass app_name '
262+
'explicitly when constructing the runner.'
263+
)
264+
self._app_name_alignment_hint = f'{mismatch_details} {resolution}'
265+
logger.warning('App name mismatch detected. %s', mismatch_details)
266+
267+
def _format_session_not_found_message(self, session_id: str) -> str:
268+
message = f'Session not found: {session_id}'
269+
if not self._app_name_alignment_hint:
270+
return message
271+
return (
272+
f'{message}. {self._app_name_alignment_hint} '
273+
'The mismatch prevents the runner from locating the session.'
260274
)
261-
raise ValueError(message)
262275

263276
def run(
264277
self,
@@ -362,7 +375,8 @@ async def _run_with_trace(
362375
app_name=self.app_name, user_id=user_id, session_id=session_id
363376
)
364377
if not session:
365-
raise ValueError(f'Session not found: {session_id}')
378+
message = self._format_session_not_found_message(session_id)
379+
raise ValueError(message)
366380
if not invocation_id and not new_message:
367381
raise ValueError('Both invocation_id and new_message are None.')
368382

src/google/adk/tools/agent_tool.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,13 @@ async def run_async(
125125
role='user',
126126
parts=[types.Part.from_text(text=args['request'])],
127127
)
128+
invocation_context = tool_context._invocation_context
129+
parent_app_name = (
130+
invocation_context.app_name if invocation_context else None
131+
)
132+
child_app_name = parent_app_name or self.agent.name
128133
runner = Runner(
129-
app_name=self.agent.name,
134+
app_name=child_app_name,
130135
agent=self.agent,
131136
artifact_service=ForwardingArtifactService(tool_context),
132137
session_service=InMemorySessionService(),
@@ -141,7 +146,7 @@ async def run_async(
141146
if not k.startswith('_adk') # Filter out adk internal states
142147
}
143148
session = await runner.session_service.create_session(
144-
app_name=self.agent.name,
149+
app_name=child_app_name,
145150
user_id=tool_context._invocation_context.user_id,
146151
state=state_dict,
147152
)

tests/unittests/test_runners.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from pathlib import Path
16+
import textwrap
1517
from typing import Optional
1618

1719
from google.adk.agents.base_agent import BaseAgent
@@ -21,6 +23,7 @@
2123
from google.adk.apps.app import App
2224
from google.adk.apps.app import ResumabilityConfig
2325
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
26+
from google.adk.cli.utils.agent_loader import AgentLoader
2427
from google.adk.events.event import Event
2528
from google.adk.plugins.base_plugin import BasePlugin
2629
from google.adk.runners import Runner
@@ -158,6 +161,120 @@ def setup_method(self):
158161
artifact_service=self.artifact_service,
159162
)
160163

164+
165+
@pytest.mark.asyncio
166+
async def test_session_not_found_message_includes_alignment_hint():
167+
168+
class RunnerWithMismatch(Runner):
169+
170+
def _infer_agent_origin(
171+
self, agent: BaseAgent
172+
) -> tuple[Optional[str], Optional[Path]]:
173+
del agent
174+
return "expected_app", Path("/workspace/agents/expected_app")
175+
176+
session_service = InMemorySessionService()
177+
runner = RunnerWithMismatch(
178+
app_name="configured_app",
179+
agent=MockLlmAgent("root_agent"),
180+
session_service=session_service,
181+
artifact_service=InMemoryArtifactService(),
182+
)
183+
184+
agen = runner.run_async(
185+
user_id="user",
186+
session_id="missing",
187+
new_message=types.Content(role="user", parts=[]),
188+
)
189+
190+
with pytest.raises(ValueError) as excinfo:
191+
await agen.__anext__()
192+
193+
await agen.aclose()
194+
195+
message = str(excinfo.value)
196+
assert "Session not found" in message
197+
assert "configured_app" in message
198+
assert "expected_app" in message
199+
assert "Ensure the runner app_name matches" in message
200+
201+
202+
@pytest.mark.asyncio
203+
async def test_runner_allows_nested_agent_directories(tmp_path, monkeypatch):
204+
project_root = tmp_path / "workspace"
205+
agent_dir = project_root / "agents" / "examples" / "001_hello_world"
206+
agent_dir.mkdir(parents=True)
207+
# Make package structure importable.
208+
for pkg_dir in [
209+
project_root / "agents",
210+
project_root / "agents" / "examples",
211+
agent_dir,
212+
]:
213+
(pkg_dir / "__init__.py").write_text("", encoding="utf-8")
214+
# Extra directories that previously confused origin inference, e.g. virtualenv.
215+
(project_root / "agents" / ".venv").mkdir()
216+
217+
agent_source = textwrap.dedent("""\
218+
from google.adk.events.event import Event
219+
from google.adk.agents.base_agent import BaseAgent
220+
from google.genai import types
221+
222+
223+
class SimpleAgent(BaseAgent):
224+
225+
def __init__(self):
226+
super().__init__(name='simplest_agent', sub_agents=[])
227+
228+
async def _run_async_impl(self, invocation_context):
229+
yield Event(
230+
invocation_id=invocation_context.invocation_id,
231+
author=self.name,
232+
content=types.Content(
233+
role='model',
234+
parts=[types.Part(text='hello from nested')],
235+
),
236+
)
237+
238+
239+
root_agent = SimpleAgent()
240+
""")
241+
(agent_dir / "agent.py").write_text(agent_source, encoding="utf-8")
242+
243+
monkeypatch.chdir(project_root)
244+
loader = AgentLoader(agents_dir="agents/examples")
245+
loaded_agent = loader.load_agent("001_hello_world")
246+
247+
assert isinstance(loaded_agent, BaseAgent)
248+
session_service = InMemorySessionService()
249+
artifact_service = InMemoryArtifactService()
250+
runner = Runner(
251+
app_name="001_hello_world",
252+
agent=loaded_agent,
253+
session_service=session_service,
254+
artifact_service=artifact_service,
255+
)
256+
assert runner._app_name_alignment_hint is None
257+
258+
session = await session_service.create_session(
259+
app_name="001_hello_world",
260+
user_id="user",
261+
)
262+
agen = runner.run_async(
263+
user_id=session.user_id,
264+
session_id=session.id,
265+
new_message=types.Content(
266+
role="user",
267+
parts=[types.Part(text="hi")],
268+
),
269+
)
270+
event = await agen.__anext__()
271+
await agen.aclose()
272+
273+
assert event.author == "simplest_agent"
274+
assert event.content
275+
assert event.content.parts
276+
assert event.content.parts[0].text == "hello from nested"
277+
161278
def test_find_agent_to_run_with_function_response_scenario(self):
162279
"""Test finding agent when last event is function response."""
163280
# Create a function call from sub_agent1

0 commit comments

Comments
 (0)