Skip to content

Commit 6d22458

Browse files
committed
feat(cli): Add save-session-on-runtime flag for periodic session checkpointing
1 parent 7de5bc5 commit 6d22458

3 files changed

Lines changed: 265 additions & 38 deletions

File tree

src/google/adk/cli/cli.py

Lines changed: 129 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import asyncio
1718
from datetime import datetime
1819
from pathlib import Path
1920
from typing import Optional
@@ -69,25 +70,25 @@ async def run_input_file(
6970
memory_service=memory_service,
7071
credential_service=credential_service,
7172
)
72-
with open(input_path, 'r', encoding='utf-8') as f:
73+
with open(input_path, "r", encoding="utf-8") as f:
7374
input_file = InputFile.model_validate_json(f.read())
74-
input_file.state['_time'] = datetime.now().isoformat()
75+
input_file.state["_time"] = datetime.now().isoformat()
7576

7677
session = await session_service.create_session(
7778
app_name=app_name, user_id=user_id, state=input_file.state
7879
)
7980
for query in input_file.queries:
80-
click.echo(f'[user]: {query}')
81-
content = types.Content(role='user', parts=[types.Part(text=query)])
81+
click.echo(f"[user]: {query}")
82+
content = types.Content(role="user", parts=[types.Part(text=query)])
8283
async with Aclosing(
8384
runner.run_async(
8485
user_id=session.user_id, session_id=session.id, new_message=content
8586
)
8687
) as agen:
8788
async for event in agen:
8889
if event.content and event.content.parts:
89-
if text := ''.join(part.text or '' for part in event.content.parts):
90-
click.echo(f'[{event.author}]: {text}')
90+
if text := "".join(part.text or "" for part in event.content.parts):
91+
click.echo(f"[{event.author}]: {text}")
9192
return session
9293

9394

