aiml
SearchMy learning
Menu

Lesson 7 of 20Module 2

Keep long sessions in budget with compaction and context caching

Cache the docs assistant's manual with ContextCacheConfig, summarize old turns with EventsCompactionConfig, and measure both in Gemini usage metadata.

Prerequisites

An ADK agent with function tools, run through a Runner and a session service.

By the end, you can

  • Put a large, stable prefix in static_instruction and cache it with ContextCacheConfig on the App.
  • Replace old turns with model-written summaries using a sliding-window EventsCompactionConfig.
  • Read cached and prompt token counts from usage metadata, and explain why each compaction misses the cache once.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b, without caching)
Dependencies
google-adk 2.12.0; litellm 1.102.4 for the Ollama alternative

Lab code, tests and fixtures (ZIP, 43 KB)Quiz check your understanding

Progress is saved in this browser.

A docs assistant that keeps a whole product manual in its prompt repeatedly sends that text alongside a growing conversation. Caching can reduce the charged input tokens, while summarization can reduce the history sent to later requests. Google ADK has two App-level settings for this. Context caching stores a stable prompt prefix, here the docuctl manual, in a Gemini cache so later requests reference it instead of resending it. Event compaction replaces old turns in the session with a model-written summary, so requests use summaries in place of older raw turns. The stored session still retains its raw events, and neither setting enforces a hard token or spending limit. Here both go on the docs assistant, and one debugging session runs with and without them. With them, Gemini gemini-3.8-flash read 23236 of 46946 prompt tokens from a cache; without them, it read 5702 of 54575:

When you need this. A developer debugging a failed release can spend an hour in one session with a docs assistant: pasting errors, asking it to read two or three deploy logs, coming back to earlier answers. Every request repeats the manual and every log read so far. Caching cuts the repeated prefix; compaction cuts the history.

Recorded example. The archived “Keep long sessions in budget with compaction and context caching” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.

Recorded: the session with caching and compactionRecorded output (excerpt) · textFull output
# budgeted: cache=True compaction=True
> 1. Deploy r42 to staging failed. What went wrong, according to its log?
call   read_deploy_log({'release': 'r42'})
model  prompt=4502 cached=0 cache=fingerprint only
model  prompt=5261 cached=4401 cache=active
answer The deploy failed during the `pre_deploy` hook while running `alembic upgrade head`. 
> 2. If I roll staging back, does that undo the migration r42 started?
model  prompt=5389 cached=4401 cache=active
answer No. Running `shipctl rollback` moves traffic back to an earlier release but does not run any hooks, so it does not undo database migrations. 
> 3. We fixed the migration and deployed r43, which failed differently. What happened?
call   read_deploy_log({'release': 'r43'})
model  prompt=4792 cached=0 cache=fingerprint only
model  prompt=7664 cached=4691 cache=active
answer The migration succeeded, but the deploy failed its health check. 
> 4. How long are staging logs kept?
model  prompt=7779 cached=4691 cache=active
answer Logs are kept for 14 days in staging (and 30 days in production). Older lines are deleted, so you must export them to your own store if you need them 
> 5. Which of the two failed deploys, r42 or r43, ran its migration to the end?
model  prompt=5735 cached=0 cache=fingerprint only
answer **r43** ran its migration to the end. 
> 6. What is the command to read the full pre_deploy output of r42?
model  prompt=5824 cached=5052 cache=active
answer The command is:
compaction of turns [1, 2]: 1266 characters
compaction of turns [2, 3, 4]: 1302 characters

Both settings are fields on App.

