Fix: keep temp: state readable for the whole invocation in DatabaseSessionService (stacked on #132) - #607
Open
AmaadMartin wants to merge 4 commits into
Conversation
added 4 commits
August 3, 2026 19:41
…ice appends DatabaseSessionService.appendEvent rebuilds session.state from persisted rows and reassigns it, which dropped every temp: key already on the in-memory session - including ones written earlier in the same invocation via State.set. Capture the temp entries before the transaction (so the stale-session reload path is covered too) and merge them back over the rebuilt state. Applying temp state before the trim also removes the need to clone the event: the caller's event is trimmed in place and returned, matching BaseSessionService and adk-python.
The event delta is trimmed of temp: keys before updateSessionState runs, so the skip can never fire. Removing it mirrors adk-python's _update_session_state. Behaviour is unchanged; the added test pins the partial-event short circuit that guards the new ordering.
Reproduces the user-visible symptom in TypeScript: a first LlmAgent with
outputKey 'temp:out' followed by a second whose instruction interpolates
{temp:out}. Without the fix the second agent throws
"Context variable not found: `temp:out`".
…RM cast
Complexity review follow-ups:
- revert the no-op event -> trimmedEvent rename in the transaction tail;
trimTempDeltaState mutates and returns the same object, so the rename
only added churn.
- replace the two-line `!actions || !stateDelta` guards in
applyTempState and updateSessionState with
`event.actions?.stateDelta ?? {}`. This drops the branch the tests had
to cast their way into while keeping the runtime tolerance the
VertexAiSessionService 'event without actions' path needs.
- feed the applyTempState no-op test a reachable empty stateDelta
instead of casting away the required actions field.
- drop the redundant Session cast on the new Vertex fixture and hoist
the private-ORM cast in the database tests into one `ormOf` helper
used by all five call sites.
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Link to an existing issue (if applicable):
Related: Output key doesn't work with temp state scope google/adk-python#4564
Related: fix: temp-scoped state now visible to subsequent agents in same invocation google/adk-python#4618
Or, if no issue exists, describe the change:
Problem (what #132 leaves unfixed):
DatabaseSessionService.appendEventrebuildssession.statefrom persisted rows andreassigns it (
session.state = newMergedState). Persisted rows never holdtemp:keys, sothat assignment wipes every
temp:key already on the in-memory session. Fix: Apply temp: state from runAsync stateDelta during the invocation (#260) #132 re-applies thecurrent event's temp delta after the merge, which fixes a single append but not the
invocation:
temp:key written by event 1 is gone after an unrelated event 2 is appended;temp:key written bytoolContext.state.set('temp:x', …)(which mutatessession.statedirectly) is gone after any append;session.statetoo, with the same effect.trims a
cloneDeep(event)and returns the clone. That diverges fromBaseSessionServiceandfrom adk-python: the caller's
event.actions.stateDeltakeeps itstemp:keys, and the objectin
session.eventsis no longer the object the caller passed in.BaseSessionService.updateSessionStatestill skipsState.TEMP_PREFIX. Once the delta istrimmed first that branch is unreachable, and adk-python removed the equivalent skip in
_update_session_state.SequentialAgentwhose first agent has
outputKey: 'temp:out'and whose second interpolates{temp:out}), andnothing exercises the
event.partialshort circuit that guards the new ordering.Solution:
core/src/sessions/database_session_service.ts— callapplyTempState(session, event)beforethe trim (the ordering
BaseSessionServiceand adk-python'sappend_eventboth use), snapshotthe
temp:-prefixed entries ofsession.statebefore the transaction, and restore them withsession.state = {...newMergedState, ...tempState}. Snapshotting before the transaction is whatmakes the
State.setpath and the stale-session reload path work: both replacesession.stateinside the transaction. Because temp is applied before the trim, the
cloneDeepis no longerneeded — the event is trimmed in place and returned, as on every other backend.
core/src/sessions/base_session_service.ts— delete the now-deadTEMP_PREFIXskip inupdateSessionState. Behaviour-neutral:appendEventtrims the delta before calling it, sothe branch could never fire. Verified by re-adding the skip and re-running the suites — 185/185
still passed, which is exactly why there is no test for this hunk. Both
updateSessionStateandapplyTempStatenow iterateObject.entries(event.actions?.stateDelta ?? {})instead of guardingwith a two-line
if (!event.actions || !event.actions.stateDelta) return;. That is not cosmetic:Event.actionsandEventActions.stateDeltaare both required types, so the old guard was onlyreachable by casting, and the cast is what the
applyTempStateno-op test used to do. Theoptional chain keeps the runtime tolerance an existing test proves is needed (see below) without
a branch the type system says cannot happen.
Nothing new is persisted.
trimTempDeltaStatestill runs before any write to storage, and thetemp:-freegetSessionresult is asserted in every new backend test.Parity note (which side wins): adk-python wins on observable semantics — ordering
(
_apply_temp_state→_trim_temp_delta_state→_update_session_state), the in-place trim, andreturning the caller's event. The
session.statereassignment in the JS database backend is alocal structural difference that this PR deliberately preserves (see
src/google/adk/sessions/database_session_service.py:741-750, which mutatessession.stateviasuper().append_eventand therefore needs no snapshot); only its temp-dropping side effect isfixed.
API surface: unchanged by this PR.
applyTempStateis exported by #132 (and is public throughcore/src/common.ts'sexport * from './sessions/base_session_service.js'); this PR adds no newexport and does not touch
core/src/index.ts. A future Firestore session service (#466) inheritsthe fixed behaviour provided it either calls
super.appendEventor callsapplyTempStateitself— and, if it reassigns
session.statethe way the database backend does, it needs the samesnapshot.
InMemorySessionServiceandVertexAiSessionServiceneed no source change: both delegate tosuper.appendEvent, which trims the delta in place before the storage-side append sees it. Pinnedby tests rather than asserted in a comment.
Suppressions: net −7. This PR removes 8
as unknown ascasts and adds 1. Removed: the two inthe
applyTempStateno-op test (which existed only to fabricate anEventwithoutactions, anEventthe type system forbids — the test now feeds a reachable emptystateDeltaand asserts thesame thing); the redundant
as unknown as Sessionon the new Vertex fixture, whose literal alreadysupplies all six
Sessionfields; and five copies of(service as unknown as {orm: MikroORM}).orm, replaced by a single module-levelormOf(service)helper used at all five call sites. Also reverted a no-opevent→trimmedEventrename in the database transaction tail:trimTempDeltaStatemutates and returnsthe same object, so the rename changed nothing at runtime while implying an untrimmed
eventstill existed.
Two review suggestions were not taken, with reasons:
updateSessionStatetoObject.assign(session.state, event.actions?.stateDelta ?? {}).Not equivalent:
Object.assign(undefined, {})throws where the copy loop was a no-op, and fivepre-existing
VertexAiSessionServiceappendEventtests passSessionfixtures with nostatefield. Applying it fails all five with
Cannot convert undefined or null to object. Taking itwould mean editing five existing tests in the same change that touches the code they cover, to
buy two lines and a narrower runtime contract. The nullish-coalescing rewrite above captures the
line saving with zero behavioural delta.
as unknown as Sessioncasts invertex_ai_session_service_test.ts(L936/988/1025/1057/1082) as a "one-line-each freebie".Those five are load-bearing, not redundant: every one of those literals omits
state(and twoalso omit
lastUpdateTime), so removing the cast is a compile error. Only the new L1108 literalis complete, and that is the one this PR fixes.
Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Added (no existing test was weakened, skipped or deleted; the two edits are the cast removals
described above, which keep every assertion):
core/test/sessions/database_session_service_test.ts(5 new cases)temp:state from an earlier event readable when a later event is appended;temp:state written straight ontosession.state(theState.setpath) across an append;temp:state when the append reloads a stale session.core/test/sessions/in_memory_session_service_test.ts(1 new case) — partial event short circuit.core/test/runner/temp_state_visibility_test.ts(new file, 1 case) —Runner+SequentialAgent+ twoLlmAgents overInMemorySessionServicewith a localBaseLlmdouble:agent 1 writes
outputKey: 'temp:out', agent 2's instruction interpolates{temp:out}. Assertsagent 2's captured system instruction contains agent 1's output, and that
getSessionafterwardsreturns state with no
temp:-prefixed key. It is a unit test driving fakes, so it lives incore/test/, nottests/e2e/.CI is
absentfor this PR, by construction..github/workflows/validation.yamlis gated onpull_request: branches: [main], so itsrun-testsjob never fires for a stacked PR whose base isfix/apply-temp-state-statedelta; onlyauto-assignruns, and that is not validation. Everythingbelow was therefore run locally on the exact pushed commit
(
e2a00d25b319731af60f7de46f755d344ccf3154).Commands run (on the pushed commit):
Proving the tests can fail. Each new test was run against the unfixed code:
database_session_service.tsto the #132 versionexpected undefined to be 'from-agent-1',expected undefined to be 'written-by-a-tool', and (for the in-place case)expected {…} to be {…} // Object.is equalityapplyTempState(session, event)fromBaseSessionService.appendEventError: Context variable not found: `temp:out`.— thrown fromcore/src/agents/instructions.ts:54, i.e. the exact symptom of google/adk-python#4564if (event.partial) return event;guard from both servicesexpected { 'temp:k1': 'v1', sk: 'v2' } to not have property "temp:k1"TEMP_PREFIXskip toupdateSessionState?? {}fallback inapplyTempState(i.e.event.actions.stateDelta)handles event without actions in appendEventCannot read properties of undefined (reading 'stateDelta')— proves the optional chain is load-bearing, not decorationCoverage. Every line and branch added by this PR is covered. Measured with
--coverage.includelimited to the two changed source files overcore/test/sessions/ core/test/runner/: the uncovered statements/branches that remain(
base_session_service.ts130-142, 182;database_session_service.ts75-96, 240-241, 305-343,410-430) are all pre-existing code this PR does not touch —
getOrCreateSession, URI parsing, andthe event-dedup replace branch. The
event.partialguards (previously uncovered in both services)are now covered by the two new partial cases, and both sides of the new
event.actions?.stateDeltachain are covered (the absent-
actionsside by the pre-existing Vertex test above).Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Reproduces google/adk-python#4564 against a real model:
Run one turn and confirm (a)
a2does not throwContext variable not found: `temp:out`,(b)
a2's prompt containsa1's output, and (c)getSession(...)afterwards returns a sessionwhose
statehas notemp:key. Repeat withnew DatabaseSessionService({dbName: 'sqlite://./tmp.db'})and confirm thetemp:value is stillreadable after a second agent appends its own event.
Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.