Skip to content

fix(workflow): keep session identity across single-turn node contexts - #6697

Open
chelsealong wants to merge 1 commit into
google:mainfrom
chelsealong:fix-6691-workflow-compaction-stale-session
Open

fix(workflow): keep session identity across single-turn node contexts#6697
chelsealong wants to merge 1 commit into
google:mainfrom
chelsealong:fix-6691-workflow-compaction-stale-session

Conversation

@chelsealong

Copy link
Copy Markdown
Contributor

Fixes #6691

Summary

When a Workflow's root agent runs multiple single-turn LlmAgent nodes
and EventsCompactionConfig.token_threshold compaction fires mid-invocation
(not on the last node), a DatabaseSessionService-backed session breaks with:

ValueError: The session has been modified in storage since it was loaded.
Please reload the session before appending more events.

InMemorySessionService does not reproduce this, because it never checks a
storage revision marker.

Root cause

prepare_llm_agent_context() in src/google/adk/workflow/_llm_agent_wrapper.py
gave every single-turn LlmAgent node its own InvocationContext, and
additionally replaced that context's session with
ic.session.model_copy(deep=False).

Session.events and Session.state are mutable containers, so the shallow
copy shared them by reference with the parent session — but
Session.last_update_time and the private storage-revision marker used by
DatabaseSessionService for stale-write detection are scalar/private
attributes, so the copy duplicated them by value.

When such a node's request-time token-threshold compaction runs
(CompactionRequestProcessor), it appends the compaction event through that
node's own session copy, advancing the revision marker on the copy only. The
parent context's session — the one later nodes copy from, and the one the
Runner's own event-consumption loop appends to — never learns about that
advance. The next append made through that stale copy is rejected by
DatabaseSessionService.append_event as a stale write.

Fix

Drop the ic.session = ic.session.model_copy(deep=False) line. Since
events/state were already shared by reference, this copy provided no
real isolation — it only caused the marker/timestamp to diverge. Every node
in the same Workflow invocation now shares the exact same Session object,
so a revision-marker advance from one node's compaction write is visible to
every other node.

Testing plan

Added test_mid_workflow_compaction_does_not_stale_later_node_append to
tests/unittests/apps/test_compaction_runner_e2e.py. It runs a two-node
Workflow (agent1 -> agent2, both single-turn) through a real
Runner.run_async against a DatabaseSessionService (sqlite), across two
turns: a short turn (no compaction), then a long turn whose estimated prompt
token count exceeds token_threshold, triggering mid-invocation compaction
on agent1 before agent2 runs. It asserts agent2's response is produced
and persisted to storage.

  • Confirmed the test fails on the unmodified source with the exact reported
    error (ValueError: The session has been modified in storage since it was loaded...), by temporarily reverting only _llm_agent_wrapper.py and
    re-running.
  • Confirmed the test passes with the fix applied.
$ pytest tests/unittests/apps/test_compaction_runner_e2e.py -q
...                                                                      [100%]
3 passed, 1 warning in 3.17s

$ pytest tests/unittests/workflow tests/unittests/apps tests/unittests/sessions tests/unittests/flows/llm_flows -q
1668 passed, 11 skipped, 12 xfailed, 121 warnings in 29.72s

$ pytest tests/unittests -q
11449 passed, 87 skipped, 25 xfailed, 1 xpassed, 2964 warnings, 19 subtests passed in 346.91s

pre-commit run (isort, pyink, ruff, addlicense, ADK compliance checks) passes
on both changed files.

AI-assistance disclosure

This PR was prepared with the help of an AI coding agent (Claude Code), which
investigated the root cause, implemented the fix, and wrote/verified the
regression test. I reviewed and take responsibility for the change.

Fixes google#6691

prepare_llm_agent_context() replaced a single-turn LlmAgent node's
InvocationContext.session with a shallow copy. Session.events and
Session.state are mutable containers so the copy shared them by
reference, but Session.last_update_time and the private storage
revision marker were duplicated by value.

When such a node triggers mid-invocation token-threshold compaction,
the compaction event is appended through that per-node session copy,
advancing the marker there but not on the parent/session object other
nodes derive their own copies from. DatabaseSessionService then rejects
the next append made through the stale parent copy with "The session
has been modified in storage since it was loaded."

Dropping the copy keeps every node's InvocationContext pointing at the
same Session object, so the revision marker stays consistent for the
rest of the Workflow.
@adk-bot adk-bot added the core [Component] This issue is related to the core interface and implementation label Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core [Component] This issue is related to the core interface and implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Context Compaction breaks Session with Workflow + DatabaseSessionService When Compaction is not on Last Agent

3 participants