Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(adk): cache session reads per execution#1890

Merged
contextablemark merged 6 commits into
ag-ui-protocol:mainfrom
he-yufeng:fix/adk-session-read-cache
Jun 11, 2026
Merged

fix(adk): cache session reads per execution#1890
contextablemark merged 6 commits into
ag-ui-protocol:mainfrom
he-yufeng:fix/adk-session-read-cache

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Summary

Fixes #1880.

The ADK middleware was fetching the same ADK session repeatedly during a single execution. With remote session services, each get_session() can pull the full session and event history, so repeated state reads add a lot of latency before the agent runs.

This adds a short-lived, execution-local read cache in SessionManager:

  • cache is enabled only around one _start_new_execution() call
  • repeated reads of the same (session_id, app_name, user_id) reuse the fetched session
  • cache entries are invalidated after state writes and after direct FunctionResponse appends
  • created/found sessions are seeded into the current execution cache

It deliberately avoids a long-lived instance cache, because ADK's runner and external session services can mutate session storage outside SessionManager.

Validation

  • PYTHONUTF8=1 PYTHONIOENCODING=utf-8 uv run pytest tests/test_session_memory.py -q
  • PYTHONUTF8=1 PYTHONIOENCODING=utf-8 uv run pytest tests/test_pending_tool_calls_gating.py tests/test_multi_instance_hitl.py -k "backend_tool_with_database_session_service or hitl_client_tool_with_database_session_service or independent_caches_shared_session_service" -q
  • python -m py_compile src\ag_ui_adk\session_manager.py src\ag_ui_adk\adk_agent.py tests\test_session_memory.py
  • git diff --check

I also tried the existing target-file format/static checks:

  • uv run black --check src\ag_ui_adk\session_manager.py src\ag_ui_adk\adk_agent.py tests\test_session_memory.py
  • uv run flake8 src\ag_ui_adk\session_manager.py src\ag_ui_adk\adk_agent.py tests\test_session_memory.py

Those fail on current-file historical formatting/lint noise across the existing files, so I did not run a whole-file formatter in this focused fix.

@vercel

vercel Bot commented Jun 7, 2026

Copy link
Copy Markdown

@he-yufeng is attempting to deploy a commit to the CopilotKit Team on Vercel.

A member of the Team first needs to authorize it.

@contextablemark contextablemark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the redundant get_session fan-out in #1880 is real and the per-execution ContextVar design (isolated per concurrent run, seeded on create/find) is a nice way to collapse it. Before merging, though, I think the cache currently outlives the window it's safe in, and serves stale data on the normal-turn path.

The problem: the ADK runner writes to the session service without going through SessionManager.