record.py · two runs of one session on gemini-3.8-flash · recorded in recorded-runs.json
cached tokenstokens sent in fullcache + compactionturn 14502 · 0 cachedturn 15261 · 4401 cachedturn 25389 · 4401 cachedsummary of turns 1–2 replaces themturn 34792 · 0 cachedturn 37664 · 4691 cachedturn 47779 · 4691 cachedsummary of turns 2–4 replaces themturn 55735 · 0 cachedturn 65824 · 5052 cachedneitherturn 14502 · 2469 cachedturn 15261 · 3233 cachedturn 25397 · 0 cachedturn 35490 · 0 cachedturn 38362 · 0 cachedturn 48454 · 0 cachedturn 58508 · 0 cachedturn 68601 · 0 cachedBars share one scale: the longest is 8601 prompt tokens.cached tokenstokens sent in fullcache + compactiont14502/0t15261/4401t25389/4401summary of 1–2t34792/0t37664/4691t47779/4691summary of 2–4t55735/0t65824/5052neithert14502/2469t15261/3233t25397/0t35490/0t38362/0t48454/0t58508/0t68601/0prompt/cached · one scale
The same questions, run with and without the App settings. Each bar is one agent model call: its length is the prompt, and the yellow part is what Gemini read from a cache. With caching, the manual prefix is cached from the second call on; each compaction changes the prefix, so the next call misses and ADK builds a new cache. In this recording, compaction reduced prompt growth by replacing earlier deploy logs with summaries. Without the settings, only the first turn shows cached tokens, from Gemini's own implicit caching. Bars are to scale and come from the recorded agent usage metadata; summarizer calls and cache storage charges are excluded.

Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-context-budget/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:

Prepare the lab environmentTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

1. Put the stable part of the prompt in static_instruction

The folder holds the docuctl manual in docs/ and staging deploy logs in logs/. The code reads GOOGLE_API_KEY; with DOCS_MODEL=ollama_chat/gemma4:e2b it runs on Gemma through LiteLlm, where compaction works but context caching, a Gemini feature, has no effect.

assistant.pySource · pythonDownload
"""The docs assistant with the docuctl manual in a cached prefix and old turns compacted.

The manual goes in static_instruction, so every request starts with the same few thousand tokens
that ContextCacheConfig can cache. Deploy logs come back as large tool results, and
EventsCompactionConfig replaces old turns with a model-written summary.
"""
import asyncio
import os
import sys
from pathlib import Path

from google.adk import Agent, Runner
from google.adk.agents.context_cache_config import ContextCacheConfig
from google.adk.apps.app import App, EventsCompactionConfig
from google.adk.apps.llm_event_summarizer import LlmEventSummarizer
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import InMemorySessionService
from google.genai import types

HERE = Path(__file__).resolve().parent
# Gemini through ADK's native client; set DOCS_MODEL=ollama_chat/gemma4:e2b to run Gemma locally.
# Context caching is a Gemini feature: on Gemma, ContextCacheConfig has no effect.
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
MANUAL = "\n\n".join(p.read_text(encoding="utf-8") for p in sorted((HERE / "docs").glob("*.md")))
RELEASES = sorted(p.stem for p in (HERE / "logs").glob("*.log"))

STATIC = f"""You answer questions from developers about docuctl, the team's deploy CLI.
Answer only from the docuctl manual below and from deploy logs you read with read_deploy_log.
Name the exact command when one exists. If the manual does not cover a question, say so.

<manual>
{MANUAL}
</manual>"""


def read_deploy_log(release: str) -> dict:
    """Read the full staging deploy log of one release, such as r42."""
    if release not in RELEASES:
        return {"error": f"No log for {release!r}. Releases: {', '.join(RELEASES)}."}
    return {"release": release, "log": (HERE / "logs" / f"{release}.log").read_text(encoding="utf-8")}


def model():
    return LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL


root_agent = Agent(
    name="docs_assistant",
    model=model(),
    static_instruction=STATIC,  # sent as the system instruction, identical on every request
    instruction="Keep each answer under 80 words.",  # dynamic part: sent after the cached prefix
    tools=[read_deploy_log],
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)

CACHE = ContextCacheConfig(
    min_tokens=4096,     # skip caching below this many prompt tokens
    ttl_seconds=600,     # each cache lives 10 minutes
    cache_intervals=10,  # and serves at most 10 invocations before it is rebuilt
)