@@ -98,6 +99,9 @@ async def run_interactively(
9899
session_service: BaseSessionService,
99100
credential_service: BaseCredentialService,
100101
memory_service: Optional[BaseMemoryService] = None,
102+
save_session_on_runtime: bool = False,
103+
interval: int = 60,
104+
agent_root: Optional[Path] = None,
101105
) -> None:
102106
app = (
103107
root_agent_or_app
@@ -111,25 +115,78 @@ async def run_interactively(
111115
memory_service=memory_service,
112116
credential_service=credential_service,
113117
)
114-
while True:
115-
query = input('[user]: ')
116-
if not query or not query.strip():
117-
continue
118-
if query == 'exit':
119-
break
120-
async with Aclosing(
121-
runner.run_async(
122-
user_id=session.user_id,
123-
session_id=session.id,
124-
new_message=types.Content(
125-
role='user', parts=[types.Part(text=query)]
126-
),
127-
)
128-
) as agen:
129-
async for event in agen:
130-
if event.content and event.content.parts:
131-
if text := ''.join(part.text or '' for part in event.content.parts):
132-
click.echo(f'[{event.author}]: {text}')
118+
119+
# Background task for periodic session saving
120+
save_task: Optional[asyncio.Task] = None
121+
122+
async def _periodic_save_session():
123+
"""Periodically save the session to disk every interval seconds."""
124+
nonlocal session, save_task
125+
try:
126+
while True:
127+
await asyncio.sleep(interval) # Save every Interval seconds
128+
129+
if save_session_on_runtime and agent_root:
130+
try:
131+
# Get the current session state
132+
current_session = await session_service.get_session(
133+
app_name=session.app_name,
134+
user_id=session.user_id,
135+
session_id=session.id,
136+
)
137+
138+
if current_session:
139+
# Save to runtime session file
140+
runtime_session_path = (
141+
agent_root / ".adk" / "runtime_session.json"
142+
)
143+
runtime_session_path.parent.mkdir(parents=True, exist_ok=True)
144+
runtime_session_path.write_text(
145+
current_session.model_dump_json(
146+
indent=2, exclude_none=True, by_alias=True
147+
),
148+
encoding="utf-8",
149+
)
150+
except Exception:
151+
# Silently ignore errors to avoid breaking the interactive loop
152+
pass
153+
except asyncio.CancelledError:
154+
# Task was cancelled, exit gracefully
155+
pass
156+
157+
# Start the periodic save task if enabled
158+
if save_session_on_runtime and agent_root:
159+
save_task = asyncio.create_task(_periodic_save_session())
160+
161+
try:
162+
while True:
163+
query = input("[user]: ")
164+
if not query or not query.strip():
165+
continue
166+
if query == "exit":
167+
break
168+
async with Aclosing(
169+
runner.run_async(
170+
user_id=session.user_id,
171+
session_id=session.id,
172+
new_message=types.Content(
173+
role="user", parts=[types.Part(text=query)]
174+
),
175+
)
176+
) as agen:
177+
async for event in agen:
178+
if event.content and event.content.parts:
179+
if text := "".join(part.text or "" for part in event.content.parts):
180+
click.echo(f"[{event.author}]: {text}")
181+
finally:
182+
# Clean up the background task
183+
if save_task:
184+
save_task.cancel()
185+
try:
186+
await save_task
187+
except asyncio.CancelledError:
188+
pass
189+
133190
await runner.close()
134191

135192

@@ -140,6 +197,8 @@ async def run_cli(
140197
input_file: Optional[str] = None,
141198
saved_session_file: Optional[str] = None,
142199
save_session: bool,
200+
save_session_on_runtime: bool,
201+
interval: int,
143202
session_id: Optional[str] = None,
144203
session_service_uri: Optional[str] = None,
145204
artifact_service_uri: Optional[str] = None,
@@ -167,7 +226,7 @@ async def run_cli(
167226
agent_parent_path = Path(agent_parent_dir).resolve()
168227
agent_root = agent_parent_path / agent_folder_name
169228
load_services_module(str(agent_root))
170-
user_id = 'test_user'
229+
user_id = "test_user"
171230

172231
agents_dir = str(agent_parent_path)
173232
agent_loader = AgentLoader(agents_dir=agents_dir)
@@ -179,7 +238,7 @@ async def run_cli(
179238
if isinstance(agent_or_app, App) and agent_or_app.name != agent_folder_name:
180239
app_name_to_dir = {agent_or_app.name: agent_folder_name}
181240

182-
if not is_env_enabled('ADK_DISABLE_LOAD_DOTENV'):
241+
if not is_env_enabled("ADK_DISABLE_LOAD_DOTENV"):
183242
envs.load_dotenv_for_agent(agent_folder_name, agents_dir)
184243

185244
# Create session and artifact services using factory functions.
@@ -211,8 +270,8 @@ def _print_event(event) -> None:
211270
text_parts = [part.text for part in content.parts if part.text]
212271
if not text_parts:
213272
return
214-
author = event.author or 'system'
215-
click.echo(f'[{author}]: {"".join(text_parts)}')
273+
author = event.author or "system"
274+
click.echo(f"[{author}]: {''.join(text_parts)}")
216275

217276
if input_file:
218277
session = await run_input_file(
@@ -227,7 +286,7 @@ def _print_event(event) -> None:
227286
)
228287
elif saved_session_file:
229288
# Load the saved session from file
230-
with open(saved_session_file, 'r', encoding='utf-8') as f:
289+
with open(saved_session_file, "r", encoding="utf-8") as f:
231290
loaded_session = Session.model_validate_json(f.read())
232291

233292
# Create a new session in the service, copying state from the file
@@ -252,22 +311,54 @@ def _print_event(event) -> None:
252311
memory_service=memory_service,
253312
)
254313
else:
255-
session = await session_service.create_session(
256-
app_name=session_app_name, user_id=user_id
257-
)
258-
click.echo(f'Running agent {agent_or_app.name}, type exit to exit.')
314+
# Check for runtime saved session
315+
runtime_session_path = agent_root / ".adk" / "runtime_session.json"
316+
if runtime_session_path.exists():
317+
try:
318+
with open(runtime_session_path, "r", encoding="utf-8") as f:
319+
loaded_session = Session.model_validate_json(f.read())
320+
321+
# Create a new session in the service, copying state from the file
322+
session = await session_service.create_session(
323+
app_name=session_app_name,
324+
user_id=user_id,
325+
state=loaded_session.state if loaded_session else None,
326+
)
327+
328+
# Append events from the file to the new session and display them
329+
if loaded_session:
330+
for event in loaded_session.events:
331+
await session_service.append_event(session, event)
332+
_print_event(event)
333+
334+
click.echo(f"Loaded runtime session from {runtime_session_path}")
335+
except Exception as e:
336+
click.echo(f"Warning: Failed to load runtime session: {e}")
337+
# Fall back to creating a new session
338+
session = await session_service.create_session(
339+
app_name=session_app_name, user_id=user_id
340+
)
341+
else:
342+
session = await session_service.create_session(
343+
app_name=session_app_name, user_id=user_id
344+
)
345+
346+
click.echo(f"Running agent {agent_or_app.name}, type exit to exit.")
259347
await run_interactively(
260348
agent_or_app,
261349
artifact_service,
262350
session,
263351
session_service,
264352
credential_service,
265353
memory_service=memory_service,
354+
save_session_on_runtime=True,
355+
interval=interval,
356+
agent_root=agent_root,
266357
)
267358

268359
if save_session:
269-
session_id = session_id or input('Session ID to save: ')
270-
session_path = agent_root / f'{session_id}.session.json'
360+
session_id = session_id or input("Session ID to save: ")
361+
session_path = agent_root / f"{session_id}.session.json"
271362

272363
# Fetch the session again to get all the details.
273364
session = await session_service.get_session(
@@ -277,7 +368,7 @@ def _print_event(event) -> None:
277368
)
278369
session_path.write_text(
279370
session.model_dump_json(indent=2, exclude_none=True, by_alias=True),
280-
encoding='utf-8',
371+
encoding="utf-8",
281372
)
282373

283-
print('Session saved to', session_path)
374+
print("Session saved to", session_path)

src/google/adk/cli/cli_tools_click.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,24 @@ def wrapper(*args, **kwargs):
605605
@main.command("run", cls=HelpfulCommand)
606606
@feature_options()
607607
@adk_services_options(default_use_local_storage=True)
608+
@click.option(
609+
"--interval",
610+
type=int,
611+
default=60,
612+
show_default=True,
613+
help=(
614+
"Autosave interval in seconds (only used if --save_session_on_runtime"
615+
" is set)."
616+
),
617+
)
618+
@click.option(
619+
"--save_session_on_runtime",
620+
type=bool,
621+
is_flag=True,
622+
show_default=True,
623+
default=False,
624+
help="Optional. Whether to save the session to a json file on runtime.",
625+
)
608626
@click.option(
609627
"--save_session",
610628
type=bool,
@@ -656,6 +674,8 @@ def wrapper(*args, **kwargs):
656674
def cli_run(
657675
agent: str,
658676
save_session: bool,
677+
save_session_on_runtime: bool,
678+
interval: int,
659679
session_id: Optional[str],
660680
replay: Optional[str],
661681
resume: Optional[str],
@@ -684,6 +704,8 @@ def cli_run(
684704
input_file=replay,
685705
saved_session_file=resume,
686706
save_session=save_session,
707+
save_session_on_runtime=save_session_on_runtime,
708+
interval=interval,
687709
session_id=session_id,
688710
session_service_uri=session_service_uri,
689711
artifact_service_uri=artifact_service_uri,

0 commit comments

Comments
 (0)