Skip to content

Commit bed446c

Browse files
DeanChensjcopybara-github
authored andcommitted
refactor(runners): extract session rewind logic into sessions/_rewind_utils
Extract session and artifact delta calculations for rewind (_compute_state_delta_for_rewind, _compute_artifact_delta_for_rewind, and rewind_async core execution) from Runner into a dedicated private helper module src/google/adk/sessions/_rewind_utils.py. Preserve Runner methods as forwarding shims maintaining 100% backwards compatibility. Add unit tests for _rewind_utils. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 973675673
1 parent fe32193 commit bed446c

4 files changed

Lines changed: 406 additions & 126 deletions

File tree

src/google/adk/runners.py

Lines changed: 20 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,146 +1238,40 @@ async def rewind_async(
12381238
session_id=session_id,
12391239
get_session_config=run_config.get_session_config,
12401240
)
1241-
rewind_event_index = -1
1242-
for i, event in enumerate(session.events):
1243-
if event.invocation_id == rewind_before_invocation_id:
1244-
rewind_event_index = i
1245-
break
1246-
1247-
if rewind_event_index == -1:
1248-
raise ValueError(
1249-
f'Invocation ID not found: {rewind_before_invocation_id}'
1250-
)
1241+
from .sessions import _rewind_utils
12511242