def compaction(summarizer_llm=None) -> EventsCompactionConfig:
    """Summarize every 2 new invocations, overlapping the previous window by 1."""
    summarizer = LlmEventSummarizer(llm=summarizer_llm) if summarizer_llm else None
    return EventsCompactionConfig(compaction_interval=2, overlap_size=1, summarizer=summarizer)


def make_app(agent: Agent = root_agent, cache: bool = True, compact: bool = True,
             summarizer_llm=None) -> App:
    return App(
        name="docs",
        root_agent=agent,
        context_cache_config=CACHE if cache else None,
        events_compaction_config=compaction(summarizer_llm) if compact else None,
    )


def make_runner(app: App) -> Runner:
    return Runner(app=app, session_service=InMemorySessionService())


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="dana", session_id=session_id, new_message=message)]


def usage(event) -> dict | None:
    """Token counts and cache state of the model call behind one event."""
    u, c = event.usage_metadata, event.cache_metadata
    if u is None:
        return None
    return {"prompt": u.prompt_token_count, "cached": u.cached_content_token_count or 0,
            "cache": None if c is None else ("active" if c.cache_name else "fingerprint only"),
            "cache_uses": None if c is None else c.invocations_used}


async def main(question: str) -> None:
    runner = make_runner(make_app())
    session = await runner.session_service.create_session(app_name="docs", user_id="dana")
    for event in await ask(runner, session.id, question):
        print(event.author, usage(event), [p.text for p in (event.content.parts if event.content else []) if p.text])
    await runner.close()


if __name__ == "__main__":
    asyncio.run(main(" ".join(sys.argv[1:])))

The manual, 17736 characters, goes in static_instruction. ADK sends it as the model’s system instruction, unchanged on every request. The short instruction becomes a user content placed after the history, so it can change, or use state templates, without changing the cached prefix.

The alternative is to put the manual in instruction alone. That works, but any {state} placeholder or edit in it changes the system instruction, and a different system instruction is a different cache. Keep the large, stable part static and the small, changing part dynamic.

A prefix too short to cache. With a one-line instruction, every call stayed fingerprint-only, and ADK logged why. Caching silently did nothing. Cache only a prefix above Gemini’s minimum and your min_tokens, and check cached_content_token_count.

2. Configure caching and compaction on the App

Both configs are App fields, so they apply to every LLM agent the App runs.

ContextCacheConfig takes three limits. min_tokens skips caching for small prompts, ttl_seconds sets how long Gemini keeps each cache, and cache_intervals caps how many invocations reuse one cache before ADK builds a fresh one. A short TTL bounds what you pay to store a cache that nobody reads; a long one saves rebuilds in a busy session.

EventsCompactionConfig has two trigger strategies. The sliding window used here, compaction_interval=2 with overlap_size=1, summarizes after every second new invocation and includes one earlier invocation for continuity. The token strategy, token_threshold with event_retention_size, compacts when the last prompt passes a token count and keeps the newest raw events. Each pair must be set together, and when both are set, the token strategy wins for that turn. A sliding window is predictable; a token threshold reacts to a developer who pastes a large log in one turn.

Here the sliding window selects events for a model-written summary. It differs from the Live API sliding window, which discards older model context without writing a summary.

With no summarizer, ADK builds an LlmEventSummarizer from the agent’s own model. make_app(summarizer_llm=...) passes a different model to LlmEventSummarizer, such as a cheaper one; the tests pass a replay model there.

Half a sliding window. EventsCompactionConfig(compaction_interval=2) raised a validation error before the App existed. Set compaction_interval with overlap_size, or token_threshold with event_retention_size.

A misspelled App field. App(..., context_cache=...) raised a validation error instead of running without a cache. Use context_cache_config and events_compaction_config.

The recorded messages:

