A session is an append-only log of events, and its state is what those events changed. ADK gives you two operations on that log for the day something goes wrong. Runner.rewind_async undoes a user’s turn and everything after it, so a conversation can return to a known good point. adk migrate session copies a session database written by an older ADK release into the current schema. Here you rewind a reading-list session for the docs assistant and look at exactly what came back and what did not, then migrate a legacy database and catch the one event the migration quietly dropped. The rewind run is recorded on Gemini gemini-3.8-flash; the migration runs need no model.
When you need this. A developer tells the docs assistant to pin a page and switch teams, then realizes it was the wrong session. The docs team wants an “undo last turn” button rather than a new session that loses the history. Separately, the assistant’s sessions have lived in a database since an ADK 1.x release, and ADK now warns that the v0 pickle schema will stop being supported. Both tasks rewrite stored state, so you need to know their edges before you run them on real data.
$ python pins.py
after turn 1: {'pinned': ['rollback'], 'user:team': 'payments', 'artifact_versions': [0], 'artifact': '- rollback', 'events': 4}
after turn 2: {'pinned': ['rollback', 'tokens'], 'user:team': 'platform', 'artifact_versions': [0, 1], 'artifact': '- rollback\n- tokens', 'events': 8}
after rewind: {'pinned': ['rollback'], 'user:team': 'platform', 'artifact_versions': [0, 1, 2], 'artifact': '- rollback', 'events': 9}
turn 3: You are on the payments team, and the rollback page is currently pinned.Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-rewind-migrate/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt1. Keep session state, user state and an artifact
An ADK artifact is a versioned file managed by an artifact service. Here it holds a Markdown reading list. pins.py gives the assistant two tools that write three kinds of data: pin_page updates pinned in session state and saves a reading-list.md artifact, and set_team writes user:team, which the user: prefix shares across all of that user’s sessions.
"""Pin docs pages for a developer, then rewind the session to undo a turn.
Run from this folder with GOOGLE_API_KEY set: python pins.py
Sessions live in SQLite through DatabaseSessionService. Set DOCS_MODEL=ollama_chat/gemma4:e2b for Gemma.
"""
import asyncio
import os
from pathlib import Path
from google.adk import Agent, Runner
from google.adk.artifacts import InMemoryArtifactService
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import DatabaseSessionService
from google.adk.tools.tool_context import ToolContext
from google.genai import types
HERE = Path(__file__).resolve().parent
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
DB_URL = os.environ.get("DOCS_DB", f"sqlite+aiosqlite:///{HERE / 'sessions.db'}")
APP, USER = "docs_assistant", "dev-1"
PAGES = ("config", "deploy", "rollback", "tokens")
async def pin_page(page: str, tool_context: ToolContext) -> dict:
"""Pin a docs page to the developer's reading list for this session.
Args:
page: The page name: config, deploy, rollback or tokens.
"""
if page not in PAGES:
return {"error": f"No page {page!r}."}
pinned = [*tool_context.state.get("pinned", []), page]
tool_context.state["pinned"] = pinned # session-scoped state
note = types.Part.from_bytes(data="\n".join(f"- {p}" for p in pinned).encode(), mime_type="text/markdown")
version = await tool_context.save_artifact("reading-list.md", note) # session-scoped artifact
return {"pinned": pinned, "artifact_version": version}
def set_team(team: str, tool_context: ToolContext) -> dict:
"""Record the developer's team. It is kept for the user across all sessions.
Args:
team: The team name, for example payments or platform.
"""
tool_context.state["user:team"] = team # user-scoped state: the user: prefix
return {"team": team}
root_agent = Agent(
name=APP,
model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
instruction="""You keep a reading list of docuctl docs pages for a developer.
Use pin_page to pin pages and set_team when the developer names their team.
Current pins: {pinned?}. Developer's team: {user:team?}.
Answer in one short sentence.""",
tools=[pin_page, set_team],
generate_content_config=types.GenerateContentConfig(
temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)
def build_runner(db_url: str = DB_URL) -> Runner:
return Runner(app_name=APP, agent=root_agent, session_service=DatabaseSessionService(db_url=db_url),
artifact_service=InMemoryArtifactService())
async def ask(runner: Runner, session_id: str, text: str) -> list:
message = types.Content(role="user", parts=[types.Part(text=text)])
return [e async for e in runner.run_async(user_id=USER, session_id=session_id, new_message=message)]
async def snapshot(runner: Runner, session_id: str) -> dict:
"""What the services hold now: session and user state, artifact versions, event count."""
session = await runner.session_service.get_session(app_name=APP, user_id=USER, session_id=session_id)
versions = await runner.artifact_service.list_versions(app_name=APP, user_id=USER, session_id=session_id,
filename="reading-list.md")
latest = await runner.artifact_service.load_artifact(app_name=APP, user_id=USER, session_id=session_id,
filename="reading-list.md")
return {"pinned": session.state.get("pinned"), "user:team": session.state.get("user:team"),
"artifact_versions": versions, "artifact": latest.inline_data.data.decode() if latest else None,
"events": len(session.events)}
async def main() -> None:
runner = build_runner()
session = await runner.session_service.create_session(app_name=APP, user_id=USER)
try:
first = await ask(runner, session.id, "I'm on the payments team. Pin the rollback page.")
print("after turn 1:", await snapshot(runner, session.id))
second = await ask(runner, session.id, "Pin the tokens page too. And I moved to the platform team.")
print("after turn 2:", await snapshot(runner, session.id))
await runner.rewind_async(user_id=USER, session_id=session.id,
rewind_before_invocation_id=second[0].invocation_id)
print("after rewind:", await snapshot(runner, session.id))
third = await ask(runner, session.id, "Which pages are pinned, and which team am I on?")
print("turn 3:", third[-1].content.parts[-1].text)
finally:
await runner.close()
if __name__ == "__main__":
asyncio.run(main())The instruction injects {pinned?} and {user:team?}, so the model sees the current values of both on every call.
2. Rewind to before the second turn
Turn 1 pinned rollback and set the team to payments; turn 2 pinned tokens and changed the team to platform. rewind_async takes the invocation ID of the first turn to undo, here the second turn’s, read from its first event. After the rewind, pinned was back to ["rollback"] and the artifact’s latest content was back to the turn 1 list. The session had 9 events, one more than before: the rewind added an event and deleted nothing.
user:team stayed platform. Rewind restores session-level state and artifacts only; app-level and user-level state are shared beyond this session, so the docs list them as not restored. The model then saw “payments” in the remaining history and “platform” in the injected state, and its turn 3 answer named the team from the history. If a rewind must also undo a user-level change, write the old value back yourself.
User state that rewind does not restore. user:team kept the value from the rewound turn, and the next answer mixed old history with new state. Treat user: and app: writes as outside the undo; restore them yourself when they must follow.
An invocation ID that is not in the session. rewind_async raises InvocationNotFoundError and changes nothing. Read the ID from the stored events of that session.
# runner.rewind_async(..., rewind_before_invocation_id='e-0000')
InvocationNotFoundError: Invocation ID not found: e-00003. Build a legacy session database
ADK releases before 1.22 stored event actions as Python pickles: the v0 schema. ADK 2.12 still reads and writes it, and includes the v0 schema classes, so legacy.py builds one the way an old deployment would have. Its second event stores a collections.Counter in state. JSON can represent its entries as a mapping, but not preserve the Python Counter type; the restricted unpickler rejects that type:
"""Build a v0 (pickle) session database like one written by ADK releases before 1.22.
Run: python legacy.py legacy.db
It uses ADK's own v0 schema classes; DatabaseSessionService detects the v0 tables and writes
event actions as pickles. The page_views Counter is session state that is not plain JSON data.
"""
import asyncio
import collections
import sys
from google.adk.events import Event, EventActions
from google.adk.sessions import DatabaseSessionService
from google.adk.sessions.schemas import v0
from google.genai import types
from sqlalchemy import create_engine
async def build(path: str) -> None:
engine = create_engine(f"sqlite:///{path}")
v0.Base.metadata.create_all(engine) # the legacy tables, with no schema-version row
engine.dispose()
sessions = DatabaseSessionService(db_url=f"sqlite+aiosqlite:///{path}")
session = await sessions.create_session(app_name="docs_assistant", user_id="dev-1", session_id="pins-2025",
state={"user:team": "payments"})
turns = [("Pin the rollback page.", {"pinned": ["rollback"]}),
("Pin the tokens page.", {"pinned": ["rollback", "tokens"],
"page_views": collections.Counter(rollback=3, tokens=1)})]
for n, (text, delta) in enumerate(turns, start=1):
await sessions.append_event(session, Event(invocation_id=f"e-{n}", author="user",
content=types.Content(role="user", parts=[types.Part(text=text)])))
await sessions.append_event(session, Event(invocation_id=f"e-{n}", author="docs_assistant",
content=types.Content(role="model", parts=[types.Part(text="Pinned.")]),
actions=EventActions(state_delta=delta)))
if __name__ == "__main__":
asyncio.run(build(sys.argv[1]))$ python legacy.py legacy.db
The database is using the legacy v0 schema, which uses Pickle to serialize event actions. The v0 schema will not be supported going forward and will be deprecated in a few rollouts. Please migrate to the v1 schema which uses JSON serialization for event data. …
This database uses the legacy schema, which stores an event as one column per field. 16 event fields are dropped on write and read back as unset: avg_logprobs, cache_metadata, environment_id, finish_reason, go_away, interaction_id, interaction_status, isolatio …
exit 0The second warning names 16 fields that v0 cannot store. This does not mean every event had meaningful values in all of them; inspect the fields your application uses before continuing to write to v0.
4. Migrate and check the result
adk migrate session reads from one URL and writes a new database at another; the command rejects an in-place migration. Its log for this database, without the routine INFO lines:
$ adk migrate session --source_db_url sqlite:///legacy.db --dest_db_url sqlite:///migrated.db
WARNING - _schema_check_utils.py:81 - The database is using the legacy v0 schema, which uses Pickle to serialize event actions. The v0 schema will not be supported going forward and will be deprecated in a few rollouts. Please migrate to the v1 schema which us …
WARNING - migrate_from_sqlalchemy_pickle.py:85 - Failed to unpickle actions for event 91f82cf5-09df-4b83-a664-3cc2ffce62ee: Refusing to load collections.Counter from a legacy pickled `events.actions` value: it is not a type that `EventActions` can hold. This v …
INFO - migrate_from_sqlalchemy_pickle.py:351 - Migrated 4 events.
INFO - migrate_from_sqlalchemy_pickle.py:356 - Migration completed successfully.
Migration check and upgrade process finished.
exit 0The command reported success and exited 0, but one line is a warning that it refused to load the Counter. check_migrated.py opens the new database with DatabaseSessionService and prints each event’s state delta:
"""Open a migrated session database and print each event's state delta.
Run: python check_migrated.py migrated.db
A migration that dropped an event's actions shows up here as an empty state_delta.
"""
import asyncio
import sys
from google.adk.sessions import DatabaseSessionService
async def check(path: str) -> list[str]:
sessions = DatabaseSessionService(db_url=f"sqlite+aiosqlite:///{path}")
lines = []
for listed in (await sessions.list_sessions(app_name="docs_assistant", user_id="dev-1")).sessions:
session = await sessions.get_session(app_name="docs_assistant", user_id="dev-1", session_id=listed.id)
lines.append(f"session {session.id}: {len(session.events)} events, state {dict(session.state)}")
lines += [f"event {e.invocation_id} {e.author:<15} state_delta={dict(e.actions.state_delta)}" for e in session.events]
return lines
if __name__ == "__main__":
print("\n".join(asyncio.run(check(sys.argv[1]))))$ python check_migrated.py migrated.db
session pins-2025: 4 events, state {'pinned': ['rollback', 'tokens'], 'page_views': {'rollback': 3, 'tokens': 1}, 'user:team': 'payments'}
event e-1 user state_delta={}
event e-1 docs_assistant state_delta={'pinned': ['rollback']}
event e-2 user state_delta={}
event e-2 docs_assistant state_delta={}
exit 0Event e-2 lost its delta. The session’s current state is intact, because the migration copies it from the sessions table, but a rewind recomputes state from event deltas, so a later rewind on this database would compute the wrong state. This database is our own, so the fix is to rerun with --allow-unsafe-unpickling, which uses Python’s full unpickler:
$ python check_migrated.py migrated-full.db
session pins-2025: 4 events, state {'pinned': ['rollback', 'tokens'], 'page_views': {'rollback': 3, 'tokens': 1}, 'user:team': 'payments'}
event e-1 user state_delta={}
event e-1 docs_assistant state_delta={'pinned': ['rollback']}
event e-2 user state_delta={}
event e-2 docs_assistant state_delta={'pinned': ['rollback', 'tokens'], 'page_views': {'rollback': 3, 'tokens': 1}}
exit 0Unpickling can run code named in the data, so use that flag only on a database no one else could have written to. Afterward, point DatabaseSessionService at the new URL.
An event the migration cannot unpickle. A Counter in state is refused with a warning, the event’s actions are dropped, and the command still exits 0. Search the log for Failed to unpickle, check deltas with check_migrated.py, and rerun with --allow-unsafe-unpickling only for a trusted database.
Migrating in place. Passing the same source and destination URL fails with In-place migration is not supported, exit 1. Write to a new database, then switch the service URL.
A misspelled source path. sqlite:///legacy-db.db does not exist, so SQLite creates an empty file, the command reports it is already at version 1 and exits 0, and no destination is written. Check that the source file exists and that the destination appears; compare session and event counts.
$ adk migrate session --source_db_url sqlite:///legacy.db --dest_db_url sqlite:///legacy.db
Migration failed: In-place migration is not supported. Please provide a different URL for dest_db_url.
exit 1
$ adk migrate session --source_db_url sqlite:///legacy-db.db --dest_db_url sqlite:///out.db
INFO - migration_runner.py:84 - Database sqlite:///legacy-db.db is already at latest version 1. No migration needed.
Migration check and upgrade process finished.
exit 0How rewind applies state and artifact deltas
Rewind does not edit history. It finds the event index where the chosen invocation starts, replays the state_delta of every earlier event to rebuild the session state at that point, skipping keys with the user:, app: and temp: prefixes, and compares the result with the current state. The differences become one new event:
{
"state_delta": {
"pinned": [
"rollback"
]
},
"artifact_delta": {
"reading-list.md": 2
},
"rewind_before_invocation_id": "e-b8177353-3d3f-4715-9724-2e23162b7f81"
}Its artifact delta points at the restored version of reading-list.md. When the runner next builds the model’s history, it skips the rewound invocations, so the model no longer sees turn 2. The docs add that the steps are not one atomic transaction, so do not rewind a session that is running a turn.
The migration has two parts: it copies sessions and app and user state as JSON, then reads each event row and unpickles its actions through a restricted unpickler that admits only the types EventActions can hold plus common standard-library data types. A row it refuses keeps its content but loses its actions, and the run continues.
Test rewind and migration offline
test_pins.py replays the recorded model replies for the rewind, and runs the real adk migrate session command on a fresh legacy.db in a temporary folder.
python -m unittest -v test_pins.pytest_a_misspelled_source_path_reports_no_migration_needed (test_pins.Migrate.test_a_misspelled_source_path_reports_no_migration_needed) ... ok
test_a_restricted_migration_drops_an_event_it_cannot_unpickle (test_pins.Migrate.test_a_restricted_migration_drops_an_event_it_cannot_unpickle) ... ok
test_allow_unsafe_unpickling_keeps_the_event (test_pins.Migrate.test_allow_unsafe_unpickling_keeps_the_event) ... ok
test_in_place_migration_is_refused (test_pins.Migrate.test_in_place_migration_is_refused) ... ok
test_an_unknown_invocation_id_raises (test_pins.Rewind.test_an_unknown_invocation_id_raises) ... ok
test_rewind_restores_session_state_and_the_artifact (test_pins.Rewind.test_rewind_restores_session_state_and_the_artifact) ... ok
test_rewound_events_stay_in_the_log (test_pins.Rewind.test_rewound_events_stay_in_the_log) ... ok
test_user_state_is_not_restored (test_pins.Rewind.test_user_state_is_not_restored) ... ok
----------------------------------------------------------------------
Ran 8 tests in 3.310s
OKAll 8 tests pass.
Read the test and recording scripts
"""Replay the rewind demo offline, and run the session migration CLI on a fresh legacy database.
Run: python -m unittest -v test_pins.py
The model is replaced by its recorded responses; the Runner, DatabaseSessionService, rewind and
`adk migrate session` are real. Nothing here calls a model or the network.
"""
import asyncio
import json
import logging
import subprocess
import sys
import tempfile
import unittest
import warnings
from pathlib import Path
from unittest import mock
warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)
from google.adk.models.base_llm import BaseLlm # noqa: E402
from google.adk.models.llm_response import LlmResponse # noqa: E402
from google.genai import types # noqa: E402
import check_migrated # noqa: E402
import legacy # noqa: E402
import pins # noqa: E402
HERE = Path(__file__).resolve().parent
RECORD = json.loads((HERE / "recorded-runs.json").read_text(encoding="utf-8"))
ADK = str(Path(sys.executable).with_name("adk"))
class ReplayLlm(BaseLlm):
turns: list
async def generate_content_async(self, llm_request, stream=False):
yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)["content"]))
class Rewind(unittest.TestCase):
def setUp(self):
warnings.simplefilter("ignore")
self.tmp = tempfile.TemporaryDirectory()
model = ReplayLlm(model="replay", turns=list(RECORD["rewind"]["model_turns"]))
self.agent = pins.root_agent.clone(update={"model": model})
def tearDown(self):
self.tmp.cleanup()
def run_demo(self, rewind_to_unknown: bool = False):
with mock.patch.object(pins, "root_agent", self.agent):
runner = pins.build_runner(f"sqlite+aiosqlite:///{Path(self.tmp.name) / 'sessions.db'}")
async def go():
session = await runner.session_service.create_session(app_name=pins.APP, user_id=pins.USER)
try:
await pins.ask(runner, session.id, RECORD["rewind"]["turns"][0])
second = await pins.ask(runner, session.id, RECORD["rewind"]["turns"][1])
target = "e-0000" if rewind_to_unknown else second[0].invocation_id
await runner.rewind_async(user_id=pins.USER, session_id=session.id, rewind_before_invocation_id=target)
return await pins.snapshot(runner, session.id)
finally:
await runner.close()
return asyncio.run(go())
def test_rewind_restores_session_state_and_the_artifact(self):
snap = self.run_demo()
expected = RECORD["rewind"]["snapshots"]["after rewind"]
self.assertEqual((snap["pinned"], snap["artifact"]), (expected["pinned"], expected["artifact"]))
self.assertEqual(snap["artifact_versions"], expected["artifact_versions"]) # restored as a new version
def test_user_state_is_not_restored(self):
snap = self.run_demo()
self.assertEqual(snap["user:team"], "platform")
self.assertEqual(RECORD["rewind"]["snapshots"]["after turn 1"]["user:team"], "payments")
def test_rewound_events_stay_in_the_log(self):
snap = self.run_demo()
self.assertEqual(snap["events"], RECORD["rewind"]["snapshots"]["after turn 2"]["events"] + 1)
def test_an_unknown_invocation_id_raises(self):
from google.adk.errors._invocation_not_found_error import InvocationNotFoundError
with self.assertRaisesRegex(InvocationNotFoundError, "Invocation ID not found: e-0000"):
self.run_demo(rewind_to_unknown=True)
class Migrate(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.dir = Path(cls.tmp.name)
asyncio.run(legacy.build(str(cls.dir / "legacy.db")))
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def setUp(self):
warnings.simplefilter("ignore")
def migrate(self, source: str, dest: str, *extra: str) -> subprocess.CompletedProcess:
return subprocess.run([ADK, "migrate", "session", "--source_db_url", f"sqlite:///{source}",
"--dest_db_url", f"sqlite:///{dest}", *extra], cwd=self.dir, capture_output=True, text=True)
def deltas(self, name: str) -> list[str]:
return [l for l in asyncio.run(check_migrated.check(str(self.dir / name))) if l.startswith("event e-2 docs")]
def test_a_restricted_migration_drops_an_event_it_cannot_unpickle(self):
proc = self.migrate("legacy.db", "migrated.db")
self.assertEqual(proc.returncode, 0)
self.assertIn("Refusing to load collections.Counter", proc.stderr)
self.assertEqual(self.deltas("migrated.db"), ["event e-2 docs_assistant state_delta={}"])
def test_allow_unsafe_unpickling_keeps_the_event(self):
proc = self.migrate("legacy.db", "migrated-full.db", "--allow-unsafe-unpickling")
self.assertEqual(proc.returncode, 0)
self.assertIn("page_views", self.deltas("migrated-full.db")[0])
def test_in_place_migration_is_refused(self):
proc = self.migrate("legacy.db", "legacy.db")
self.assertEqual(proc.returncode, 1)
self.assertIn("In-place migration is not supported", proc.stdout + proc.stderr)
def test_a_misspelled_source_path_reports_no_migration_needed(self):
proc = self.migrate("legacy-db.db", "out.db")
self.assertEqual(proc.returncode, 0)
self.assertIn("already at latest version 1. No migration needed.", proc.stderr)
self.assertTrue((self.dir / "legacy-db.db").exists())
self.assertFalse((self.dir / "out.db").exists())
if __name__ == "__main__":
unittest.main()"""Record the rewind demo on Gemini and the session-database migration commands.
Run from this folder with GOOGLE_API_KEY set: python record.py
Writes run-output.txt and recorded-runs.json. Databases are created in a temporary folder.
"""
import asyncio
import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)
TMP = Path(tempfile.mkdtemp())
os.environ["DOCS_DB"] = f"sqlite+aiosqlite:///{TMP / 'sessions.db'}"
import check_migrated # noqa: E402
import legacy # noqa: E402
import pins # noqa: E402
HERE = Path(__file__).resolve().parent
ADK = str(Path(sys.executable).with_name("adk"))
TURNS = ["I'm on the payments team. Pin the rollback page.",
"Pin the tokens page too. And I moved to the platform team.",
"Which pages are pinned, and which team am I on?"]
async def rewind_demo() -> dict:
runner = pins.build_runner()
session = await runner.session_service.create_session(app_name=pins.APP, user_id=pins.USER)
rec = {"snapshots": {}, "turns": TURNS}
try:
await pins.ask(runner, session.id, TURNS[0])
rec["snapshots"]["after turn 1"] = await pins.snapshot(runner, session.id)
second = await pins.ask(runner, session.id, TURNS[1])
rec["snapshots"]["after turn 2"] = await pins.snapshot(runner, session.id)
rec["rewound_invocation"] = second[0].invocation_id
await runner.rewind_async(user_id=pins.USER, session_id=session.id, rewind_before_invocation_id=second[0].invocation_id)
rec["snapshots"]["after rewind"] = await pins.snapshot(runner, session.id)
third = await pins.ask(runner, session.id, TURNS[2])
rec["answer"] = third[-1].content.parts[-1].text
stored = await runner.session_service.get_session(app_name=pins.APP, user_id=pins.USER, session_id=session.id)
rec["rewind_event"] = next(e.actions.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
for e in stored.events if e.actions.rewind_before_invocation_id)
rec["model_turns"] = [{"content": {"role": "model", "parts": [
{k: v for k, v in p.model_dump(mode="json", exclude_none=True).items() if k != "thought_signature"}
for p in e.content.parts]}} for e in stored.events if e.content and e.content.role == "model"]
try:
await runner.rewind_async(user_id=pins.USER, session_id=session.id, rewind_before_invocation_id="e-0000")
except Exception as exc: # recorded failure
rec["unknown_invocation"] = f"{type(exc).__name__}: {exc}"
finally:
await runner.close()
return rec
def sh(cmd: list[str]) -> dict:
proc = subprocess.run(cmd, cwd=TMP, capture_output=True, text=True, timeout=120)
lines = [re.sub(r"^\d{4}-\d\d-\d\d [\d:,]+ - ", "", l) for l in (proc.stderr + proc.stdout).splitlines()
if l.strip() and not l.startswith(" ")]
lines = [l[:260] + (" …" if len(l) > 260 else "") for l in lines]
shown = ["adk", *cmd[1:]] if cmd[0] == ADK else ["python", *[Path(a).name if a.endswith(".py") else a for a in cmd[1:]]]
return {"command": " ".join(shown), "lines": lines, "exit": proc.returncode}
def migrations() -> dict:
py = sys.executable
runs = {
"build": sh([py, str(HERE / "legacy.py"), "legacy.db"]),
"migrate": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy.db", "--dest_db_url", "sqlite:///migrated.db"]),
"check": sh([py, str(HERE / "check_migrated.py"), "migrated.db"]),
"migrate_unsafe": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy.db",
"--dest_db_url", "sqlite:///migrated-full.db", "--allow-unsafe-unpickling"]),
"check_unsafe": sh([py, str(HERE / "check_migrated.py"), "migrated-full.db"]),
"in_place": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy.db", "--dest_db_url", "sqlite:///legacy.db"]),
"typo": sh([ADK, "migrate", "session", "--source_db_url", "sqlite:///legacy-db.db", "--dest_db_url", "sqlite:///out.db"]),
}
runs["typo"]["created"] = sorted(p.name for p in TMP.glob("*.db"))
return runs
def main() -> None:
rec = {"model": pins.MODEL, "rewind": asyncio.run(rewind_demo()), "migrate": migrations()}
r = rec["rewind"]
out = ["$ python pins.py", *[f"{k}: {v}" for k, v in r["snapshots"].items()], f"turn 3: {r['answer']}", "",
"# runner.rewind_async(..., rewind_before_invocation_id='e-0000')", r["unknown_invocation"], ""]
for run in rec["migrate"].values():
out += [f"$ {run['command']}", *run["lines"], f"exit {run['exit']}", ""]
out.append(f"files now: {rec['migrate']['typo']['created']}")
text = "\n".join(out)
(HERE / "run-output.txt").write_text(text + "\n", encoding="utf-8")
(HERE / "recorded-runs.json").write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(text)
if __name__ == "__main__":
main()- Run an agent from event triggers: start the docs assistant from Pub/Sub and Eventarc deliveries instead of a user’s message.
Check your understanding
Read the explanation
The rewind restores session-scoped state while leaving user- and app-scoped state alone.
References
Official sources checked October 21, 2026, against google-adk 2.12.0. How rewind computes state comes from the installed google.adk.sessions._rewind_utils module.
- ADK: rewind sessions
- ADK: session database schema migration
- ADK: state
- ADK: artifacts
- Python: pickle security warning
Previously on the blog: Google ADK Sessions, State & Memory Explained (with Code) covers session state in ADK 1.x.