1252-
# Compute state delta to reverse changes
1253-
state_delta = await self._compute_state_delta_for_rewind(
1254-
session, rewind_event_index
1255-
)
1256-
1257-
# Compute artifact delta to reverse changes
1258-
artifact_delta = await self._compute_artifact_delta_for_rewind(
1259-
session, rewind_event_index
1260-
)
1261-
1262-
# Create rewind event
1263-
rewind_event = Event(
1264-
invocation_id=new_invocation_context_id(),
1265-
author='user',
1266-
actions=EventActions(
1267-
rewind_before_invocation_id=rewind_before_invocation_id,
1268-
state_delta=state_delta,
1269-
artifact_delta=artifact_delta,
1270-
),
1243+
await _rewind_utils.rewind_session(
1244+
session_service=self.session_service,
1245+
session=session,
1246+
rewind_before_invocation_id=rewind_before_invocation_id,
1247+
artifact_service=self.artifact_service,
1248+
app_name=self.app_name,
1249+
compute_state_delta=self._compute_state_delta_for_rewind,
1250+
compute_artifact_delta=self._compute_artifact_delta_for_rewind,
12711251
)
12721252

1273-
logger.info('Rewinding session to invocation: %s', rewind_event)
1274-
1275-
await self.session_service.append_event(session=session, event=rewind_event)
1276-
12771253
async def _compute_state_delta_for_rewind(
12781254
self, session: Session, rewind_event_index: int
12791255
) -> dict[str, Any]:
12801256
"""Computes the state delta to reverse changes."""
1281-
state_at_rewind_point: dict[str, Any] = {}
1282-
for i in range(rewind_event_index):
1283-
if session.events[i].actions.state_delta:
1284-
for k, v in session.events[i].actions.state_delta.items():
1285-
if k.startswith('app:') or k.startswith('user:'):
1286-
continue
1287-
if v is None:
1288-
state_at_rewind_point.pop(k, None)
1289-
else:
1290-
state_at_rewind_point[k] = v
1291-
1292-
current_state = session.state
1293-
rewind_state_delta = {}
1257+
from .sessions import _rewind_utils
12941258

1295-
# 1. Add/update keys in rewind_state_delta to match state_at_rewind_point.
1296-
for key, value_at_rewind in state_at_rewind_point.items():
1297-
if key not in current_state or current_state[key] != value_at_rewind:
1298-
rewind_state_delta[key] = value_at_rewind
1299-
1300-
# 2. Set keys to None in rewind_state_delta if they are in current_state
1301-
# but not in state_at_rewind_point. These keys were added after the
1302-
# rewind point and need to be removed.
1303-
for key in current_state:
1304-
if key.startswith('app:') or key.startswith('user:'):
1305-
continue
1306-
if key not in state_at_rewind_point:
1307-
rewind_state_delta[key] = None
1308-
1309-
return rewind_state_delta
1259+
return await _rewind_utils.compute_state_delta_for_rewind(
1260+
session, rewind_event_index
1261+
)
13101262

13111263
async def _compute_artifact_delta_for_rewind(
13121264
self, session: Session, rewind_event_index: int
13131265
) -> dict[str, int]:
13141266
"""Computes the artifact delta to reverse changes."""
1315-
if not self.artifact_service:
1316-
return {}
1317-
1318-
versions_at_rewind_point: dict[str, int] = {}
1319-
for i in range(rewind_event_index):
1320-
event = session.events[i]
1321-
if event.actions.artifact_delta:
1322-
versions_at_rewind_point.update(event.actions.artifact_delta)
1323-
1324-
current_versions: dict[str, int] = {}
1325-
for event in session.events:
1326-
if event.actions.artifact_delta:
1327-
current_versions.update(event.actions.artifact_delta)
1328-
1329-
rewind_artifact_delta = {}
1330-
for filename, vn in current_versions.items():
1331-
if filename.startswith('user:'):
1332-
# User artifacts are not restored on rewind.
1333-
continue
1334-
vt = versions_at_rewind_point.get(filename)
1335-
if vt == vn:
1336-
continue
1267+
from .sessions import _rewind_utils
13371268

1338-
rewind_artifact_delta[filename] = vn + 1
1339-
artifact: types.Part
1340-
if vt is None:
1341-
# Artifact did not exist at rewind point. Mark it as inaccessible.
1342-
artifact = types.Part(
1343-
inline_data=types.Blob(
1344-
mime_type='application/octet-stream', data=b''
1345-
)
1346-
)
1347-
else:
1348-
# Artifact version changed after rewind point. Restore to version at
1349-
# rewind point by loading the actual data via the artifact service.
1350-
loaded_artifact = await self.artifact_service.load_artifact(
1351-
app_name=self.app_name,
1352-
user_id=session.user_id,
1353-
session_id=session.id,
1354-
filename=filename,
1355-
version=vt,
1356-
)
1357-
if loaded_artifact is None:
1358-
logger.warning(
1359-
'Artifact %s version %d not found during rewind for'
1360-
' session %s. Replacing with empty data.',
1361-
filename,
1362-
vt,
1363-
session.id,
1364-
)
1365-
artifact = types.Part(
1366-
inline_data=types.Blob(
1367-
mime_type='application/octet-stream', data=b''
1368-
)
1369-
)
1370-
else:
1371-
artifact = loaded_artifact
1372-
await self.artifact_service.save_artifact(
1373-
app_name=self.app_name,
1374-
user_id=session.user_id,
1375-
session_id=session.id,
1376-
filename=filename,
1377-
artifact=artifact,
1378-
)
1379-
1380-
return rewind_artifact_delta
1269+
return await _rewind_utils.compute_artifact_delta_for_rewind(
1270+
session,
1271+
rewind_event_index,
1272+
artifact_service=self.artifact_service,
1273+
app_name=self.app_name,
1274+
)
13811275

13821276
def _should_append_event(self, event: Event, is_live_call: bool) -> bool:
13831277
"""Checks if an event should be appended to the session."""
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Private helper module for session and artifact rewind in ADK."""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
from typing import Any
21+
from typing import Awaitable
22+
from typing import Callable
23+
from typing import Optional
24+
from typing import TYPE_CHECKING
25+
26+
from google.genai import types
27+
28+
from ..events.event import Event
29+
from ..events.event_actions import EventActions
30+
from ..platform import uuid as platform_uuid
31+
from ..sessions.base_session_service import BaseSessionService
32+
from ..sessions.session import Session
33+
34+
if TYPE_CHECKING:
35+
from ..artifacts.base_artifact_service import BaseArtifactService
36+
37+
logger = logging.getLogger("google_adk." + __name__)
38+
39+
40+
async def compute_state_delta_for_rewind(
41+
session: Session, rewind_event_index: int
42+
) -> dict[str, Any]:
43+
"""Computes the state delta to reverse changes."""
44+
state_at_rewind_point: dict[str, Any] = {}
45+
for i in range(rewind_event_index):
46+
if session.events[i].actions.state_delta:
47+
for k, v in session.events[i].actions.state_delta.items():
48+
if k.startswith("app:") or k.startswith("user:"):
49+
continue
50+
if v is None:
51+
state_at_rewind_point.pop(k, None)
52+
else:
53+
state_at_rewind_point[k] = v
54+
55+
current_state = session.state
56+
rewind_state_delta = {}
57+
58+
# 1. Add/update keys in rewind_state_delta to match state_at_rewind_point.
59+
for key, value_at_rewind in state_at_rewind_point.items():
60+
if key not in current_state or current_state[key] != value_at_rewind:
61+
rewind_state_delta[key] = value_at_rewind
62+
63+
# 2. Set keys to None in rewind_state_delta if they are in current_state
64+
# but not in state_at_rewind_point. These keys were added after the
65+
# rewind point and need to be removed.
66+
for key in current_state:
67+
if key.startswith("app:") or key.startswith("user:"):
68+
continue
69+
if key not in state_at_rewind_point:
70+
rewind_state_delta[key] = None
71+
72+
return rewind_state_delta
73+
74+
75+
async def compute_artifact_delta_for_rewind(
76+
session: Session,
77+
rewind_event_index: int,
78+
*,
79+
artifact_service: Optional[BaseArtifactService] = None,
80+
app_name: Optional[str] = None,
81+
) -> dict[str, int]:
82+
"""Computes the artifact delta to reverse changes."""
83+
if not artifact_service:
84+
return {}
85+
86+
versions_at_rewind_point: dict[str, int] = {}
87+
for i in range(rewind_event_index):
88+
event = session.events[i]
89+
if event.actions.artifact_delta:
90+
versions_at_rewind_point.update(event.actions.artifact_delta)
91+
92+
current_versions: dict[str, int] = {}
93+
for event in session.events:
94+
if event.actions.artifact_delta:
95+
current_versions.update(event.actions.artifact_delta)
96+
97+
rewind_artifact_delta = {}
98+
for filename, vn in current_versions.items():
99+
if filename.startswith("user:"):
100+
# User artifacts are not restored on rewind.
101+
continue
102+
vt = versions_at_rewind_point.get(filename)
103+
if vt == vn:
104+
continue
105+
106+
rewind_artifact_delta[filename] = vn + 1
107+
artifact: types.Part
108+
if vt is None:
109+
# Artifact did not exist at rewind point. Mark it as inaccessible.
110+
artifact = types.Part(
111+
inline_data=types.Blob(mime_type="application/octet-stream", data=b"")
112+
)
113+
else:
114+
# Artifact version changed after rewind point. Restore to version at
115+
# rewind point by loading the actual data via the artifact service.
116+
loaded_artifact = await artifact_service.load_artifact(
117+
app_name=app_name,
118+
user_id=session.user_id,
119+
session_id=session.id,
120+
filename=filename,
121+
version=vt,
122+
)
123+
if loaded_artifact is None:
124+
logger.warning(
125+
"Artifact %s version %d not found during rewind for"
126+
" session %s. Replacing with empty data.",
127+
filename,
128+
vt,
129+
session.id,
130+
)
131+
artifact = types.Part(
132+
inline_data=types.Blob(
133+
mime_type="application/octet-stream", data=b""
134+
)
135+
)
136+
else:
137+
artifact = loaded_artifact
138+
await artifact_service.save_artifact(
139+
app_name=app_name,
140+
user_id=session.user_id,
141+
session_id=session.id,
142+
filename=filename,
143+
artifact=artifact,
144+
)
145+
146+
return rewind_artifact_delta
147+
148+
149+
async def rewind_session(
150+
*,
151+
session_service: BaseSessionService,
152+
session: Session,
153+
rewind_before_invocation_id: str,
154+
artifact_service: Optional[BaseArtifactService] = None,
155+
app_name: Optional[str] = None,
156+
compute_state_delta: Optional[
157+
Callable[[Session, int], Awaitable[dict[str, Any]]]
158+
] = None,
159+
compute_artifact_delta: Optional[
160+
Callable[[Session, int], Awaitable[dict[str, int]]]
161+
] = None,
162+
) -> None:
163+
"""Rewinds the session to before the specified invocation."""
164+
rewind_event_index = -1
165+
for i, event in enumerate(session.events):
166+
if event.invocation_id == rewind_before_invocation_id:
167+
rewind_event_index = i
168+
break
169+
170+
if rewind_event_index == -1:
171+
raise ValueError(f"Invocation ID not found: {rewind_before_invocation_id}")
172+
173+
# Compute state delta to reverse changes
174+
if compute_state_delta is not None:
175+
state_delta = await compute_state_delta(session, rewind_event_index)
176+
else:
177+
state_delta = await compute_state_delta_for_rewind(
178+
session, rewind_event_index
179+
)
180+
181+
# Compute artifact delta to reverse changes
182+
if compute_artifact_delta is not None:
183+
artifact_delta = await compute_artifact_delta(session, rewind_event_index)
184+
else:
185+
artifact_delta = await compute_artifact_delta_for_rewind(
186+
session,
187+
rewind_event_index,
188+
artifact_service=artifact_service,
189+
app_name=app_name,
190+
)
191+
192+
# Create rewind event
193+
rewind_event = Event(
194+
invocation_id=f"e-{platform_uuid.new_uuid()}",
195+
author="user",
196+
actions=EventActions(
197+
rewind_before_invocation_id=rewind_before_invocation_id,
198+
state_delta=state_delta,
199+
artifact_delta=artifact_delta,
200+
),
201+
)
202+
203+
logger.info("Rewinding session to invocation: %s", rewind_event)
204+
await session_service.append_event(session=session, event=rewind_event)

0 commit comments

Comments
 (0)