feat(compaction): compact mid-invocation once the prompt crosses a threshold - #1234
Conversation
karolpiotrowicz
left a comment
There was a problem hiding this comment.
Mid-invocation tail retention is the right idea and the token gate is a
reasonable trigger, but the default configuration currently removes the user's
own question, and two failure paths go silent.
1. EventRetentionSize: 0 is the default and it swallows the live question.
resolveCompactionConfig fills only Summarizer, so the minimal config that
turns the feature on — &compaction.Config{TokenThreshold: N} — leaves retention
at zero. Validate() accepts it (while rejecting the mirror image at
compaction.go:117), and selectTailRetentionWindow(events, 0) then compacts
the in-flight user turn away. Either default it to a non-zero value or make
Validate() reject the pair.
2. A summarizer error wrapping context.Canceled disappears. The processor
yields the error into the flow (compaction_processor.go:55) and the scheduler
unwraps it to NodeCancelled (scheduler.go:786-789), so the turn ends with no
answer and no error. The identical error surfaces correctly from the
post-invocation pass, which is a useful contrast for the fix.
3. Tail retention breaks every ModeSingleTurn delegation. The processor
appends to ctx.Session(), which for a delegated sub-agent is
*llmagent.wrappedSession (llm_agent_wrapper.go:109) — rejected by every
session backend. It surfaces as a tool error, so the caller sees zero compaction
errors and a confusing failure instead.
One design point worth a second look: there is no cooldown, no
progress requirement and no per-invocation cap, so the processor re-fires ahead
of nearly every model call in a tool loop. Measured 1.46×–1.85× the model calls
in that shape — and at retention=2 the token signal is pinned across all 12
calls, so compaction fires 11 times while being structurally unable to reduce
anything. A cooldown plus a "did the last pass actually shrink the prompt" gate
would bound it.
Smaller: an event stamped exactly at the previous compaction's EndTimestamp is
skipped by tail_retention.go:160 and then deleted by the range, so it is lost;
and the same-timestamp back-off at :174-181 runs before the
longestSelfContainedPrefix trim at :184, so the trim can still split a group
and orphan a function response.
Additional findings
[minor] Compaction summaries carry no IsolationScope, so compaction is a paid-for no-op for scoped agents. stamp (internal/compactioninternal/compactor.go:121-133) sets only ID, InvocationID and Timestamp, while internal/llminternal/contents_processor.go:106 filters on an exact IsolationScope match before Apply runs, so the summary never reaches the prompt: a scoped agent's prompt still contains all four raw events, whereas a control summary carrying scope1 collapses them as intended. There is no event loss — Apply runs on the already-filtered list (contents_processor.go:120), which no longer contains the summary, so it drops nothing — the cost is that the summarizer runs, is billed and writes an event while the prompt never shrinks, for scoped agents. This also predates this change; the sliding-window path uses the same unfiltered collection and the same stamp. Populating Branch and IsolationScope on the summary event from the invocation context (in stamp or in CompactionRequestProcessor) would fix it.
[minor] Tail retention is silently inert on RunLive, on a rationale the live path contradicts. The comment at runner/runner.go:555-559 explains the omission by saying a live session "streams over a persistent connection instead of re-sending assembled history each turn", but the live path does assemble the full history through the same request-processor chain (internal/llminternal/base_flow.go:301) and send it with SendHistory (:380), re-sending on every resumption inside the reconnect loop at :340 — so history size does matter there. Meanwhile Runner.New accepts TokenThreshold for a live-only runner with no error, no warning and nothing in the public docs, so a user who configures the safety net gets neither compaction nor a signal. Opting the live path out may well be right; the stated reason is the part to correct, since it is the justification a future maintainer will rely on. Either wire the runtime into RunLive or reject/warn on a tail-retention config for a live-only runner, and document on Config.TokenThreshold which serving surfaces run tail retention. (Growth on a real live session was not demonstrated — that needs a live model connection — so this rests on reading the live path.)
[minor] The public config surface omits the facts users need, and the Config doc contradicts Validate(). The exported TokenThreshold doc (session/compaction/compaction.go:66-69) states only when compaction fires; the three material facts — the count comes from the previous response, the fallback is len(text)/4 over text parts only, and the comparison is against the total prompt — live in unexported internals (internal/compactioninternal/tail_retention.go:81-90, :106-118), and the repository carries no markdown guidance for choosing a value. Separately, compaction.go:47-48 says enabling neither strategy "disables compaction entirely" while Validate() at :120-122 rejects exactly that config and runner.New fails hard on &compaction.Config{}. This is documentation with teeth: the retention-zero config and the accepted-but-inert config both read as legal from the field docs. State the units and the lagging-signal caveat on the exported field, give a rule of thumb relative to the model's context window, note that a tool round costs two events (so EventRetentionSize: 2 retains half an exchange), and reword the Config doc to something like "a non-nil Config must enable at least one strategy; use a nil Config to disable compaction".
[minor] The compaction runtime survives onto the context handed to a user-supplied Summarizer, with no depth guard. internal/compactioninternal/compactor.go:102 passes the invocation context to cfg.Summarizer.SummarizeEvents unchanged. The default path is safe — LLMSummarizer calls s.model.GenerateContent directly (session/compaction/llm_summarizer.go:122), bypassing Flow, so CompactionRequestProcessor is never re-entered — but Summarizer is a documented public extension point (session/compaction/compaction.go:126-136), and a summarizer that runs an agent or flow on the given context would re-enter compaction on the same session. Nothing bounds the recursion, since every level sees the same over-threshold session. Strip the runtime before calling the summarizer (ctx = compactionctx.ToContext(ctx, nil)), or carry a depth guard on the runtime.
| // firstRetained is where the raw tail begins; everything before it is | ||
| // eligible for summarization. | ||
| firstRetained := len(candidates) | ||
| if retentionSize > 0 { |
There was a problem hiding this comment.
[blocker] retentionSize > 0 means the zero value retains nothing, and Validate() accepts it: it rejects the mirror mistake (EventRetentionSize > 0 && TokenThreshold == 0) but blesses &compaction.Config{TokenThreshold: 1000}, and resolveCompactionConfig (runner/runner.go:149) fills in only Summarizer — yet zero is exactly what a user writes when they only care about the token threshold. The whole history then becomes the window, including the user message the runner appended moments earlier:
turn-2 prompt: [model] S1
[user] Continue processing previous requests as instructed...
The live question is gone and the model is asked to continue without it. The same run with EventRetentionSize: 1 keeps WHAT-IS-MY-LIVE-QUESTION in the prompt, isolating the zero value as the cause. The same defect appears at any EventRetentionSize below the current turn's event count — a tool round costs two events, so EventRetentionSize: 2 fails on the first tool call.
Reject TokenThreshold > 0 && EventRetentionSize == 0 in Validate(), symmetric with the existing check, or default it in resolveCompactionConfig; independently, selectTailRetentionWindow should never summarize events from the current invocation (ev.InvocationID == ctx.InvocationID()), which also covers the EventRetentionSize: 2 variant.
There was a problem hiding this comment.
Fixed. Validate now rejects a token threshold with a zero retention size, and the field doc says why.
Confirmed against the reference while checking: adk-python does the same thing, if event_retention_size == 0: events_to_compact = candidate_events, so the window extends to the newest event and the turn in progress is summarized out of its own prompt. Shared rather than Go-only, and with no legitimate use in either, so Go rejects it rather than waiting. Recorded in the cross-language notes so the Python side can pick it up.
ea92267 to
52e1a59
Compare
7b2148b to
7353d20
Compare
52e1a59 to
40a9484
Compare
7353d20 to
b287af2
Compare
|
Thank you for this. It is the most useful review on the stack, and it caught something I had got wrong rather than merely missed: I had described the hardening added in #1232 as if it were global, when it only ever applied to the post-invocation sliding-window path. This PR's mid-turn path had unfixed twins of the same defects. I have posted a correction on #1232. Pushed now, with a regression test for each, every one confirmed to fail against the previous behaviour:
Still open and triaged, not forgotten: the two trigger blockers ( One thing you did not raise that the triage turned up: the tail path also has no equivalent of |
40a9484 to
eb20515
Compare
1d51176 to
1492a4c
Compare
42c8cf8 to
cf699f0
Compare
1492a4c to
9fd8b68
Compare
|
All nine agreed decisions are implemented and pushed. Summary of what changed and where, plus the things I found along the way that were not in the plan. Landed
The #1236 cassette was also re-recorded with a fourth tool-calling turn, so pairing across a summary is exercised for the first time. Four things worth flaggingThe The A2A cleanup hang is filed as #1298, with the evidence that it hits both Three of the seven open #1234 items turned out to be shared with adk-python, not Go defects: the zero retention size, the tie at the compaction boundary, and the absent cooldown. That changed what I did with them: the first is rejected in Go because it has no legitimate use anywhere, the other two are recorded in the cross-language notes rather than diverged on unilaterally. Two of my own tests were vacuous and I only caught them by mutating the source. The both-strategies test passed whether or not the gate existed, because the configuration never made the two strategies want the same turn. And an assertion I added last session checked for the absence of Still openDeliberately not done, with reasoning in the replies: the partially overlapping ranges from the two producers, which decision 4 largely prevents and which is better judged after it lands; the coverage model, left timestamp-based to match the reference; and recovered-call ordering, which interacts with the stream ordering changed on #1232. Every branch was verified independently: build, |
|
Ready for another look. Before asking, I went back over every inline comment on #1231 to #1236 and checked the code rather than trusting my own earlier replies. Two things came out of that. 50 of your comments never got an inline answer. On #1232 I replied to all 19 in a commit message, which from your side looks identical to being ignored. Same for most of #1231. Every one of those now has a reply on the thread itself. One thing I reported as fixed was not. The claim that compaction "keeps prompts small" is wrong, because the sliding window never re-summarizes a summary. I said I had corrected the wording. I had corrected it in one place, and the original had already been copied into three more, one of which my own search missed because it wraps across two lines. Fixed in all four now. Thread stateThreads are resolved only where the fix is real and I can point at it. Anything partly done or not done is left open on purpose, including four on #1231 that were marked resolved earlier but should not have been.
So: 52 done, 38 still open. The open ones split into things that need a decision from you and things that just need doing, and each thread says which. The larger open questions, if you would rather answer them here than thread by thread: whether coverage should be keyed on event identity instead of timestamps, which closes two defects at once but changes the stored shape and diverges from the Python version; whether summaries should go through the plugin pipeline, which the Python version effectively does and this does not; and whether the mid-turn strategy needs a cooldown, which neither implementation has today. State of the codeEvery branch passes build, race tests with shuffling, lint and tidy in both modules. I dispatched the Go workflow manually to confirm that, because these pull requests target the integration branch and so get no automated checks beyond the CLA. That gap is worth fixing separately. Also opened #1297, which adds an API compatibility check, since nothing in CI would have caught the signature break you found here. |
9fd8b68 to
2f975e7
Compare
25533df to
f838737
Compare
2f975e7 to
7d4bdde
Compare
f838737 to
ff5b84b
Compare
…ow has Review of #1234 found that the mid-turn tail-retention path never inherited the hardening the post-invocation sliding-window path received. The two paths run the same algorithm against the same session, so every defect fixed on one side was still live on the other. Two of them were worse mid-turn than they had been post-invocation. Appending the summary to the session on the invocation context fails outright when an agent has wrapped it. A coordinator hands a sub-agent a session wrapped to carry a synthetic first turn, and every session service type-asserts on its own concrete type, so the append is rejected. Nothing surfaced: the failure became a tool-error response and the coordinator answered on top of a broken delegation. The processor now compacts against the session underneath any wrapper. Unwrapping rather than re-reading is what makes this work, because the wrapper reads through to the session it decorates on every call, so a summary appended underneath reaches the prompt this processor deliberately runs ahead of. A freshly read session is a different object and the summary misses it. A summarizer error that wrapped context.Canceled was swallowed whole. The error rides the flow's error channel into the workflow scheduler, which tests for a context.Canceled chain before anything else and drops what it finds, so a cancelled summarizer ended the turn with no answer, no events and no error. The ErrCompaction sentinel added in #1232 does not help, because the scheduler never consults it. The cause is now rendered with %v, which keeps it in the message and out of the chain. Callers lose errors.Is against the cause and keep it against ErrCompaction, which is the right way round for bookkeeping. The remaining three are ports of code that already existed in runner.go: - The session is re-read after summarizing and the summary is discarded when RangeRaced reports that another invocation appended inside the chosen range. The read is only a comparison here, because the append has to keep object identity with the session the prompt is built from. - ctx.Err() is checked before the summarizer call and before the append, so a cancelled turn neither spends a model call nor writes a summary nobody is waiting for. - The window is trimmed to a single branch and isolation scope, and the rolling seed carries the previous summary's scope rather than only its branch. A window spanning a boundary produced one summary that misattributed half its content and, stamped with the first event's empty scope, was readable by agents the filters exist to keep it from. Also corrects an assertion added alongside the tail-retention span test: it checked for the absence of "gen_ai.compaction.interval", but the key is "gen_ai.compaction.compaction_interval", so it could never have failed whatever the span carried. Testing: internal/llminternal/compaction_processor_test.go is new, and covers the wrapper, cancellation and raced-summary paths; the file had no tests at all before. Every fix has a regression test confirmed to fail against the previous behaviour, and the wrapper one reproduces the reviewer's error verbatim. Build, race tests, lint and tidy are clean in both modules.
…lling turns Five findings on #1234, and one wiring bug found while fixing them. A turn compacted mid-flight was compacted again the moment it ended. The two strategies are independent triggers on the same history, so a turn that crossed the token threshold paid for a second model call to re-summarize what had just been summarized, and left two ranges over overlapping spans. The reference implementation avoids this by evaluating both in one place and returning early; the same effect is reached here by recording, on the per-invocation compaction runtime, that a compaction already ran. Wiring bug found by the test for that: in the node path the runtime was allocated inside newNodeInvocationContext, so the mid-turn processor and the post-invocation hook held different instances and no hand-off could ever arrive. One runtime is now attached to the invocation's context before either is built from it. The token scan read the summary's own usage metadata. A summary carries the usage of the summarizer's call, which measures the transcript it was handed rather than the agent's prompt, and that number sits far above the threshold. Every later turn therefore saw the threshold crossed and compacted again. Compaction events are skipped in the scan. A mid-turn compaction failure aborted the turn. Tail retention runs before a model call, inside an invocation whose tools may already have run and committed their side effects, so aborting cost the user an answer while the side effects stood and any summary already written was orphaned, all to report that an optimisation did not happen. Failures are now logged and the turn continues with a larger prompt, which usually still fits since the threshold sits well below the real context limit, and which otherwise fails with the provider's own error. The failure is not lost: the compaction span records it with an error status. A config with a token threshold and no retention size is now rejected. At zero the window extends to the newest event, which is the question being answered, so the turn in progress was summarized out of its own prompt. The reference implementation shares this behaviour; it has no legitimate use in either. Finally, a compaction that fired and could do nothing now emits a span saying so. A trigger that never fires stays silent, so a span still means compaction was wanted, but the case where the threshold is crossed and nothing may be summarized looked exactly like an idle session while the prompt grew on every turn. Testing: the hand-off test failed vacuously at first, passing whether or not the gate was present because the configuration never made both strategies want the same turn; it is now tuned so they collide, and it catches both the missing gate and the split-runtime wiring. Two tests that pinned the old abort behaviour were rewritten to pin the new contract rather than deleted.
Six items from the #1234 review that needed work rather than a decision. An event stamped exactly at the previous compaction's end was lost. Candidates were filtered on "strictly after that instant", while the new range, seeded with the previous summary, starts back at the previous start and so covers it. Such an event went into no window and inside the next recorded range: summarized by nothing, and dropped from every prompt afterwards. Candidates are now taken by stream position, which has no ties. The estimator counted bytes while its constant and its documentation both said characters, so the same ten characters cost 2, 7 or 10 depending on the script. It counts runes now. A failed session re-read still killed the turn. Everything else on that path degrades, for the good reason that tools may already have run; this one path did not. It does now, so no mid-turn compaction failure can fail a user's turn. RangeRaced ignored compaction events entirely, so two invocations could each summarize the same span and both record it. It now treats a compaction event that is new since the window was chosen as a race, while still ignoring the one the window was built from. The rolling seed is labelled rather than anonymous, so a summary is not mistaken for something the agent said. compactionctx had no tests at all. It has them now, including the nil receiver every caller relies on and the concurrent marking that is the reason the flag is atomic. One change was reverted after a test disagreed with it. Making LatestCompactionEvent require usable content looked like the obvious way to unify the two predicates, but a contentless record still marks how far compaction reached, and requiring content there makes the next window re-summarize everything the broken record covered. The existing test says so. The asymmetry is deliberate and is now documented where it lives.
7d4bdde to
4157dc0
Compare
There was a problem hiding this comment.
Reviewed at 7d4bdde. The doc-only commits pushed since then do not change any line cited below.
This revision adds four mechanisms: the progress gate, the session unwrap, the race check and the deliberate degradation. Three of them have defects the suite cannot see, and two make the feature worse than not having it.
1. AllowAt implements the opposite of its own doc for a grown prompt. The comment two lines above says "A prompt that has actually shrunk, or grown past where it was, is allowed through"; the code is last == 0 || int64(tokens) < last , which admits only a shrink. Once a compaction happens at N tokens, tail retention is refused for the rest of the invocation however large the prompt gets: AllowAt(5100) and AllowAt(50000) after RecordAt(5000) are both false. In a tool loop this is not theoretical — I measured a single invocation reaching 45,056 tokens against a 2,000 threshold, after compaction had visibly shrunk it twice. The same loop with the gate removed peaks at 10,012 for four extra summarizer calls. One value cannot express both "the last compaction did not help" and "it helped and the turn grew again".
Worth knowing before fixing: because promptTokenCount still returns the pre-compaction usage reading on the call immediately after a compaction, the next AllowAt sees tokens == last and declines anyway, so changing < to <= buys exactly one extra firing.
2. The rolling seed backdates a coverage range across the retained tail. Candidates are taken by stream position after the previous compaction record, which is appended after the raw tail it deliberately did not cover; the seed then starts the new range back at the previous range's start. Covered and summarized diverge. Six ordinary turns through the real runner at TokenThreshold: 1000, EventRetentionSize: 2 silently dropped four stored events from every later prompt — they were handed to no summarizer and appear in no summary. This is the coverage-model issue written up on #1231, and it is the sharpest instance of it, because it needs no ties, no concurrency and no unusual configuration.
3. The live turn is summarized out of its own prompt at every retention size Validate() accepts. The new branch closes EventRetentionSize == 0, but the defect is a count-vs-turn mismatch, not a zero: a tool round costs two events. At retention 1 and 2, four of six second-turn prompts lost the user's question; at retention 3, two of six. Excluding the current invocation's events is what actually holds.
4. RecordAt fires before the summarizer runs and is never rolled back. Here , so one transient failure disarms compaction for the whole invocation with nothing stored: a summarizer that 503s once and would then succeed produced one attempt, zero stored events, and a prompt that reached 15,063 against a 1,000 threshold. Combined with 1 and with the degraded error path this is self-concealing — the gate stops retrying and degrade stops reporting.
A few others. RangeRaced builds its "already known" set from the live session handle at check time, and parallelagent sub-agents share that handle, so a sibling's append during the model call reads as pre-existing; its compaction-event branch cannot fire at all, since a concurrently appended record is stamped after everything it covers. selectTailRetentionWindow has no skipBlockedHead fallback, so one unanswered tool call stalls tail retention for the rest of the session — I had 38 compactable events behind one. promptTokenCount scans the whole session with no branch or isolation-scope filter, so a sub-agent whose own prompt is a couple of tokens saw 200,000. The delta estimator counts only part.Text, which is exactly what a tool loop does not grow by — 400,000 characters of function response moved the estimate by nothing. Mid-turn summaries skip RunOnEventCallback while the post-invocation pass runs it. UnwrapSession is an unbounded loop with no cycle guard on the per-model-call path. And Validate() now rejects a TokenThreshold-only config that the previous revision accepted, which apidiff cannot see — defaulting would be the non-breaking choice.
Two doc fixes while you are in there: TailRetention's doc block runs into ProgressGate's with no blank line, so go doc TailRetention prints nothing; and compactionFailure's rationale describes the error being yielded into the flow's error channel, but its only caller is the log.Printf in degrade.
2e10a25 to
391c543
Compare
356537c to
2e72953
Compare
391c543 to
4c461f8
Compare
2e72953 to
880f37f
Compare
karolpiotrowicz
left a comment
There was a problem hiding this comment.
RangeRaced is properly fixed — 8-way parallel fan-out with a barrier now stores 1 record and discards 7, against 8 stored and 28 overlapping pairs before. The gate re-fire is fixed too: the same 30-round tool loop that made 29 summarizer calls now makes 1. Four things left, one of which I would want a doc line for before this ships.
Tail-retention summaries still never reach the plugin pipeline. SanitizeSummary now has a live second call site at compaction_processor.go:128, which closes half of it, but the plugins themselves do not run on this path. With a redaction plugin registered: 1 summary stored, plugin invoked 0 times, the planted secret reaching the session unredacted. The sliding-window path calls RunOnEventCallback and this one does not. The in-tree comment is honest about the gap, but nothing on the exported surface tells someone configuring tail retention alongside a redaction plugin that the plugin will not see these events, and the failure is silent.
The retry fix went one step too far. compactionctx.go:127 gates on !st.failed, :144 sets it on a failure or a decline, and :155 clears only the token marker, so the first failure latches compaction off for the rest of the invocation. Same test on both revisions, one invocation with a 40-round tool loop and a summarizer failing once: before, 2 calls and 1 record stored; now, 1 call and 0 records. Nothing is lost and the post-invocation pass still runs, so this is a worse trade rather than a defect — but it swaps "retry forever" for "never retry" when the useful answer is two or three attempts per invocation. Clearing failed in Recovered is not the fix, since Recovered only fires below the threshold and the prompt does not drop after a failure.
The gate is keyed per agent now (:94), which fixes the loop-agent case. The side effect is that 8 parallel children each pass the check and each call the summarizer, and the race guard then discards 7 of the 8 results. The stored outcome is right and the prompt is right — it is 8× the model spend inside one turn.
Small one: the doc comments at compactionctx.go:245 sit under const runtimeCtxKey and describe AllowAt, with a [Runtime.Recovered] link that does not resolve.
4c461f8 to
5f17c22
Compare
880f37f to
b9894cd
Compare
|
Correcting something I told you on three PRs: the two strategies do compose. I said they did not, and that was an over-generalisation from a single configuration. What I originally measured was
At interval 2 and 3 the call count is exactly the sliding-window count, so tail retention contributes nothing. At interval 10 and up it exceeds it and the prompt is bounded: both strategies running, correctly. The real rule is that tail retention fires only when more events accumulate between sliding-window compactions than Nothing in the code changed as a result, which is the one piece of luck here: I deliberately did not add a There is also a divergence worth recording, which I have not chased down. adk-python's selector returns a usable window at interval 2 with retention 2, and Go's does not: Go excludes events covered by any later record, where Python uses a simple cutoff after the latest record's end, so Go starves across a wider band. My two measurements were not like-for-like (Python's selector called directly, Go end to end through the runner), so I would want to redo that before calling it exact. |
5f17c22 to
5029eb7
Compare
b9894cd to
7a6c64d
Compare
5029eb7 to
a705902
Compare
ca975ec to
d0fc05c
Compare
karolpiotrowicz
left a comment
There was a problem hiding this comment.
The bounded-retry change is the right shape and I verified it through Runner.Run — details on #1232, where the gate lives. trimToTimestampBoundary also checks out now: tieSafeCut and the balance check sit at the same index, so a returned prefix always ends where the open-obligation set was empty, and the two other routes I chased (the backward walk in tail retention, and skipBlockedHead) cannot reintroduce an unbalanced window.
One small thing here. compactionctx_test.go:182 reads:
// The worker is untouched by any of it.
if !worker.AllowAt(2500) {
_ = worker
}There is no t.Error in the branch, so it cannot fail whichever way AllowAt answers, and the cross-agent isolation the comment describes is unpinned. _ = worker also keeps the branch non-empty, so staticcheck stays quiet about it.
The property is worth pinning for real, because per-agent scoping of the failure counter is exactly what stops one sub-agent's failures suppressing another's compaction. Note the worker already recorded a compaction earlier in the test, so AllowAt returns false for it on its own account — asserting through worker cannot express the property. A third gate that never recorded would.
Same scope note as on #1232: I have not finished reviewing the rest of the stack this round, so this is not a complete pass.
a705902 to
de27c3f
Compare
d0fc05c to
9f5cb80
Compare
karolpiotrowicz
left a comment
There was a problem hiding this comment.
The replacement is right. compactionctx_test.go:185-188 asserts through a third gate that has neither compacted nor failed, which is the only way to express the property — and the comment says why the worker could not carry it. Keying every gate to one scope now fails the test, which is the check that matters.
Verified at 9f5cb80: build, the full suite and golangci-lint clean.
karolpiotrowicz
left a comment
There was a problem hiding this comment.
Nothing in this PR's own diff needs changing. Build, the full suite, go vet, gofmt and golangci-lint are all clean at the current head (9f5cb80), and the bystander gate you replaced the no-op assertion with does express the property — keying every gate to one scope fails it, which is the check that matters.
One thing to carry, and it is not yours to fix here. tail_retention.go:389 is the third consumer of coversAfter, and I have left a finding on #1232 about that function reading superseded compaction records. The effect here is that a straggler rescued by RepairAfterAppend is skipped by tail-retention window selection and can never be summarized. Tail retention is the strategy an application turns on when it needs a bound, so this path is where the symptom actually costs something, even though both newCoverIndex and coversAfter live in #1232. Worth re-checking this window once the fix lands upstream.
Also worth a look before this lands, and I could not settle it either way: whether LatestCompactionEvent can select a sibling branch's record and strand a branch's own earlier summary. tail_retention.go:463-468 drops the seed on a branch mismatch, which is right, but I did not establish what happens to the earlier same-branch summary in that case. You will know in a minute whether that is reachable.
The mid-turn repair you added while fixing the round-6 findings is the more valuable half of that change, and the plugin-pipeline gap you documented at compaction_processor.go:124-127 matches Kotlin — neither SlidingWindowEventCompactor.kt nor TokenThresholdEventCompactor.kt runs a plugin hook, and CompactionRequestProcessor.kt has none at all. Both your comments justify it against adk-python, which is not this stack's bar, so the conclusion is right for a reason the comments do not give.
de27c3f to
a48824e
Compare
9f5cb80 to
0e65c8a
Compare
|
Nothing here was on your list; this is a self-review round before I ask you to Tail retention drew its window from the wrong conversation. The corrective write no longer dies with the caller. Storing a summary and A panicking summarizer could take the process down. One I have not fixed, deliberately. When the only uncovered candidates ahead Review |
karolpiotrowicz
left a comment
There was a problem hiding this comment.
The three you found here all check out, and the placement decisions are the ones I would want. RepairContext is the right idiom — context.WithTimeout(context.WithoutCancel(ctx), repairTimeout) keeps the caller's values, drops its cancellation, and bounds the write — and both call sites take it with defer cancel(), so the two-phase window really is closed on both paths rather than the one you happened to find first. Same for putting the panic recovery in compactAfterInvocation rather than in its two copied callers.
The candidate list not applying promptTokenCount's branch and scope filter is the one I would have most regretted missing. A sub-agent spending its one allowed compaction on a sibling branch, not shrinking its own prompt by a token, and then standing the progress gate down for the rest of an over-threshold turn is the bound switching itself off, and it would have looked like tail retention simply not working.
Deferring the narrower-seed case is the right call. It costs a wasted model call rather than data, it is self-limiting, and both available fixes carry hole-inheritance requirements whose failure mode is deleting conversation — which is not what you want as a late edit in a round that already rewrites the copy path and the cover index. Worth a tracked follow-up so it does not get lost.
Build, the full suite over 157 packages, go vet, gofmt and -race are all clean at 0e65c8ae. golangci-lint is not: it reports the same two reflect.Ptr findings in compactor_test.go that #1232 carries, since the file arrives through the base. Fixing it there fixes it here.
I have left one finding on #1232 that reaches this PR too — the subsumed-record guard in newCoverIndex is not pinned by any test, and tail retention is the consumer where it matters most.
karolpiotrowicz
left a comment
There was a problem hiding this comment.
Approving at 0e65c8a. Nothing in this PR's own diff needed changing, and the three defects you found here yourself are the ones that mattered: the candidate list not applying promptTokenCount's branch and scope filter would have looked like tail retention simply not working, rather than like the bound switching itself off.
Carrying forward from #1232 rather than holding this: the subsumption guard in newCoverIndex is not pinned by any test, and tail retention is the consumer where that matters most. The narrower-seed case you deferred is the right call to defer.
a48824e to
063caab
Compare
0e65c8a to
3b722e3
Compare
|
Rebased on the #1232 fixes at Both follow-ups you carried forward are addressed there: One correction, detailed on #1232: the case you described for the guard — a record subsumed by a strictly wider range with different content — is not reachable, because subsumption requires the subsuming record to sit later in the stream, so it always has both a later positional guard and a superset range. The reachable case is an identical range with different summary text, where subsumption is decided by position without comparing holes while |
…reshold The sliding window replaces each group of invocations with one summary, and summaries are never re-summarized, so it reduces prompt size by a constant factor rather than bounding it. Tail retention is what bounds it. It runs inside an invocation, before a model call, once the prompt passes TokenThreshold. It summarizes everything but the most recent events and seeds each new window with the previous summary, so history stays one rolling summary plus a raw tail however long the conversation runs. Because it runs mid-turn it also catches a single long tool-calling turn that inflates the prompt on its own, which a post-invocation strategy cannot see until the turn is over. The live turn's own question is held back from the window. Summarizing it means summarizing the instruction currently being carried out, and EventRetentionSize cannot protect it, because it counts events and a turn is not a fixed number of them. Enable this or the sliding window, not both. They share a candidate rule: tail retention summarizes the events no compaction already covers, and the sliding window covers everything it reaches, so with both enabled tail retention never finds enough uncovered events to fire. The package documentation says so, with the measurements. adk-python starves its own token-threshold strategy the same way.
063caab to
f98ca49
Compare
3b722e3 to
982b3e5
Compare
|
Rebase, plus the other half of one change from #1232.
Recording it because I got this wrong first: I read the gate as an unconditional one-compaction-per-invocation latch and was about to change it. It is not. Shared context for this round. The whole stack was rebased onto current
Every slice was rebuilt from the rebased tree and verified to build, lint and test on its own. The full suite is clean. |
Third of five. Re-cut: this PR previously showed code that later PRs
corrected, and its base moved from
compaction-03-telemetry(now closed) tocompaction-02-sliding-window.What this adds
Tail retention: the strategy that actually bounds prompt size.
The sliding window in #1232 replaces each group of invocations with one summary,
and summaries are never re-summarized, so it reduces prompt size by a constant
factor rather than bounding it. Tail retention seeds each new window with the
previous summary, so history stays one rolling summary plus a raw tail however
long the conversation runs.
It runs inside an invocation, before a model call, once the prompt passes
TokenThreshold. Running mid-turn is what lets it catch a single longtool-calling turn that inflates the prompt on its own, which a post-invocation
strategy cannot see until the turn is over.
Two things worth reviewing closely
The live turn's question is held back. Summarizing it means summarizing the
instruction currently being carried out, and
EventRetentionSizecannot protectit, because it counts events and a turn is not a fixed number of them: one tool
round costs two. Only that one event is held back, so a long tool loop can still
compact its own traffic.
A blocked head does not stall it. One unanswered call among parallel
siblings used to stop the strategy for the rest of the session, silently, since
"no window" and "nothing to do yet" are both nil. The scan now resumes wherever
the set of open obligations changed, not only where it grew.
Enable this or the sliding window, not both
They share a candidate rule. Tail retention summarizes the events no compaction
already covers, and the sliding window covers everything it reaches, so with
both enabled tail retention never finds enough uncovered events to fire and the
ceiling never applies. Measured over 160 turns with a 520-character summary:
tail retention alone holds the prompt flat at about 550 characters, the two
together grow it to 41,000.
This is documented rather than rejected in
Validate. adk-python starves itsown token-threshold strategy the same way, so it is the shared design, and
AGENTS.md makes adk-python the source of truth for behaviour.
Testing plan
go build,go test -race -count=1 -shuffle=on,golangci-lint runandgo mod tidy -diffclean in both modules, verified at this slice specifically.Known-unrelated failure:
TestConfigureExportersin./telemetry.Changed since the last review
Your first finding reproduces, and I was wrong to say otherwise. I reported
that I could not make the rolling summary fail end to end and asked for your
scenario. My probes never built it: the live-head exclusion only lands inside
the record's range when the held-back question has events after it, and a plain
text turn puts the question last. Add a prior compaction and a tool-calling turn
and it happens exactly as described. Fixed by
coversAllOfon #1232, andTestSelectTailRetentionWindowSeedsWhileHoldingBackTheLiveHeadis the combinedhold-back-plus-seed case that was missing.
The race guard gets a real before-state.
KnownEventIDscaptures identitiesbefore the model call and
RangeRacedSincecompares against those, rather thanagainst a live handle that already contains whatever arrived during it.
The progress gate took three attempts to get right, which is worth knowing.
Closing it only in the
Finishcallback reached neither failure that actuallyhappens, so a failing summarizer still cost 29 summarizer calls across a
30-round tool loop; it is 1 now. Then treating a discard as a failure was worse
than the bug it replaced, because a competing compaction landing is benign and
nothing can reopen a gate closed that way. Then closing it on an empty window
took the storm to 0 rather than 1, because an empty window is "not yet" on a
turn that keeps appending. Five subtests pin which outcomes close it.
The gate is keyed per agent, by name, branch and isolation scope. Branch
alone gave a loop agent's worker and critic one gate, which is the case the
scoping exists for: only a parallel agent gets a distinct branch.
Tail-retention summaries pass through
SanitizeSummary. They still do notrun the plugin pipeline, and that now looks correct rather than pending:
adk-python runs no plugin hook on either compaction path, so this matches it and
it is the sliding-window path that goes further.
Still open, and deliberately: window selection is linear in session length now
rather than quadratic, but the mid-turn path still walks the session before each
model call.
Notes for an automated reviewer
Review this exact commit:
982b3e5c63fde3b80facb7bcc2777a16b18e38b4. Check out the SHA, not a branchname. Several branches in this repository have similar names and hold older
versions of this same code:
baptmont/compaction-03-telemetry(ff5b84bb)and
baptmont/compaction-07-review-fixes(888ee5c1) belong to closed PRsfrom an earlier shape of the stack, and the pre-re-cut tips are still reachable.
Two agents auditing this stack have already spent a full run on the wrong tree
and reported bugs that no longer exist. If
internal/compactioninternalhas nocoverIndexand noRepairAfterAppend, you are on the wrong commit.Deliberate decisions, so they are not re-reported as defects. Each is
documented at the code:
HasUsableSummaryis only a nil check. A record whose content holds no prosestill covers its range and deletes those events. The stricter check was
written and reverted: adk-python checks only
is None, this stack tracks it,and nothing here can produce such a record.
plugin hook on either compaction path, so this matches it.
repair would need another, and each round strictly shrinks what the record
covers on both axes -- the timestamp range and the stream position. It shrinks
only the range if the positional guard is taken from the surviving record, and
that was a real defect, fixed in round 6 by
foldCorrections.session/databaseorders ties byid, which is stable but arbitrary. Trueinsertion order needs a sequence column, which Vertex AI would not carry.
documented as leaving the prompt unbounded, and adk-python behaves the same.
Known flakes, both unrelated to this stack and both passing on re-run:
TestDelegation_04_ChatToTwoSingleTurnParallel(issue #1313) and an occasionalgolangci-lint runner error reporting
no export data for github.com/google/uuid.Verification: build,
go test -race -count=1 -shuffle=on,golangci-lint runandgo mod tidy -diffare clean at this commit in bothmodules, and CI runs on this stack now.
Decisions taken since round 3, so they are not re-reported
Each is documented at the code. Raise them if you disagree, but they are choices
rather than oversights:
gate and seven summaries are discarded. The output and the prompt are correct;
it is model spend. Making siblings share a gate walks back the per-agent
scoping that fixed the loop-agent case, so it stands.
session/databasebreaks a timestamp tie onid DESC. Two random UUIDs,which is not conversational order. adk-python orders identically, and after
the repair fix nothing correctness-critical depends on tie order.
not. adk-python runs no plugin hook on either, so the first is a deliberate
divergence in the safe direction and the second matches the reference. The gap
is documented on the exported surface, at
compaction.Config.TokenThreshold.cannot tell a re-summarization from two independent summaries that happen to
span one instant, because the record stores holes rather than membership.
Preferring the later one keeps the documented property that a re-summarized
range never appears twice, at the cost of the rarer case. Holes are unioned
across records that share a range and the same summary text, which is what a
correction is, so a correction cannot lose its straggler to an ordering tie.
TestNoEventIsDroppedWithoutHavingBeenSummarizedis breadth, not asubstitute for the targeted tests. It found a real unsoundness during
development, and it does not currently generate the shapes that reproduce the
tie-safe cut, the hole union or the content check. Its comment says so.
raw_eventVertex recordings pin a wholesession.EventJSON dumpand are brittle to any new field on that struct. Out of scope here: not
compaction code.
Round 6 — self-review before re-requesting review
Four independent reviewers were run over the stack before asking for another
round. Every fix below is mutation-tested: the mutation that reproduces the
original defect is named in the test's doc comment.
Two defects that deleted conversation.
copyAnyreturned any payload that was notmap[string]any/[]anyuntouched. A tool payload here is not decoded JSON -- it is whatever a Go
handler returned, stored as-is -- so
loadmemorytool, which stores[]memory.Entryholding a*genai.Content, handed a summarizer live pointersinto stored history. Now a depth-bounded reflective deep copy.
on the timestamp axis and moves forward on the stream axis; the positional
guard used the survivor's position, so an event appended between a record and
its correction was outside the original's reach and inside the survivor's.
Holes were already folded across the correction group and position was not;
foldCorrectionsnow folds both, taking the group's earliest position.Four more.
newCoverIndexindexed contentless records that prompt assemblyskips, leaving events permanently raw and permanently uncompactable at once;
tail retention drew its window from the whole session while the threshold that
triggered it was scope-filtered, so a sub-agent could spend its one compaction on
a sibling branch; token usage was dropped on the error path, so the most
expensive failure mode reported zero spend; and the corrective write ran on the
caller's context, so a cancellation between the two appends left a record
claiming a range nothing had summarized.
Four tests that could not fail were found and strengthened, including the
alias guard itself, which compared only top-level
genai.Partpointers and sowas blind to a fresh
Blobholding the original's byte slice.Known to remain. When the only uncovered candidates ahead of the retained
tail sit inside the previous range -- what a repair manufactures -- the rolling
seed can produce a range strictly narrower than the record it replaces, so
neither subsumes the other and both summaries materialize in one prompt. It is
self-limiting and costs a wasted compaction rather than data. Left deliberately:
both candidate fixes carry hole-inheritance subtleties whose failure mode is
deleting conversation, so it wants its own change and its own review.