diff --git a/src/google/adk/cli/cli.py b/src/google/adk/cli/cli.py index 941f1c28c7..d4673459f7 100644 --- a/src/google/adk/cli/cli.py +++ b/src/google/adk/cli/cli.py @@ -15,13 +15,18 @@ from __future__ import annotations from datetime import datetime +import os from pathlib import Path +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.llm_agent import LlmAgent from ..apps.app import App @@ -45,6 +50,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 _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', '.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): + self._handle_event(event) + + 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, @@ -93,6 +127,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 @@ -105,7 +142,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 @@ -135,6 +209,7 @@ async def run_cli( saved_session_file: Optional[str] = None, save_session: bool, session_id: Optional[str] = None, + dev_mode: bool = False, session_service_uri: Optional[str] = None, artifact_service_uri: Optional[str] = None, ) -> None: @@ -151,6 +226,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. session_service_uri: Optional[str], custom session service URI. artifact_service_uri: Optional[str], custom artifact service URI. """ @@ -183,6 +259,13 @@ async def run_cli( ) credential_service = InMemoryCredentialService() + agents_dir = str(agent_parent_path) + agent_loader = AgentLoader(agents_dir=agents_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 + ) if not is_env_enabled('ADK_DISABLE_LOAD_DOTENV'): envs.load_dotenv_for_agent(agent_folder_name, agents_dir) @@ -197,53 +280,79 @@ def _print_event(event) -> None: author = event.author or 'system' click.echo(f'[{author}]: {"".join(text_parts)}') - 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: - # Load the saved session from file - with open(saved_session_file, 'r', encoding='utf-8') as f: - loaded_session = Session.model_validate_json(f.read()) - - # Create a new session in the service, copying state from the file - session = await session_service.create_session( - app_name=session_app_name, - user_id=user_id, - state=loaded_session.state if loaded_session else None, + # Set up watchdog observer for dev mode + observer: Optional[Observer] = None + change_handler: Optional[DevModeChangeHandler] = None + if dev_mode: + 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', ) - # Append events from the file to the new session and display them - if loaded_session: - for event in loaded_session.events: - await session_service.append_event(session, event) - _print_event(event) - - await run_interactively( - agent_or_app, - artifact_service, - session, - session_service, - credential_service, - ) - else: - session = await session_service.create_session( - app_name=session_app_name, user_id=user_id - ) - 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, - ) + 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: + # Load the saved session from file + with open(saved_session_file, 'r', encoding='utf-8') as f: + loaded_session = Session.model_validate_json(f.read()) + + # Create a new session in the service, copying state from the file + session = await session_service.create_session( + app_name=session_app_name, + user_id=user_id, + state=loaded_session.state if loaded_session else None, + ) + + # Append events from the file to the new session and display them + if loaded_session: + for event in loaded_session.events: + await session_service.append_event(session, event) + _print_event(event) + + 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: + session = await session_service.create_session( + app_name=session_app_name, user_id=user_id + ) + 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: ') diff --git a/src/google/adk/cli/cli_eval.py b/src/google/adk/cli/cli_eval.py index cce160ae36..bbcc89ad90 100644 --- a/src/google/adk/cli/cli_eval.py +++ b/src/google/adk/cli/cli_eval.py @@ -97,7 +97,15 @@ def parse_and_get_evals_to_run( eval_set_to_evals = {} for input_eval_set in evals_to_run_info: evals = [] - if ":" not in input_eval_set: + # Check if the input is a file path that exists (e.g. C:\path\to\file.json) + if os.path.exists(input_eval_set): + eval_set = input_eval_set + # Check if it's a file path with cases (e.g. C:\path\to\file.json:case1,case2) + elif ":" in input_eval_set and os.path.exists(input_eval_set.rsplit(":", 1)[0]): + eval_set = input_eval_set.rsplit(":", 1)[0] + evals = input_eval_set.rsplit(":", 1)[1].split(",") + evals = [s for s in evals if s.strip()] + elif ":" not in input_eval_set: # We don't have any eval cases specified. This would be the case where the # the user wants to run all eval cases in the eval set. eval_set = input_eval_set diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 66f4dbe455..8337bfd092 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -24,7 +24,6 @@ import os from pathlib import Path import tempfile -import textwrap from typing import Optional import click @@ -367,62 +366,7 @@ def validate_exclusive(ctx, param, value): return value -def adk_services_options(): - """Decorator to add ADK services options to click commands.""" - - def decorator(func): - @click.option( - "--session_service_uri", - help=textwrap.dedent( - """\ - Optional. The URI of the session service. - - Leave unset to use the in-memory session service (default). - - Use 'agentengine://' to connect to Agent Engine - sessions. can either be the full qualified resource - name 'projects/abc/locations/us-central1/reasoningEngines/123' or - the resource id '123'. - - Use 'memory://' to run with the in-memory session service. - - Use 'sqlite://' to connect to a SQLite DB. - - See https://docs.sqlalchemy.org/en/20/core/engines.html#backend-specific-urls for more details on supported database URIs.""" - ), - ) - @click.option( - "--artifact_service_uri", - type=str, - help=textwrap.dedent( - """\ - Optional. The URI of the artifact service. - - Leave unset to store artifacts under '.adk/artifacts' locally. - - Use 'gs://' to connect to the GCS artifact service. - - Use 'memory://' to force the in-memory artifact service. - - Use 'file://' to store artifacts in a custom local directory.""" - ), - default=None, - ) - @click.option( - "--memory_service_uri", - type=str, - help=textwrap.dedent("""\ - Optional. The URI of the memory service. - - Use 'rag://' to connect to Vertex AI Rag Memory Service. - - Use 'agentengine://' to connect to Agent Engine - sessions. can either be the full qualified resource - name 'projects/abc/locations/us-central1/reasoningEngines/123' or - the resource id '123'. - - Use 'memory://' to force the in-memory memory service."""), - default=None, - ) - @functools.wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - - return wrapper - - return decorator - - @main.command("run", cls=HelpfulCommand) -@adk_services_options() @click.option( "--save_session", type=bool, @@ -465,6 +409,16 @@ def wrapper(*args, **kwargs): ), 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( @@ -477,9 +431,7 @@ def cli_run( session_id: Optional[str], replay: Optional[str], resume: Optional[str], - session_service_uri: Optional[str] = None, - artifact_service_uri: Optional[str] = None, - memory_service_uri: Optional[str] = None, + dev: bool, ): """Runs an interactive CLI for a certain agent. @@ -488,17 +440,11 @@ def cli_run( Example: adk run path/to/my_agent + + adk run --dev path/to/my_agent """ logs.log_to_tmp_folder() - # Validation warning for memory_service_uri (not supported for adk run) - if memory_service_uri: - click.secho( - "WARNING: --memory_service_uri is not supported for adk run.", - fg="yellow", - err=True, - ) - agent_parent_folder = os.path.dirname(agent) agent_folder_name = os.path.basename(agent) @@ -510,8 +456,7 @@ def cli_run( saved_session_file=resume, save_session=save_session, session_id=session_id, - session_service_uri=session_service_uri, - artifact_service_uri=artifact_service_uri, + dev_mode=dev, ) ) @@ -946,6 +891,55 @@ def wrapper(*args, **kwargs): return decorator +def adk_services_options(): + """Decorator to add ADK services options to click commands.""" + + def decorator(func): + @click.option( + "--session_service_uri", + help=( + """Optional. The URI of the session service. + - Use 'agentengine://' to connect to Agent Engine + sessions. can either be the full qualified resource + name 'projects/abc/locations/us-central1/reasoningEngines/123' or + the resource id '123'. + - Use 'sqlite://' to connect to an aio-sqlite + based session service, which is good for local development. + - Use 'postgresql://:@:/' + to connect to a PostgreSQL DB. + - See https://docs.sqlalchemy.org/en/20/core/engines.html#backend-specific-urls + for more details on other database URIs supported by SQLAlchemy.""" + ), + ) + @click.option( + "--artifact_service_uri", + type=str, + help=( + "Optional. The URI of the artifact service," + " supported URIs: gs:// for GCS artifact service." + ), + default=None, + ) + @click.option( + "--memory_service_uri", + type=str, + help=("""Optional. The URI of the memory service. + - Use 'rag://' to connect to Vertex AI Rag Memory Service. + - Use 'agentengine://' to connect to Agent Engine + sessions. can either be the full qualified resource + name 'projects/abc/locations/us-central1/reasoningEngines/123' or + the resource id '123'."""), + default=None, + ) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + return decorator + + def deprecated_adk_services_options(): """Deprecated ADK services options.""" @@ -953,7 +947,7 @@ def warn(alternative_param, ctx, param, value): if value: click.echo( click.style( - f"WARNING: Deprecated option --{param.name} is used. Please use" + f"WARNING: Deprecated option {param.name} is used. Please use" f" {alternative_param} instead.", fg="yellow", ), @@ -1148,8 +1142,6 @@ def cli_web( adk web --session_service_uri=[uri] --port=[port] path/to/agents_dir """ - session_service_uri = session_service_uri or session_db_url - artifact_service_uri = artifact_service_uri or artifact_storage_uri logs.setup_adk_logger(getattr(logging, log_level.upper())) @asynccontextmanager @@ -1174,6 +1166,8 @@ async def _lifespan(app: FastAPI): fg="green", ) + session_service_uri = session_service_uri or session_db_url + artifact_service_uri = artifact_service_uri or artifact_storage_uri app = get_fast_api_app( agents_dir=agents_dir, session_service_uri=session_service_uri, @@ -1247,10 +1241,10 @@ def cli_api_server( adk api_server --session_service_uri=[uri] --port=[port] path/to/agents_dir """ - session_service_uri = session_service_uri or session_db_url - artifact_service_uri = artifact_service_uri or artifact_storage_uri logs.setup_adk_logger(getattr(logging, log_level.upper())) + session_service_uri = session_service_uri or session_db_url + artifact_service_uri = artifact_service_uri or artifact_storage_uri config = uvicorn.Config( get_fast_api_app( agents_dir=agents_dir, diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index 95b561e57b..373eda1f1d 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") @@ -894,3 +911,130 @@ 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 + # Check for key terms to be resilient to line wrapping + assert "automatic" in result.output + assert "agent" in result.output + assert "reloading" in result.output