recorded-runs.json: three failuresRecorded output · textDownload
Value error, compaction_interval and overlap_size must be set together. [type=value_error, input_value={'compaction_interval': 2}, input_type=dict]
Previous request too small for caching (223 < 4096 tokens)
1 validation error for App: context_cache

The short-prefix case is the one to watch in production: nothing fails, and only the usage metadata or the ADK log shows that no cache was used.

3. Run one long session twice

record.py asks the same 6 questions twice, in one session each time: once with both settings, once with neither. It reads the token counts from each event’s usage_metadata, the cache state from cache_metadata, and the summaries from the session.

Terminal commandsTerminal · bash
python record.py
Recorded: the same session without either settingRecorded output (excerpt) · textFull output
# plain: cache=False compaction=False
> 1. Deploy r42 to staging failed. What went wrong, according to its log?
call   read_deploy_log({'release': 'r42'})
model  prompt=4502 cached=2469 cache=None
model  prompt=5261 cached=3233 cache=None
answer The deploy failed in the `pre_deploy` hook while running database migrations (`alembic upgrade head`). 
> 2. If I roll staging back, does that undo the migration r42 started?
model  prompt=5397 cached=0 cache=None
answer No. `shipctl rollback` moves traffic to an earlier release but does not run any hooks, so it does not undo database migrations. 
> 3. We fixed the migration and deployed r43, which failed differently. What happened?
call   read_deploy_log({'release': 'r43'})
model  prompt=5490 cached=0 cache=None
model  prompt=8362 cached=0 cache=None
answer Release `r43` failed because of a failed health check. 
> 4. How long are staging logs kept?
model  prompt=8454 cached=0 cache=None
answer Logs are kept for 14 days in staging (and 30 days in production). After that period, older log lines are gone.
> 5. Which of the two failed deploys, r42 or r43, ran its migration to the end?
model  prompt=8508 cached=0 cache=None
answer **r43** ran its migration to the end. 
> 6. What is the command to read the full pre_deploy output of r42?
model  prompt=8601 cached=0 cache=None
answer The command is:

The counts cover agent model calls captured by the recording callback. They exclude summarizer model requests and cache storage charges, so they do not measure total cost. The two live runs can also produce different answers and tool choices.

Read the two runs side by side:

  1. The first call of the session sent 4502 prompt tokens and had a fingerprint only: ADK records a hash of the prefix but does not cache on the first request. The second call, in the same turn, read 4401 tokens from the cache ADK had just created.
  2. After the first compaction, the first call of the next turn read 0 cached tokens. The summary changed the prefix, so ADK fingerprinted it again and cached the new prefix one call later.
  3. The budgeted run ended with 5824 prompt tokens per request; the plain run ended with 8601. The deploy logs in the plain history are sent again on every turn.
  4. The plain run still shows cached tokens in the first turn. The Gemini API caches some repeated prefixes on its own, which it calls implicit caching; it is not guaranteed, and here it stopped after the first turn.

The answers to the question about migrations match: both runs said that r43 ran its migration to the end. The summary had kept the fact that r42’s migration failed.

A compaction misses the cache. The first call after each compaction read zero cached tokens, because the summary changed the prefix. Include these cache misses when comparing total token use and cost; this recording does not establish the best compaction interval for another workload.

How compaction and prefix caching interact

The cache. On each Gemini request, ADK’s context cache manager compares a fingerprint of the system instruction, the tools and the leading contents with the previous request’s. On a match it calls Gemini’s caches.create with that prefix and then sends only the rest of the request with a cached_content reference. The ADK log line from the recorded run names the resource:

recorded-runs.json: the first cache_log linesRecorded output (excerpt) · textFull output
Cache created successfully: cachedContents/6sm6holvq2uzac9j8cp5stcdqtmlc0d3k0x0yooo
Cache cleaned up: cachedContents/6sm6holvq2uzac9j8cp5stcdqtmlc0d3k0x0yooo

When the prefix changes, ADK deletes the old cache and starts over, which is the second line. A cache is also rebuilt when its TTL passes or it has served cache_intervals invocations. For the user-turn requests shown here, the cacheable prefix ends before the final contiguous batch of user contents. The newest question and the dynamic instruction stay outside that explicit cache.

Compaction. After each invocation, the Runner checks the compaction config. When it triggers, the summarizer reads the covered events and the Runner appends one new event whose actions.compaction holds the time range and the summary text. Nothing is deleted: the budgeted session stored 18 events, the plain one 16, and the difference is the summaries. When ADK builds the next request, it drops the raw events inside a compacted range and puts the summary in their place. The number of contents in each request shows it:

Run Contents per model request, in order
cache + compaction 2, 4, 6, 3, 5, 7, 4, 6
neither 2, 4, 6, 8, 10, 12, 14, 16

Overlapping summaries are both kept when neither covers the other, so the last request carried the summary of turns 1, 2 and the summary of turns 2, 3, 4.

Test compaction without a model

test_assistant.py replays the recorded Gemini turns and summaries through a fake model, with the real Runner and session service, and checks what each request contained.

Terminal commandsTerminal · bash
python -m unittest -v test_assistant.py
Recorded test outputRecorded output · textDownload
/Users/arjunprabhulal/aiml-prod-m2/public/examples/adk-context-budget/assistant.py:67: UserWarning: [EXPERIMENTAL] EventsCompactionConfig: This feature is experimental and may change or be removed in future versions without notice. It may introduce breaking changes at any time.
  return EventsCompactionConfig(compaction_interval=2, overlap_size=1, summarizer=summarizer)
/Users/arjunprabhulal/aiml-lanes/envs/venv-agents/lib/python3.12/site-packages/google/adk/models/llm_request.py:306: UserWarning: [EXPERIMENTAL] feature FeatureName.JSON_SCHEMA_FOR_FUNC_DECL is enabled.
  declaration = tool._get_declaration()
test_compaction_runs_after_every_second_turn (test_assistant.Compaction.test_compaction_runs_after_every_second_turn) ... ok
test_compaction_shrinks_the_request_history (test_assistant.Compaction.test_compaction_shrinks_the_request_history) ... ok
test_raw_events_stay_in_the_session (test_assistant.Compaction.test_raw_events_stay_in_the_session) ... ok
test_the_summary_replaces_old_tool_results (test_assistant.Compaction.test_the_summary_replaces_old_tool_results) ... ok
test_a_half_sliding_window_is_rejected (test_assistant.Configuration.test_a_half_sliding_window_is_rejected) ... ok
test_an_unknown_app_field_is_rejected (test_assistant.Configuration.test_an_unknown_app_field_is_rejected) ... ok
test_the_manual_is_the_static_system_instruction (test_assistant.Configuration.test_the_manual_is_the_static_system_instruction) ... ok
test_a_short_prefix_never_gets_a_cache (test_assistant.RecordedCacheUse.test_a_short_prefix_never_gets_a_cache) ... ok
test_the_first_call_after_each_compaction_misses_the_cache (test_assistant.RecordedCacheUse.test_the_first_call_after_each_compaction_misses_the_cache) ... ok

----------------------------------------------------------------------
Ran 9 tests in 0.273s

OK

All 9 tests pass. The replay cannot exercise Gemini’s cache, so the two cache tests check the recorded run instead.

Read the test and recording scripts
test_assistant.pySource · pythonDownload
"""Replay the recorded Gemini turns offline and check what compaction does to the request.

Run: python -m unittest -v test_assistant.py
"""
import asyncio
import json
import logging
import unittest
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

from google.adk.agents.context_cache_config import ContextCacheConfig  # noqa: E402
from google.adk.apps.app import App, EventsCompactionConfig  # noqa: E402
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 assistant  # noqa: E402

RECORD = json.loads(Path(__file__).with_name("recorded-runs.json").read_text(encoding="utf-8"))
BUDGETED, PLAIN = RECORD["runs"]


