contextopt_persistent

command
v0.0.0-...-3309b9d Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT Imports: 15 Imported by: 0

README

contextopt + persistent memory — pay for summarization once

This example shows how to run the two summarizing layers of components/memory together without paying the LLM summarization bill twice:

Layer Package When it summarizes Persists?
Cross-request condenser memory/session Between requests, when the stored window exceeds CondenseThreshold Yes — anchored summary written to the store
Intra-run optimizer middleware/contextopt Inside a single adk.Runner.Run, on real context-window overflow No — compacts the in-flight message list only

Both can call the same (expensive) LLM Summarizer. Wired naively, the same history gets summarized once by each layer per turn — double the cost. This sample wires them so the cost is provably paid at most once per turn.

Run it

No API key required — the chat model is a local stub and the Summarizer is instrumented to count its LLM calls.

go run ./components/memory/examples/contextopt_persistent

Expected output:

turn 1: condensed=false summarization LLM calls this turn = 0
turn 2: condensed=true  summarization LLM calls this turn = 1
turn 3: condensed=true  summarization LLM calls this turn = 1
turn 4: condensed=true  summarization LLM calls this turn = 1

Total summarization LLM calls over 4 turns: 3 (max 1 in any single turn).
The history was summarized AT MOST ONCE per turn — never twice.

The middleware is fully active (it still trims, prunes and compacts every turn), but it never adds a second summarization call on top of the cross-request Condense.

The three-part invariant

A turn pays the summarization cost twice only if both layers decide to summarize. To make that impossible, enforce all three of these together:

  1. One TokenCounter everywhere — the same counter in the memory store, session.Config, and contextopt.Config. If the layers disagree on how big the window is, their overflow decisions diverge and one re-summarizes what the other already did.
  2. WindowBudget ≤ the middleware's usable window — the usable window is MaxInputTokens (else ContextLimit − ReservedTokens). Because Condense bounds the persisted window to WindowBudget, the messages handed to the model on the first call of a turn already fit the middleware's budget, so contextopt.IsOverflow returns false and its Summarizer is not called.
  3. Shared summary markers — automatic. Condense writes summaries via memory.NewSummaryMessage, and contextopt recognizes them through memory.IsSummary (trimBeforeLastSummary, lastSummaryText). The persisted summary is the truncation frontier both layers agree on.

In the example these map to:

counter := memory.DefaultTokenCounter // (1) shared by store, session, middleware

session.Config{
    WindowBudget: usableWindow,   // (2) == middleware MaxInputTokens
    TokenCounter: counter,        // (1)
    Summarizer:   sharedSummarizer,
    CondenseThreshold: 3_000,     // < usableWindow, so Condense fires first
}

contextopt.Config{
    MaxInputTokens: usableWindow, // (2)
    TokenCounter:   counter,      // (1)
    Summarizer:     sharedSummarizer,
}

A single contextopt.NewModelSummarizer(model) instance feeds both layers (the session.Summarizer interface is structurally identical to contextopt.Summarizer, with no import dependency between the packages).

Why the cost stays at one call per turn

request
  │
  ├─ BeginTurn ─ holds the per-session lock; user msg pending (not yet persisted)
  │
  ├─ Condense(ctx) ──────────────► the ONLY normal summarization call.
  │     window ≥ CondenseThreshold   Writes an anchored summary to the store.
  │
  ├─ Window(0)  ────────────────► [summary + recent] + pending user,
  │                                 bounded by WindowBudget ≤ usableWindow
  │
  ├─ runner.Run → adk.Runner.Run
  │     contextopt middleware runs every model call:
  │       trimBeforeLastSummary  (free; drops pre-summary history)
  │       prune / compress       (cheap; no LLM)
  │       IsOverflow? ───────────► FALSE, because the window already fits
  │                                 usableWindow with the SAME counter.
  │       → Summarizer NOT called  (no second bill)
  │
  └─ persistence goroutine: CommitAssistant → releases the lock
        the next BeginTurn for this session blocks here, serializing turns

If the conversation keeps growing within a single long run (e.g. large tool outputs pushing past the window mid-turn), contextopt may summarize — but that is genuine intra-run overflow, done incrementally by passing the existing summary as previousSummary, not a duplicate of the cross-request work.

Turn ownership and the persistence race (important)

After runner.Run succeeds, the runner bridge owns the turn: its persistence goroutine calls CommitAssistant (or Discard) exactly once and releases the session lock when done. The caller must not also Discard the turn on the success path — a deferred turn.Discard() can race the async commit and drop the pending user message, so the history silently loses every user turn.

This example therefore Discards only on the early error paths (before the bridge takes over) and lets the bridge own commit/discard afterwards. Turns stay correctly serialized because the next BeginTurn blocks on the per-session lock until persistence releases it.

Files

  • main.go — the full runnable wiring (session + contextopt + runner bridge) with an instrumented Summarizer that proves the call count.

See also

Documentation

Overview

Command contextopt_persistent demonstrates how to combine the cross-request session condenser (components/memory/session) with the intra-run context optimizer (components/middleware/contextopt) WITHOUT paying the LLM summarization cost twice.

Two layers can summarize a conversation:

  • session.Turn.Condense — cross-request, persists an anchored summary to the memory store when the window exceeds CondenseThreshold.
  • contextopt (middleware) — intra-run, may summarize again on overflow during a single adk.Runner.Run.

Naively wired, the SAME history is summarized once by each layer, doubling the (expensive) LLM bill. The fix is the three-part invariant that makes the cost provably paid AT MOST ONCE per turn:

  1. the SAME TokenCounter in the memory store, session.Config and contextopt.Config;
  2. WindowBudget <= the middleware's usable window (MaxInputTokens here);
  3. shared summary markers (automatic via memory.NewSummaryMessage).

This program runs a multi-turn workload and counts LLM summarization calls so you can verify the cost is paid at most once per turn. The model is a local stub, so no API key is required:

go run ./components/memory/examples/contextopt_persistent

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL