From 542f79a3729d4ce00b469a82e71b260a43c76e84 Mon Sep 17 00:00:00 2001 From: Reddy Durgeshwant Date: Sun, 23 Nov 2025 21:32:31 +0530 Subject: [PATCH 1/3] Udpate Cli flags to support dev mode and implement watchodg for better dev experience --- src/google/adk/cli/cli.py | 183 ++++++++++++++++++++------ src/google/adk/cli/cli_tools_click.py | 14 ++ 2 files changed, 157 insertions(+), 40 deletions(-) diff --git a/src/google/adk/cli/cli.py b/src/google/adk/cli/cli.py index 5ae18aac0a..2f64489c4c 100644 --- a/src/google/adk/cli/cli.py +++ b/src/google/adk/cli/cli.py @@ -15,12 +15,16 @@ from __future__ import annotations from datetime import datetime +import threading from typing import Optional from typing import Union import click from google.genai import types from pydantic import BaseModel +from watchdog.events import FileSystemEvent +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer from ..agents.base_agent import BaseAgent from ..agents.llm_agent import LlmAgent @@ -44,6 +48,35 @@ class InputFile(BaseModel): queries: list[str] +class DevModeChangeHandler(FileSystemEventHandler): + """Handles file system events for development mode auto-reload.""" + + def __init__(self): + self.reload_needed = threading.Event() + self.last_modified_file = None + + def on_modified(self, event: FileSystemEvent): + if event.is_directory: + return + if event.src_path.endswith('.py') or event.src_path.endswith('.yaml'): + self.last_modified_file = event.src_path + self.reload_needed.set() + + def on_created(self, event: FileSystemEvent): + if event.is_directory: + return + if event.src_path.endswith('.py') or event.src_path.endswith('.yaml'): + self.last_modified_file = event.src_path + self.reload_needed.set() + + def check_and_reset(self) -> tuple[bool, Optional[str]]: + """Check if reload is needed and reset the flag.""" + if self.reload_needed.is_set(): + self.reload_needed.clear() + return True, self.last_modified_file + return False, None + + async def run_input_file( app_name: str, user_id: str, @@ -92,6 +125,9 @@ async def run_interactively( session: Session, session_service: BaseSessionService, credential_service: BaseCredentialService, + agent_loader: Optional[AgentLoader] = None, + agent_folder_name: Optional[str] = None, + change_handler: Optional[DevModeChangeHandler] = None, ) -> None: app = ( root_agent_or_app @@ -104,7 +140,44 @@ async def run_interactively( session_service=session_service, credential_service=credential_service, ) + + current_agent_or_app = root_agent_or_app + while True: + # Check if we need to reload the agent in dev mode + if change_handler and agent_loader and agent_folder_name: + needs_reload, changed_file = change_handler.check_and_reset() + if needs_reload: + try: + click.secho( + f'\nDetected change in {changed_file}', + fg='yellow', + ) + click.secho('Reloading agent...', fg='yellow') + # Remove from cache and reload + agent_loader.remove_agent_from_cache(agent_folder_name) + current_agent_or_app = agent_loader.load_agent(agent_folder_name) + + # Update the app and runner with the new agent + app = ( + current_agent_or_app + if isinstance(current_agent_or_app, App) + else App(name=session.app_name, root_agent=current_agent_or_app) + ) + await runner.close() + runner = Runner( + app=app, + artifact_service=artifact_service, + session_service=session_service, + credential_service=credential_service, + ) + click.secho('Agent reloaded successfully!\n', fg='green') + except Exception as e: + click.secho( + f'Error reloading agent: {e}\n', + fg='red', + ) + query = input('[user]: ') if not query or not query.strip(): continue @@ -134,6 +207,7 @@ async def run_cli( saved_session_file: Optional[str] = None, save_session: bool, session_id: Optional[str] = None, + dev_mode: bool = False, ) -> None: """Runs an interactive CLI for a certain agent. @@ -148,6 +222,7 @@ async def run_cli( contains a previously saved session, exclusive with input_file. save_session: bool, whether to save the session on exit. session_id: Optional[str], the session ID to save the session to on exit. + dev_mode: bool, whether to enable development mode with auto-reload. """ artifact_service = InMemoryArtifactService() @@ -155,9 +230,8 @@ async def run_cli( credential_service = InMemoryCredentialService() user_id = 'test_user' - agent_or_app = AgentLoader(agents_dir=agent_parent_dir).load_agent( - agent_folder_name - ) + agent_loader = AgentLoader(agents_dir=agent_parent_dir) + agent_or_app = agent_loader.load_agent(agent_folder_name) session_app_name = ( agent_or_app.name if isinstance(agent_or_app, App) else agent_folder_name ) @@ -166,45 +240,74 @@ async def run_cli( ) if not is_env_enabled('ADK_DISABLE_LOAD_DOTENV'): envs.load_dotenv_for_agent(agent_folder_name, agent_parent_dir) - if input_file: - session = await run_input_file( - app_name=session_app_name, - user_id=user_id, - agent_or_app=agent_or_app, - artifact_service=artifact_service, - session_service=session_service, - credential_service=credential_service, - input_path=input_file, - ) - elif saved_session_file: - with open(saved_session_file, 'r', encoding='utf-8') as f: - loaded_session = Session.model_validate_json(f.read()) - - if loaded_session: - for event in loaded_session.events: - await session_service.append_event(session, event) - content = event.content - if not content or not content.parts or not content.parts[0].text: - continue - click.echo(f'[{event.author}]: {content.parts[0].text}') - - await run_interactively( - agent_or_app, - artifact_service, - session, - session_service, - credential_service, - ) - else: - click.echo(f'Running agent {agent_or_app.name}, type exit to exit.') - await run_interactively( - agent_or_app, - artifact_service, - session, - session_service, - credential_service, + + # Set up watchdog observer for dev mode + observer: Optional[Observer] = None + change_handler: Optional[DevModeChangeHandler] = None + if dev_mode: + import os + + agent_path = os.path.join(agent_parent_dir, agent_folder_name) + change_handler = DevModeChangeHandler() + observer = Observer() + observer.schedule(change_handler, agent_path, recursive=True) + observer.start() + click.secho( + 'Auto-reload enabled - watching for file changes...', + fg='green', ) + try: + if input_file: + session = await run_input_file( + app_name=session_app_name, + user_id=user_id, + agent_or_app=agent_or_app, + artifact_service=artifact_service, + session_service=session_service, + credential_service=credential_service, + input_path=input_file, + ) + elif saved_session_file: + with open(saved_session_file, 'r', encoding='utf-8') as f: + loaded_session = Session.model_validate_json(f.read()) + + if loaded_session: + for event in loaded_session.events: + await session_service.append_event(session, event) + content = event.content + if not content or not content.parts or not content.parts[0].text: + continue + click.echo(f'[{event.author}]: {content.parts[0].text}') + + await run_interactively( + agent_or_app, + artifact_service, + session, + session_service, + credential_service, + agent_loader=agent_loader if dev_mode else None, + agent_folder_name=agent_folder_name if dev_mode else None, + change_handler=change_handler, + ) + else: + click.echo(f'Running agent {agent_or_app.name}, type exit to exit.') + await run_interactively( + agent_or_app, + artifact_service, + session, + session_service, + credential_service, + agent_loader=agent_loader if dev_mode else None, + agent_folder_name=agent_folder_name if dev_mode else None, + change_handler=change_handler, + ) + finally: + # Clean up observer + if observer: + observer.stop() + observer.join() + if save_session: session_id = session_id or input('Session ID to save: ') session_path = ( diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 529ee7319c..ec4f97f191 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -397,6 +397,16 @@ def validate_exclusive(ctx, param, value): ), callback=validate_exclusive, ) +@click.option( + "--dev", + is_flag=True, + show_default=True, + default=False, + help=( + "Optional. Enable development mode with automatic agent reloading when" + " source files change. Watches for changes in .py and .yaml files." + ), +) @click.argument( "agent", type=click.Path( @@ -409,6 +419,7 @@ def cli_run( session_id: Optional[str], replay: Optional[str], resume: Optional[str], + dev: bool, ): """Runs an interactive CLI for a certain agent. @@ -417,6 +428,8 @@ def cli_run( Example: adk run path/to/my_agent + + adk run --dev path/to/my_agent """ logs.log_to_tmp_folder() @@ -431,6 +444,7 @@ def cli_run( saved_session_file=resume, save_session=save_session, session_id=session_id, + dev_mode=dev, ) ) From 0496f30d17d38883297c9a2ae589833d814e2e7e Mon Sep 17 00:00:00 2001 From: Reddy Durgeshwant Date: Sun, 23 Nov 2025 22:26:07 +0530 Subject: [PATCH 2/3] Add tests for devMode cli --- .../cli/utils/test_cli_tools_click.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index be9015ca87..91a9e5ae5f 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -45,6 +45,23 @@ def __init__(self, name): super().__init__(name=name) self.sub_agents = [] + async def run_async(self, invocation_context): + """Mock run_async method for evaluation compatibility.""" + from google.adk.events.event import Event + from google.adk.events.event import EventActions + from google.genai import types as genai_types + + # Create a simple response event + content = genai_types.Content( + parts=[genai_types.Part(text="Mock response from dummy agent")] + ) + event = Event( + author=self.name, + content=content, + action=EventActions.MODEL_RESPONSE, + ) + yield event + root_agent = DummyAgent(name="dummy_agent") @@ -847,3 +864,127 @@ def _mock_to_cloud_run(*_a, **kwargs): " command." ) assert expected_msg in result.output + + +# Dev Mode Tests + +def test_dev_mode_change_handler_py_file(): + """Test DevModeChangeHandler detects .py file changes.""" + from google.adk.cli.cli import DevModeChangeHandler + from watchdog.events import FileSystemEvent + + handler = DevModeChangeHandler() + + # Test .py file change + event = FileSystemEvent('test.py') + event.is_directory = False + handler.on_modified(event) + + reload_needed, file_changed = handler.check_and_reset() + assert reload_needed is True + assert file_changed == 'test.py' + + # Test reset works + reload_needed2, file_changed2 = handler.check_and_reset() + assert reload_needed2 is False + assert file_changed2 is None + + +def test_dev_mode_change_handler_yaml_file(): + """Test DevModeChangeHandler detects .yaml file changes.""" + from google.adk.cli.cli import DevModeChangeHandler + from watchdog.events import FileSystemEvent + + handler = DevModeChangeHandler() + + # Test .yaml file change + event = FileSystemEvent('config.yaml') + event.is_directory = False + handler.on_modified(event) + + reload_needed, file_changed = handler.check_and_reset() + assert reload_needed is True + assert file_changed == 'config.yaml' + + +def test_dev_mode_change_handler_ignores_non_matching_files(): + """Test DevModeChangeHandler ignores files that don't match .py or .yaml.""" + from google.adk.cli.cli import DevModeChangeHandler + from watchdog.events import FileSystemEvent + + handler = DevModeChangeHandler() + + # Test .txt file (should be ignored) + event = FileSystemEvent('readme.txt') + event.is_directory = False + handler.on_modified(event) + + reload_needed, file_changed = handler.check_and_reset() + assert reload_needed is False + assert file_changed is None + + +def test_dev_mode_change_handler_ignores_directories(): + """Test DevModeChangeHandler ignores directory events.""" + from google.adk.cli.cli import DevModeChangeHandler + from watchdog.events import FileSystemEvent + + handler = DevModeChangeHandler() + + # Test directory event (should be ignored) + event = FileSystemEvent('subdir') + event.is_directory = True + handler.on_modified(event) + + reload_needed, file_changed = handler.check_and_reset() + assert reload_needed is False + assert file_changed is None + + +def test_dev_mode_change_handler_on_created(): + """Test DevModeChangeHandler detects file creation.""" + from google.adk.cli.cli import DevModeChangeHandler + from watchdog.events import FileSystemEvent + + handler = DevModeChangeHandler() + + # Test file creation + event = FileSystemEvent('new_agent.py') + event.is_directory = False + handler.on_created(event) + + reload_needed, file_changed = handler.check_and_reset() + assert reload_needed is True + assert file_changed == 'new_agent.py' + + +async def test_cli_run_with_dev_flag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`adk run --dev` should call run_cli with dev_mode=True.""" + rec = _Recorder() + monkeypatch.setattr(cli_tools_click, "run_cli", lambda **kwargs: rec(kwargs)) + monkeypatch.setattr( + cli_tools_click.asyncio, "run", lambda coro: coro + ) # pass-through + + # create dummy agent directory + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + (agent_dir / "__init__.py").touch() + (agent_dir / "agent.py").touch() + + runner = CliRunner() + result = runner.invoke(cli_tools_click.main, ["run", "--dev", str(agent_dir)]) + assert result.exit_code == 0 + assert rec.calls and rec.calls[0][0][0]["dev_mode"] is True + + +def test_cli_run_dev_flag_help_text(): + """Test that --dev flag appears in help text.""" + runner = CliRunner() + result = runner.invoke(cli_tools_click.main, ["run", "--help"]) + assert result.exit_code == 0 + assert "--dev" in result.output + assert "Enable development mode" in result.output + assert "automatic agent" in result.output From cd917256c34c8b2e7354cf070ef72784db6098cd Mon Sep 17 00:00:00 2001 From: Reddy Durgeshwant Date: Sun, 23 Nov 2025 22:35:34 +0530 Subject: [PATCH 3/3] refactor(cli): improve DevModeChangeHandler code quality - Extract duplicate logic into _handle_event helper method - Use tuple syntax for str.endswith() with multiple extensions - Move import os to module level following PEP 8 --- src/google/adk/cli/cli.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/google/adk/cli/cli.py b/src/google/adk/cli/cli.py index 2f64489c4c..373669efdf 100644 --- a/src/google/adk/cli/cli.py +++ b/src/google/adk/cli/cli.py @@ -15,6 +15,7 @@ from __future__ import annotations from datetime import datetime +import os import threading from typing import Optional from typing import Union @@ -55,19 +56,19 @@ def __init__(self): self.reload_needed = threading.Event() self.last_modified_file = None - def on_modified(self, event: FileSystemEvent): + def _handle_event(self, event: FileSystemEvent): + """Handle file system events for .py and .yaml files.""" if event.is_directory: return - if event.src_path.endswith('.py') or event.src_path.endswith('.yaml'): + if event.src_path.endswith(('.py', '.yaml')): self.last_modified_file = event.src_path self.reload_needed.set() + def on_modified(self, event: FileSystemEvent): + self._handle_event(event) + def on_created(self, event: FileSystemEvent): - if event.is_directory: - return - if event.src_path.endswith('.py') or event.src_path.endswith('.yaml'): - self.last_modified_file = event.src_path - self.reload_needed.set() + self._handle_event(event) def check_and_reset(self) -> tuple[bool, Optional[str]]: """Check if reload is needed and reset the flag.""" @@ -245,8 +246,6 @@ async def run_cli( observer: Optional[Observer] = None change_handler: Optional[DevModeChangeHandler] = None if dev_mode: - import os - agent_path = os.path.join(agent_parent_dir, agent_folder_name) change_handler = DevModeChangeHandler() observer = Observer()