class ReplayLlm(BaseLlm):
    """Returns recorded responses in order and keeps every request it receives."""
    turns: list
    requests: list = []

    async def generate_content_async(self, llm_request, stream=False):
        self.requests.append(llm_request)
        yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)))


def replay(run: dict, compact: bool):
    """Run the six questions against recorded model turns; return the model requests and the stored session."""
    model = ReplayLlm(model="replay", turns=[c["turn"] for c in run["model_calls"]], requests=[])
    summaries = [{"role": "model", "parts": [{"text": c["summary"]}]} for c in run["compactions"]]
    summarizer = ReplayLlm(model="replay", turns=summaries, requests=[])
    app = assistant.make_app(assistant.root_agent.clone(update={"model": model}), cache=False,
                             compact=compact, summarizer_llm=summarizer)

    async def go():
        runner = assistant.make_runner(app)
        session = await runner.session_service.create_session(app_name="docs", user_id="dana")
        for question in [t["question"] for t in run["turns"]]:
            await assistant.ask(runner, session.id, question)
        stored = await runner.session_service.get_session(app_name="docs", user_id="dana", session_id=session.id)
        await runner.close()
        return model.requests, summarizer.requests, stored
    return asyncio.run(go())


class Compaction(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.budgeted = replay(BUDGETED, compact=True)
        cls.plain = replay(PLAIN, compact=False)

    def test_compaction_runs_after_every_second_turn(self):
        _, summarizer_requests, stored = self.budgeted
        self.assertEqual(len(summarizer_requests), len(BUDGETED["compactions"]))
        self.assertEqual(sum(1 for e in stored.events if e.actions.compaction), len(BUDGETED["compactions"]))

    def test_compaction_shrinks_the_request_history(self):
        budgeted = [len(r.contents) for r in self.budgeted[0]]
        plain = [len(r.contents) for r in self.plain[0]]
        self.assertEqual(budgeted, [c["contents"] for c in BUDGETED["model_calls"]])
        self.assertEqual(plain, [c["contents"] for c in PLAIN["model_calls"]])
        self.assertLess(budgeted[-1], plain[-1])

    def test_raw_events_stay_in_the_session(self):
        stored_budgeted, stored_plain = self.budgeted[2], self.plain[2]
        raw = [e for e in stored_budgeted.events if not e.actions.compaction]
        self.assertEqual(len(raw), len(stored_plain.events))

    def test_the_summary_replaces_old_tool_results(self):
        def logs_in(request):
            return [p.function_response.response["release"] for c in request.contents for p in c.parts
                    if p.function_response and "release" in p.function_response.response]
        self.assertEqual(logs_in(self.budgeted[0][-1]), [])  # both raw deploy logs are gone from the request
        self.assertEqual(logs_in(self.plain[0][-1]), ["r42", "r43"])


class Configuration(unittest.TestCase):
    def test_a_half_sliding_window_is_rejected(self):
        with self.assertRaisesRegex(ValueError, "compaction_interval and overlap_size must be set together"):
            EventsCompactionConfig(compaction_interval=2)

    def test_an_unknown_app_field_is_rejected(self):
        with self.assertRaisesRegex(ValueError, "context_cache"):
            App(name="docs", root_agent=assistant.root_agent, context_cache=ContextCacheConfig())

    def test_the_manual_is_the_static_system_instruction(self):
        self.assertIn("<manual>", assistant.root_agent.static_instruction)
        self.assertIn("docuctl rollback", assistant.root_agent.static_instruction)


class RecordedCacheUse(unittest.TestCase):
    """Checks on the recorded Gemini run: these read recorded-runs.json and make no model call."""

    def test_the_first_call_after_each_compaction_misses_the_cache(self):
        compacted_after = [max(c["turns"]) for c in BUDGETED["compactions"]]
        for turn in BUDGETED["turns"]:
            if turn["turn"] - 1 in compacted_after:
                self.assertEqual(turn["usage"][0]["cache"], "fingerprint only")
                self.assertEqual(turn["usage"][0]["cached"], 0)

    def test_a_short_prefix_never_gets_a_cache(self):
        short = RECORD["failures"]["short_prefix"]
        self.assertTrue(all(u["cache"] == "fingerprint only" for u in short["usage"]))
        self.assertIn("too small for caching", short["cache_log"][0])


if __name__ == "__main__":
    unittest.main()
record.pySource · pythonDownload
"""Record one long session twice on Gemini: with caching and compaction, and without either.

Run from this folder with GOOGLE_API_KEY set: python record.py
Writes recorded-runs.json and run-output.txt. The tests replay the model turns offline.
"""
import asyncio
import json
import logging
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")

from google.adk import Agent  # noqa: E402
from google.adk.agents.context_cache_config import ContextCacheConfig  # noqa: E402
from google.adk.apps.app import App, EventsCompactionConfig  # noqa: E402

import assistant  # noqa: E402

HERE = Path(__file__).resolve().parent
QUESTIONS = [
    "Deploy r42 to staging failed. What went wrong, according to its log?",
    "If I roll staging back, does that undo the migration r42 started?",
    "We fixed the migration and deployed r43, which failed differently. What happened?",
    "How long are staging logs kept?",
    "Which of the two failed deploys, r42 or r43, ran its migration to the end?",
    "What is the command to read the full pre_deploy output of r42?",
]


class CacheLog(logging.Handler):
    """Collect ADK's cache-manager messages: they say why a cache was or was not created."""
    def __init__(self):
        super().__init__(logging.INFO)
        self.lines = []

    def emit(self, record):
        if "gemini_context_cache_manager" in record.name:
            self.lines.append(record.getMessage())


def recording(agent: Agent, calls: list) -> Agent:
    def before(callback_context, llm_request):
        calls.append({"invocation": callback_context.invocation_id, "contents": len(llm_request.contents)})

    def after(callback_context, llm_response):
        content = llm_response.content.model_dump(mode="json", exclude_none=True) if llm_response.content else None
        for part in (content or {}).get("parts", []):
            part.pop("thought_signature", None)
        calls[-1]["turn"] = content
    return agent.clone(update={"before_model_callback": before, "after_model_callback": after})


async def session_run(label: str, cache: bool, compact: bool, agent: Agent = assistant.root_agent,
                      questions: list = QUESTIONS) -> dict:
    calls, log = [], CacheLog()
    logging.getLogger("google_adk").addHandler(log)
    logging.getLogger("google_adk").setLevel(logging.INFO)
    runner = assistant.make_runner(assistant.make_app(recording(agent, calls), cache=cache, compact=compact))
    session = await runner.session_service.create_session(app_name="docs", user_id="dana")
    turns = []
    for n, question in enumerate(questions, 1):
        events = await assistant.ask(runner, session.id, question)
        model_events = [e for e in events if e.usage_metadata]
        answer = next(("".join(p.text or "" for p in e.content.parts if not p.thought).strip()
                       for e in reversed(events) if e.is_final_response() and e.content), "")
        turns.append({"turn": n, "question": question,
                      "tool_calls": [f"{c.name}({c.args})" for e in events for c in e.get_function_calls()],
                      "usage": [assistant.usage(e) for e in model_events], "answer": answer})
    stored = await runner.session_service.get_session(app_name="docs", user_id="dana", session_id=session.id)
    invocations = list(dict.fromkeys(e.invocation_id for e in stored.events if not e.actions.compaction))
    compactions = []
    for e in stored.events:
        if e.actions.compaction:
            c = e.actions.compaction
            covered = sorted({invocations.index(x.invocation_id) + 1 for x in stored.events
                              if not x.actions.compaction and c.start_timestamp <= x.timestamp <= c.end_timestamp})
            compactions.append({"turns": covered, "summary": "".join(p.text or "" for p in c.compacted_content.parts)})
    for call in calls:
        call["turn_index"] = invocations.index(call.pop("invocation")) + 1
    await runner.close()
    logging.getLogger("google_adk").removeHandler(log)
    return {"label": label, "cache": cache, "compact": compact, "turns": turns, "model_calls": calls,
            "compactions": compactions, "stored_events": len(stored.events), "cache_log": log.lines}


async def failures() -> dict:
    out = {}
    # 1. A sliding window needs both numbers; one alone is rejected when the config is built.
    try:
        EventsCompactionConfig(compaction_interval=2)
    except ValueError as error:
        out["half_config"] = str(error).splitlines()[1].strip() if "\n" in str(error) else str(error)
    # 2. A short prefix: with no manual, the cacheable part is below Gemini's minimum cache size.
    short = assistant.root_agent.clone(update={"static_instruction": "You answer questions about docuctl."})
    run = await session_run("short prefix", cache=True, compact=False, agent=short,
                            questions=["What does docuctl rollback do?", "Does it undo migrations?"])
    out["short_prefix"] = {"usage": [u for t in run["turns"] for u in t["usage"]], "cache_log": run["cache_log"]}
    # 3. The App rejects a config object it does not know, so a typo cannot silently turn caching off.
    try:
        App(name="docs", root_agent=assistant.root_agent, context_cache=ContextCacheConfig())
    except ValueError as error:
        out["unknown_field"] = [line for line in str(error).splitlines() if line.strip()][:2]
    return out


def transcript(run: dict) -> str:
    lines = [f"# {run['label']}: cache={run['cache']} compaction={run['compact']}"]
    for t in run["turns"]:
        lines.append(f"> {t['turn']}. {t['question']}")
        lines += [f"call   {c}" for c in t["tool_calls"]]
        lines += [f"model  prompt={u['prompt']} cached={u['cached']} cache={u['cache']}" for u in t["usage"]]
        lines.append("answer " + t["answer"].splitlines()[0][:150])
    lines += [f"compaction of turns {c['turns']}: {len(c['summary'])} characters" for c in run["compactions"]]
    return "\n".join(lines)


async def main() -> None:
    budgeted = await session_run("budgeted", cache=True, compact=True)
    plain = await session_run("plain", cache=False, compact=False)
    record = {"model": assistant.MODEL, "manual_characters": len(assistant.MANUAL),
              "runs": [budgeted, plain], "failures": await failures()}
    (HERE / "recorded-runs.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
    (HERE / "run-output.txt").write_text(transcript(budgeted) + "\n\n" + transcript(plain) + "\n", encoding="utf-8")
    print(transcript(budgeted) + "\n\n" + transcript(plain))
    print(json.dumps(record["failures"], indent=2))


if __name__ == "__main__":
    asyncio.run(main())

Tune it for real sessions

The recorded run shows the knobs to watch. A summary is a model’s lossy copy of the turns it replaces: it can drop a release name or an error code, so give the summarizer a prompt_template that keeps identifiers when your domain depends on them, and audit summaries against the raw events, which stay in the session. An explicit cache is stored, and billed, until its TTL passes. ADK deletes a cache when it replaces it, but the last cache of a session stays until its TTL: the recorded run created 3 caches and cleaned up 2. If another turn updates the session while a summary is being written, the Runner discards that summary and logs it, in that conflict case. Other summarization or model errors still need application error handling. Before you change min_tokens, cache_intervals or the compaction interval, read cached_content_token_count and prompt_token_count from your own traces.

Check your understanding

The cache-and-compaction report shows fewer prompt tokens. Its counts exclude summarizer requests and cache storage. Which claim is supported?

Choose one answer, then check your reasoning.

Read the explanation

The report measures the captured agent model requests. It does not account for all charges or enforce a spend cap.

References

Official sources checked October 21, 2026, against google-adk 2.12.0.

Previously on the blog: Google ADK Context Management: Caching & Compaction covers the ADK 1.x version of this lesson.

Written by Arjun Prabhulal.

Module 2

Manage context and artifacts