_create_runner builds the Runner with session_service=self._session_manager._session_service (adk_agent.py L1104) — the same service the cache wraps. When runner.run_async (L2626) runs the agent, ADK persists the LLM responses, tool calls, and state deltas via session_service.append_event(...) directly. Those writes never touch SessionManager, so invalidate_session never fires for them — but the cache (started at L1796 and torn down only in _start_new_execution's finally) stays open across the entire runner execution.

On a plain user-message turn that means:

  1. L2238 update_session_state → invalidates
  2. L2243 get_sessionre-seeds the cache with the pre-run session
  3. L2626 runner mutates the store directly — no invalidation
  4. L2877 get_session_state → cache hit → stale → feeds STATE_SNAPSHOT
  5. L2912 get_session → cache hit → stale → feeds MESSAGES_SNAPSHOT (right under the # Refresh session to get latest events comment — the cache defeats the refresh that line exists to do)

So the run's STATE_SNAPSHOT drops state the agent set, and MESSAGES_SNAPSHOT is missing the agent's reply. It's worst on long / subsequent turns — the exact case the PR is optimizing. The HITL tool-result branches happen to dodge it (their post-append invalidate_session leaves the cache empty before the runner), but the common path doesn't. The existing integration tests that would catch this need a live LLM and are currently red on main for unrelated reasons (decommissioned gemini-2.0-flash), so the unit tests passing doesn't rule it out.

Minimal repro against a real InMemorySessionService, simulating the runner's direct append_event inside the cache window:

token = mgr.start_session_read_cache()
pre = await mgr.get_session_state(sid, app, uid)            # seeds cache
sess = await svc.get_session(app_name=app, user_id=uid, session_id=sid)
await svc.append_event(sess, Event(author="agent", invocation_id="i",
    content=types.Content(role="model", parts=[types.Part(text="hi")]),
    actions=EventActions(state_delta={"counter": 99})))     # runner-style write
final = await mgr.get_session_state(sid, app, uid)           # cache hit
mgr.stop_session_read_cache(token)
# final["counter"] == 0, ground truth == 99   -> stale
# cached session.events == 0, ground truth == 1 -> stale MESSAGES_SNAPSHOT

Suggested fix: scope the cache to the pre-run phase only. The issue notes the redundancy is "all before the agent runs", so the cache never needs to span the runner. Tear it down right before the runner.run_async loop (L2626) so every post-run read goes back to the live service. That keeps the full win (the pre-run get_session/get_state_value/_has_pending_tool_calls reads still collapse to one fetch) without ever serving a session the runner has since mutated.

One implementation note: the teardown has to happen inside _run_adk_in_background (that's where the runner lives), not in _start_new_execution. Since the background task is spawned via create_task (L2127) it holds its own copy of the context, and ContextVar.reset(token) is bound to the context that created the token — so reuse won't work cleanly here. Simplest is a tokenless teardown that just disables caching for the rest of the bg task, e.g.:

def disable_session_read_cache(self) -> None:
    """Disable the per-execution read cache for the remainder of this context."""
    _SESSION_READ_CACHE.set(None)

called immediately before the runner loop. The parent's existing stop_session_read_cache(token) in the finally then remains a harmless reset in its own context.

(If you'd rather keep the cache live across the run, the alternative is to invalidate_session(backend_session_id, …) right after the runner — but that's more fragile, since it relies on enumerating every out-of-band writer. Scoping to pre-run is the lower-risk option.)

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Addressed the stale post-run cache path in 8352e3e. The session manager now has a tokenless disable operation, and the background execution disables the per-execution read cache immediately before runner.run_async. This keeps the pre-run deduplication while forcing state/message snapshots after the runner to read the live session service. Added a focused disable-cache regression test; test_session_memory.py: 39 passed on Windows.

@contextablemark contextablemark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@he-yufeng Thanks! Scoping the cache to pre-run with disable_session_read_cache() before runner.run_async fixes the STATE_SNAPSHOT / MESSAGES_SNAPSHOT staleness in the background task. I ran the live suite (real Gemini on gemini-3.5-flash, plus the live Vertex Agent Engine session tests) and that path is solid now.

We're getting real close now if you wouldn't mind bearing with me - there's one more spot the same root cause reaches, though, and I was able to reproduce it live.

disable_session_read_cache() only disables the cache in the background task's context : _start_new_execution (the parent) keeps its own live cache, and it does a post-run read.

_run_adk_in_background is spawned via asyncio.create_task (adk_agent.py L2127), so it runs with a copy of the parent context. The disable call at L2626 sets the ContextVar to None in that copy only. The parent's ContextVar still points at the seeded cache dict. And the parent's finally does a post-run read that the runner's out-of-band writes have invalidated: _has_pending_tool_calls at L1897, the HITL guard that decides whether to evict the execution from _active_executions.

So on a HITL turn, the pending-tool-call write lands in the store during the run, but the parent's guard reads the stale pre-run cache and sees no pending calls — and deletes the execution that the guard exists specifically to preserve.

Reproduced live (real Gemini + DatabaseSessionService, resumable app, single HITL turn), instrumenting the parent's cleanup read vs. the store:

tool calls emitted     : ['klt5ridb']
cache active in parent : True
parent cleanup saw     : False    <- stale, cache-served
store ground truth     : True     <- pending call IS persisted

The new test_hitl_client_tool_live_llm_with_database_session_service doesn't catch this because it asserts against session_service.get_session(...) directly (the store), not the in-memory _active_executions cleanup decision.

This isn't specific to DatabaseSessionService — it applies to any backend the runner writes through without going via SessionManager, including the VertexAiSessionService case from #1880.

Suggested fix: drop the parent's cache before that post-run read. Since the parent owns the token, the simplest is to disable at the top of the finally:

finally:
    try:
        # The runner mutated the session outside SessionManager, so the
        # pre-run read cache in THIS (parent) context is now stale. Drop it
        # before the post-run pending-tool-calls read below, otherwise the
        # HITL cleanup guard reads a stale "no pending calls".
        self._session_manager.disable_session_read_cache()
        # Clean up execution if complete and no pending tool calls (HITL scenarios)
        async with self._execution_lock:
            ...
            has_pending = await self._has_pending_tool_calls(input.thread_id, user_id)
            ...
    finally:
        self._session_manager.stop_session_read_cache(session_cache_token)

(Reordering so stop_session_read_cache(session_cache_token) runs before _has_pending_tool_calls would also work, since reset returns the var to its None default.)

With this change the re-probe shows parent cleanup saw: True (agrees with the store), and the HITL / session-memory / resumability suites stay green (69 passed). Worth a small regression test that asserts _active_executions still contains the entry after a HITL turn with a pending tool call — the existing tests only check store persistence, so this gap would otherwise stay invisible.

Again, thanks for your work on this one - it's turned out to be gnarlier than most - but I'm hyper-vigilant on anything dealing with tool calls and session state because that's where so many regressions pop up.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Addressed this in 8562c4d. The parent _start_new_execution cleanup now disables its session read cache before the post-run pending-tool-call check, so the HITL cleanup guard does not reuse the pre-run cache after runner-side session writes. I also added a focused regression test that makes the active-execution retention depend on the parent cleanup dropping that cache before _has_pending_tool_calls runs. Local verification: python -m py_compile integrations/adk-middleware/python/src/ag_ui_adk/adk_agent.py integrations/adk-middleware/python/tests/test_tool_tracking_hitl.py; PYTHONPATH=integrations/adk-middleware/python/src PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest integrations/adk-middleware/python/tests/test_tool_tracking_hitl.py -q (9 passed); PYTHONPATH=integrations/adk-middleware/python/src PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest integrations/adk-middleware/python/tests/test_session_memory.py -q (39 passed); git diff --check.

@contextablemark contextablemark left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@he-yufeng Looks good! Thank you for your efforts.

@github-actions

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1781159601 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1781159601' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1781159601' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1781159601' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1781159601' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1781159601' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1781159601' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1781159601

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 2df0bb6

@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@1890

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@1890

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@1890

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@1890

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@1890

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@1890

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@1890

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@1890

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@1890

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@1890

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@1890

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@1890

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@1890

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@1890

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@1890

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@1890

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@1890

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@1890

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@1890

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@1890

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@1890

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@1890

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@1890

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@1890

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@1890

commit: 655ade2

@contextablemark contextablemark merged commit 59a541e into ag-ui-protocol:main Jun 11, 2026
38 of 40 checks passed
TheoGermain pushed a commit to TheoGermain/ag-ui that referenced this pull request Jun 11, 2026
Documents the session read-cache optimization from ag-ui-protocol#1890 (fixes ag-ui-protocol#1880)
under Unreleased. Changelog-only; no code changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

adk-middleware: SessionManager re-fetches the full session on every state accessor (no caching) — many redundant get_session round-trips per turn

2 participants