Skip to content

Commit e545e5a

Browse files
abchang123copybara-github
authored andcommitted
feat: Allow user to edit agent files/directories and apply changes without reloading anything
PiperOrigin-RevId: 780710003
1 parent e33161b commit e545e5a

File tree

4 files changed

+61
-1
lines changed

4 files changed

+61
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dependencies = [
5151
"typing-extensions>=4.5, <5",
5252
"tzlocal>=5.3", # Time zone utilities
5353
"uvicorn>=0.34.0", # ASGI server for FastAPI
54+
"watchdog>=6.0.0", # For file change detection and hot reload
5455
"websockets>=15.0.1", # For BaseLlmFlow
5556
# go/keep-sorted end
5657
]

src/google/adk/cli/cli_tools_click.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,13 @@ def decorator(func):
583583
default=False,
584584
help="Optional. Whether to enable A2A endpoint.",
585585
)
586+
@click.option(
587+
"--reload_agents",
588+
is_flag=True,
589+
default=False,
590+
show_default=True,
591+
help="Optional. Whether to enable live reload for agents changes.",
592+
)
586593
@functools.wraps(func)
587594
def wrapper(*args, **kwargs):
588595
return func(*args, **kwargs)
@@ -625,6 +632,7 @@ def cli_web(
625632
session_db_url: Optional[str] = None, # Deprecated
626633
artifact_storage_uri: Optional[str] = None, # Deprecated
627634
a2a: bool = False,
635+
reload_agents: bool = False,
628636
):
629637
"""Starts a FastAPI server with Web UI for agents.
630638
@@ -674,6 +682,7 @@ async def _lifespan(app: FastAPI):
674682
a2a=a2a,
675683
host=host,
676684
port=port,
685+
reload_agents=reload_agents,
677686
)
678687
config = uvicorn.Config(
679688
app,
@@ -721,6 +730,7 @@ def cli_api_server(
721730
session_db_url: Optional[str] = None, # Deprecated
722731
artifact_storage_uri: Optional[str] = None, # Deprecated
723732
a2a: bool = False,
733+
reload_agents: bool = False,
724734
):
725735
"""Starts a FastAPI server for agents.
726736
@@ -748,6 +758,7 @@ def cli_api_server(
748758
a2a=a2a,
749759
host=host,
750760
port=port,
761+
reload_agents=reload_agents,
751762
),
752763
host=host,
753764
port=port,
@@ -861,6 +872,7 @@ def cli_deploy_cloud_run(
861872
session_db_url: Optional[str] = None, # Deprecated
862873
artifact_storage_uri: Optional[str] = None, # Deprecated
863874
a2a: bool = False,
875+
reload_agents: bool = False,
864876
):
865877
"""Deploys an agent to Cloud Run.
866878

src/google/adk/cli/fast_api.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
from pydantic import ValidationError
5050
from starlette.types import Lifespan
5151
from typing_extensions import override
52+
from watchdog.events import FileSystemEventHandler
53+
from watchdog.observers import Observer
5254

5355
from ..agents import RunConfig
5456
from ..agents.live_request_queue import LiveRequest
@@ -87,6 +89,21 @@
8789
logger = logging.getLogger("google_adk." + __name__)
8890

8991
_EVAL_SET_FILE_EXTENSION = ".evalset.json"
92+
_app_name = ""
93+
_runners_to_clean = set()
94+
95+
96+
class AgentChangeEventHandler(FileSystemEventHandler):
97+
98+
def __init__(self, agent_loader: AgentLoader):
99+
self.agent_loader = agent_loader
100+
101+
def on_modified(self, event):
102+
if not (event.src_path.endswith(".py") or event.src_path.endswith(".yaml")):
103+
return
104+
logger.info("Change detected in agents directory: %s", event.src_path)
105+
self.agent_loader.remove_agent_from_cache(_app_name)
106+
_runners_to_clean.add(_app_name)
90107

91108

92109
class ApiServerSpanExporter(export.SpanExporter):
@@ -205,6 +222,7 @@ def get_fast_api_app(
205222
host: str = "127.0.0.1",
206223
port: int = 8000,
207224
trace_to_cloud: bool = False,
225+
reload_agents: bool = False,
208226
lifespan: Optional[Lifespan[FastAPI]] = None,
209227
) -> FastAPI:
210228
# InMemory tracing dict.
@@ -235,14 +253,16 @@ def get_fast_api_app(
235253

236254
@asynccontextmanager
237255
async def internal_lifespan(app: FastAPI):
238-
239256
try:
240257
if lifespan:
241258
async with lifespan(app) as lifespan_context:
242259
yield lifespan_context
243260
else:
244261
yield
245262
finally:
263+
if reload_agents:
264+
observer.stop()
265+
observer.join()
246266
# Create tasks for all runner closures to run concurrently
247267
await cleanup.close_runners(list(runner_dict.values()))
248268

@@ -336,6 +356,13 @@ async def internal_lifespan(app: FastAPI):
336356
# initialize Agent Loader
337357
agent_loader = AgentLoader(agents_dir)
338358

359+
# Set up a file system watcher to detect changes in the agents directory.
360+
observer = Observer()
361+
if reload_agents:
362+
event_handler = AgentChangeEventHandler(agent_loader)
363+
observer.schedule(event_handler, agents_dir, recursive=True)
364+
observer.start()
365+
339366
@app.get("/list-apps")
340367
def list_apps() -> list[str]:
341368
base_path = Path.cwd() / agents_dir
@@ -390,6 +417,9 @@ async def get_session(
390417
)
391418
if not session:
392419
raise HTTPException(status_code=404, detail="Session not found")
420+
421+
global _app_name
422+
_app_name = app_name
393423
return session
394424

395425
@app.get(
@@ -947,6 +977,11 @@ async def process_messages():
947977

948978
async def _get_runner_async(app_name: str) -> Runner:
949979
"""Returns the runner for the given app."""
980+
if app_name in _runners_to_clean:
981+
_runners_to_clean.remove(app_name)
982+
runner = runner_dict.pop(app_name, None)
983+
await cleanup.close_runners(list([runner]))
984+
950985
envs.load_dotenv_for_agent(os.path.basename(app_name), agents_dir)
951986
if app_name in runner_dict:
952987
return runner_dict[app_name]

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,15 @@ def load_agent(self, agent_name: str) -> BaseAgent:
164164
agent = self._perform_load(agent_name)
165165
self._agent_cache[agent_name] = agent
166166
return agent
167+
168+
def remove_agent_from_cache(self, agent_name: str):
169+
# Clear module cache for the agent and its submodules
170+
keys_to_delete = [
171+
module_name
172+
for module_name in sys.modules
173+
if module_name == agent_name or module_name.startswith(f"{agent_name}.")
174+
]
175+
for key in keys_to_delete:
176+
logger.debug("Deleting module %s", key)
177+
del sys.modules[key]
178+
self._agent_cache.pop(agent_name, None)

0 commit comments

Comments
 (0)