Repository navigation
feat(go-adk): add compaction support to Go ADK runtime - #1971
yashrajshuklaaa wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR adds post-invocation “context compaction” support to the Go runtime by introducing a compaction package, wiring it into the A2A executor, and enabling in-memory session event replacement.
Changes:
- Added
go/adk/pkg/compactionwith configuration parsing and compaction/summarization logic. - Extended runner creation to return a compaction config and wired compaction into
KAgentExecutor.Execute. - Added
ReplaceEventsto local sessions to allow atomic in-memory event history rewrites after persisting a summary.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| go/core/internal/controller/reconciler/reconciler.go | Updates runtime feature notes to reflect compaction support. |
| go/adk/pkg/session/local_session.go | Adds an atomic ReplaceEvents method used by compaction. |
| go/adk/pkg/runner/adapter.go | Returns compaction config from CreateRunnerConfig via compaction.FromAgentConfig. |
| go/adk/pkg/compaction/compaction.go | Introduces compaction implementation and AgentConfig-to-Config translation. |
| go/adk/pkg/compaction/compaction_test.go | Adds initial unit tests for compaction helpers and nil-compactor behavior. |
| go/adk/pkg/a2a/executor.go | Instantiates a compactor and runs post-invocation compaction. |
| go/adk/cmd/main.go | Plumbs compaction config into executor and logs when enabled. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Implements post-invocation event history compaction for the Go ADK runtime. The upstream Go ADK v1.1.0 does not ship this so it is implemented within kagent via a new compaction package Closes kagent-dev#1783 Signed-off-by: Yashraj Shukla <[email protected]>
ff168bb to
0b561b3
Compare
|
@iplay88keys @jmhbh @supreme-gg-gg PTAL |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
go/adk/pkg/runner/adapter.go:1
CreateRunnerConfignow has a breaking signature change by adding*compaction.Configas a new return value. If this function is used outside this package (it’s exported), this will force cascading changes for all callers. Consider a non-breaking approach such as (a) introducing a new function (e.g.,CreateRunnerConfigWithCompaction) or (b) embedding compaction config intorunner.Config/an options struct to avoid widening the return tuple.
package runner
| func buildSummaryEvent(summaryText string) *adksession.Event { | ||
| e := &adksession.Event{ | ||
| ID: uuid.NewString(), | ||
| InvocationID: "compaction_" + uuid.NewString(), | ||
| Timestamp: time.Now(), | ||
| Author: summaryEventAuthor, | ||
| } | ||
| e.LLMResponse = model.LLMResponse{ | ||
| Content: &genai.Content{ | ||
| Role: "model", | ||
| Parts: []*genai.Part{{Text: summaryText}}, | ||
| }, | ||
| } | ||
| return e | ||
| } |
| func serializeEvents(events []*adksession.Event) string { | ||
| var sb strings.Builder | ||
| for _, e := range events { | ||
| if e.Content == nil { | ||
| continue | ||
| } | ||
| role := e.Author | ||
| if role == "" { | ||
| role = e.Content.Role | ||
| } | ||
| for _, p := range e.Content.Parts { | ||
| if p == nil { | ||
| continue | ||
| } | ||
| switch { | ||
| case p.Text != "": | ||
| fmt.Fprintf(&sb, "[%s]: %s\n", role, p.Text) | ||
| case p.FunctionCall != nil: | ||
| fmt.Fprintf(&sb, "[%s] called tool %q\n", role, p.FunctionCall.Name) | ||
| case p.FunctionResponse != nil: | ||
| fmt.Fprintf(&sb, "[tool %s response]\n", p.FunctionResponse.Name) | ||
| } | ||
| } | ||
| } | ||
| return sb.String() | ||
| } |
| if len(invocations) >= c.cfg.CompactionInterval { | ||
| trigger = true | ||
| log.V(1).Info("Compaction triggered by interval", | ||
| "invocations", len(invocations), | ||
| "threshold", c.cfg.CompactionInterval) | ||
| } | ||
|
|
||
| if !trigger && c.cfg.TokenThreshold > 0 { | ||
| tokens := lastTokens | ||
| if tokens == 0 { | ||
| tokens = estimateTokens(allEvents) | ||
| } | ||
| if tokens >= c.cfg.TokenThreshold { | ||
| trigger = true | ||
| log.V(1).Info("Compaction triggered by token threshold", | ||
| "tokens", tokens, | ||
| "threshold", c.cfg.TokenThreshold) | ||
| } | ||
| } |
| keepCount := c.cfg.OverlapSize | ||
| if keepCount >= len(invocations) { | ||
| return nil | ||
| } | ||
|
|
||
| toCompact := invocations[:len(invocations)-keepCount] |
| if err := sessionSvc.AppendEvent(ctx, sess, summaryEvent); err != nil { | ||
| return fmt.Errorf("compaction: failed to persist summary event: %w", err) | ||
| } | ||
|
|
||
| var keepEvents []*adksession.Event | ||
| for _, inv := range invocations[len(invocations)-keepCount:] { | ||
| keepEvents = append(keepEvents, inv.events...) | ||
| } | ||
| // Apply EventRetentionSize cap if configured. | ||
| if c.cfg.EventRetentionSize > 0 && len(keepEvents) > c.cfg.EventRetentionSize { | ||
| keepEvents = keepEvents[len(keepEvents)-c.cfg.EventRetentionSize:] | ||
| } | ||
| replaceSessionEvents(sess, summaryEvent, keepEvents) |
| // 10b. Post-invocation compaction (no-op when not configured). | ||
| // TODO: avoid the extra GetSession call by threading the session object through Execute. | ||
| if e.compactor != nil && e.sessionService != nil { | ||
| liveSess, sessErr := e.sessionService.GetSession(ctx, e.appName, userID, sessionID) | ||
| if sessErr == nil && liveSess != nil { | ||
| if compactErr := e.compactor.MaybeCompact(ctx, liveSess, e.sessionService, 0); compactErr != nil { | ||
| e.logger.Error(compactErr, "Post-invocation compaction failed (non-fatal)") | ||
| } | ||
| } | ||
| } |
| if text == "" { | ||
| t.Fatal("expected non-empty serialized text") | ||
| } | ||
| if len(text) == 0 { | ||
| t.Fatal("serialized text should not be empty") | ||
| } |
EItanya
left a comment
There was a problem hiding this comment.
Thanks for taking this on. Adding Go-runtime compaction is a useful direction, and the current PR is a helpful starting point. I took a closer look against the Python ADK compaction path, and I think there is one important design gap to resolve before merging.
The main issue is that this does not yet match Python ADK compaction semantics. Python ADK does not durably delete compacted raw events. It appends a special compaction marker event with actions.compaction containing the compacted time range and compacted content, then prompt construction later uses that marker to replace covered raw events with the summary. In this PR, the Go runtime appends a normal model-looking summary event and trims only the in-memory local session. After a fresh GetSession, the persisted store still has the raw events plus the summary, and Go ADK has no marker-aware filtering step, so the next model request can see both raw history and the summary.
That means I would treat the durable-store Copilot concern as real, but I would not solve it by deleting old backend rows unless we intentionally want different semantics from Python. A closer match would be to persist some compaction marker/range and add a Go-side prompt-time filtering layer, or otherwise wrap/filter session events before Go ADK builds LLM contents.
A few related differences from the Python implementation also seem worth addressing:
- compaction_interval in Python means number of new invocations since the last compaction. The current Go code checks total grouped invocations, which can repeatedly compact the same logical history after reload or behave differently depending on in-memory truncation.
- summarizer_model appears to be parsed but not used to construct SummarizerLLM. Python builds an LlmEventSummarizer from the configured model, and otherwise initializes one from the agent model.
- token_threshold and event_retention_size are validated as a pair in Python. The Go implementation currently accepts partial token config and applies event retention to the kept events after interval compaction too, while Python uses retention for token-threshold compaction.
- Python takes care not to compact pending function calls or split function call/function response pairs. The Go implementation serializes calls/responses into text and can drop the structured pairing from the retained context.
The smaller Copilot comments about defensive copying, sentinels, and test cleanup are either already addressed or easy to handle. The extra GetSession call is a performance concern, but I think it is secondary to getting the compaction model aligned with Python first.
Suggested acceptance test: create enough persisted events to trigger compaction, run compaction, reload the session from the backing service, then verify that the next model request sees summary plus retained events rather than raw compacted history plus summary. That would catch the main semantic issue here.
Again, thank you for pushing this forward. I think the feature is valuable; it just needs the durable marker/filtering piece before it will behave like the existing Python ADK path.
|
@EItanya thanks for the detailed explanation , it really helped me understand what the correct implementation should look like So i checked the Go ADK module to see if the same primitives exist there. Went through every released version from v0.1.0 all the way to v1.4.0 , searching for EventCompaction , CompactedContent , StartTimestamp and any other naming variation I could think of. Nothing. The EventActions struct in Go ADK doesn't have a Compaction field at all and contents_processor.go has no filtering step for compaction markers either |
|
so i think the right move is to contribute to EventCompaction support upstream to google.golang.org/adk first by adding the struct to EventActions and the filtering logic in contents_processor.go to mirror what Python ADK does |
|
@EItanya opened an issue at https://github.com/google/adk-go after that we can continue with this |
|
This pull request has been marked as stale because of no activity in the last 15 days. It will be closed in the next 5 days unless it is tagged "no stalebot" or other activity occurs. |
## Problem Since 0.10.0 the Agent CRD defaults `spec.declarative.runtime` to `go`, and the Go runtime never read `spec.declarative.context.compaction`. The controller compiles the field into the agent's `config.json` (`context_config.compaction`) and the Python runtime consumes it, but a Go agent grew its prompt with every turn regardless of what the operator configured; the only signal was the `UnsupportedFeatures` condition (kagent-dev#2728). Measured on an internal installation running 0.10.1: an agent session grew from 4k to 190k input tokens per model call over 119 calls, and every Go agent there ran without compaction because the runtime ignored the field. `google.golang.org/adk/v2` v2.3.0 (2026-08-31) ships native compaction (`session/compaction`: sliding window, tail retention, LLM summarizer) and the `runner.Config.Compaction` hook, so the runtime can consume it instead of carrying its own port of the algorithm, which is what kagent-dev#1971 and kagent-dev#2025 attempted before the primitives existed. ## Change - **Dependency:** `google.golang.org/adk/v2` v2.2.0 → v2.3.0. The OpenTelemetry exporters and contrib instrumentation move to the matching v1.45.0 / v0.21.0 / v0.70.0 set, which the new ADK version requires for `otel/log`. otel/log v0.21.0 hands log bodies as attribute.Value; the telemetry test on main follows that in the same commit. No other kagent code needed changes for the bump. - **Runtime:** `agent.CompactionConfig` translates `context_config.compaction` into a `compaction.Config` and `runner.CreateRunnerConfig` hands it to the ADK runner. `compaction_interval` / `overlap_size` drive the sliding window, `token_threshold` / `event_retention_size` tail retention; either strategy is off when its settings are unset, so an agent without `context_config` runs exactly as before. - **Half-configured settings** are ignored with a warning rather than rejected: `token_threshold` without `event_retention_size` (and the reverse), `overlap_size` without an interval. That mirrors the Python runtime, where adk-python runs tail retention only when both settings are present. What remains is validated at startup with the runner's own rules, so a configuration the runner would refuse (negative values, no strategy at all) fails fast with the reason instead of on the first turn. - **Summarizer:** `summarizer_model` builds a `compaction.LLMSummarizer` through the same model factory as the agent (`CreateLLM`), with the model's generation config and a 60 s bound per call (the same bound the runner applies to its default summarizer). A `prompt_template` gets the tool-name instruction the Python runtime appends (kagent-dev#1907), so summaries keep namespaced tool names; a template without a summarizer model runs on the agent's own model. With neither, the runner summarizes with the agent's model and ADK's default prompt, matching the Python runtime. - **Observability:** startup logs `context compaction enabled` with the resolved values and the summarizer model, or `context compaction not configured`, so an operator can see from the agent log what the runtime does with the field. - **API:** `main` had no field for compaction in the v1alpha3 API (the 0.10 line has `spec.declarative.context`), so nothing produced `context_config`. `spec.kagent.compaction` on the `Harness` adds it, next to `spec.kagent.memory`: `compactionInterval` / `overlapSize` for the sliding window, `tokenThreshold` / `eventRetentionSize` for tail retention, and an optional `summarizer` with a `modelConfigRef` (a `ModelConfig` in the Harness namespace) and a `promptTemplate`. It is runtime policy rather than portable agent behaviour (specific to the ADK runner; the Claude and Codex harnesses have nothing to apply it to), which is why it lives on the Harness and not on the `AgentTemplate`. The CRD rejects at admission what the runtime would refuse at startup: no strategy, `tokenThreshold` without `eventRetentionSize` (and the reverse), `overlapSize` without `compactionInterval`, a `promptTemplate` without `{conversation_history}`. - **Controller:** the kagent compiler translates the Harness's compaction policy into `context_config` in `config.json` of every root agent the Harness runs. A summarizer `ModelConfig` other than the agent's own is resolved from the Harness namespace like the memory model, so its credentials and egress join the revision and a change to it compiles a new revision; the agent's own model stays implicit because the runtime already summarizes with it by default. Compaction records travel on `EventActions.Compaction`; the local DurableDir session store persists `Actions` as a whole (ADK's `session/database`), so a summary survives an actor restart and the same range is not summarized again. The 0.10 line, where the field already exists on the Agent CRD and the reconciler flagged it as unsupported, is kagent-dev#2791. ## Testing - `go test ./adk/pkg/agent/`: `TestCompactionStrategies` (table: each strategy alone and combined, unset settings, the three half-configured cases, no strategy, negative values), `TestCompactionConfig` (not configured → nil; default summarizer; dedicated summarizer model; custom prompt on the agent model; prompt without `{conversation_history}` rejected; prompt without any model rejected; the `context_config` block as the controller compiles it), `TestCompactionSlidingWindowFiresAfterInterval` (a scripted model drives the ADK runner: no record after turn 1, one record after turn 2 whose content is the summarizer's reply, and turn 3's request shows the summary and none of the compacted turns), `TestCompactionTailRetentionFiresOverTokenThreshold`. - `go test ./core/internal/translator/...`: `TestApplyCompaction` (not configured → no `context_config`; all four settings and a prompt on the agent model → mapped with no `summarizer_model`; a dedicated summarizer `ModelConfig` → its model in `config.json`, its credential in the environment, its host in the egress, itself in the provenance models; a missing summarizer `ModelConfig` → error). - `go test ./api/v1alpha3/` (envtest): the five new CEL cases reject each half configuration with its message, and the fully configured Harness is accepted. - `make -C go test`: all packages pass. - `make -C go lint`: 0 issues. ## End-to-end `TestAgentInstanceContextCompaction` (in the `test-e2e` job) runs the whole chain with `mockllm` as the summarizer: a clone of the suite's `kagent` Harness with `compactionInterval: 2` and a dedicated summarizer `ModelConfig`, admitting a plain `AgentTemplate`; both models point at one mock LLM behind a recording proxy on the test host; a default header on each `ModelConfig` tells the two models apart. Three turns through the public A2A API, then the recorded requests show what the runtime sent: the summarizer model was called exactly once, after the second turn, with both turns in the transcript and the custom prompt; the second turn's request still carried the first turn raw; the third turn's request carries the summary and none of the four compacted messages. Run before pushing against a local kind cluster with Substrate 0.0.26 and the controller and Go ADK images built from this branch: passes in 4.9 s. The real-model run (kind, images built from the branch, Anthropic `claude-haiku-4-5`, a five-turn tool-using conversation: turn 5 needs 2,524 prompt tokens with compaction against 50,247 without) was done on the 0.10 backport kagent-dev#2791. The runtime code is the same on both branches apart from the logging package (`log/slog` here, logr on 0.10). Refs kagent-dev#2728 (closed by kagent-dev#2791 for the 0.10 line; on `main` the field returns with the v1alpha3 API). --------- Signed-off-by: Timo Derstappen <[email protected]>
## Problem Since 0.10.0 the Agent CRD defaults `spec.declarative.runtime` to `go`, and the Go runtime never read `spec.declarative.context.compaction`. The controller compiles the field into the agent's `config.json` (`context_config.compaction`) and the Python runtime consumes it, but a Go agent grew its prompt with every turn regardless of what the operator configured; the only signal was the `UnsupportedFeatures` condition (kagent-dev#2728). Measured on an internal installation running 0.10.1: an agent session grew from 4k to 190k input tokens per model call over 119 calls, and every Go agent there ran without compaction because the runtime ignored the field. `google.golang.org/adk/v2` v2.3.0 (2026-08-31) ships native compaction (`session/compaction`: sliding window, tail retention, LLM summarizer) and the `runner.Config.Compaction` hook, so the runtime can consume it instead of carrying its own port of the algorithm, which is what kagent-dev#1971 and kagent-dev#2025 attempted before the primitives existed. ## Change - **Dependency:** `google.golang.org/adk/v2` v2.2.0 → v2.3.0. The OpenTelemetry exporters and contrib instrumentation move to the matching v1.45.0 / v0.21.0 / v0.70.0 set, which the new ADK version requires for `otel/log`. otel/log v0.21.0 hands log bodies as attribute.Value; the telemetry test on main follows that in the same commit. No other kagent code needed changes for the bump. - **Runtime:** `agent.CompactionConfig` translates `context_config.compaction` into a `compaction.Config` and `runner.CreateRunnerConfig` hands it to the ADK runner. `compaction_interval` / `overlap_size` drive the sliding window, `token_threshold` / `event_retention_size` tail retention; either strategy is off when its settings are unset, so an agent without `context_config` runs exactly as before. - **Half-configured settings** are ignored with a warning rather than rejected: `token_threshold` without `event_retention_size` (and the reverse), `overlap_size` without an interval. That mirrors the Python runtime, where adk-python runs tail retention only when both settings are present. What remains is validated at startup with the runner's own rules, so a configuration the runner would refuse (negative values, no strategy at all) fails fast with the reason instead of on the first turn. - **Summarizer:** `summarizer_model` builds a `compaction.LLMSummarizer` through the same model factory as the agent (`CreateLLM`), with the model's generation config and a 60 s bound per call (the same bound the runner applies to its default summarizer). A `prompt_template` gets the tool-name instruction the Python runtime appends (kagent-dev#1907), so summaries keep namespaced tool names; a template without a summarizer model runs on the agent's own model. With neither, the runner summarizes with the agent's model and ADK's default prompt, matching the Python runtime. - **Observability:** startup logs `context compaction enabled` with the resolved values and the summarizer model, or `context compaction not configured`, so an operator can see from the agent log what the runtime does with the field. - **API:** `main` had no field for compaction in the v1alpha3 API (the 0.10 line has `spec.declarative.context`), so nothing produced `context_config`. `spec.kagent.compaction` on the `Harness` adds it, next to `spec.kagent.memory`: `compactionInterval` / `overlapSize` for the sliding window, `tokenThreshold` / `eventRetentionSize` for tail retention, and an optional `summarizer` with a `modelConfigRef` (a `ModelConfig` in the Harness namespace) and a `promptTemplate`. It is runtime policy rather than portable agent behaviour (specific to the ADK runner; the Claude and Codex harnesses have nothing to apply it to), which is why it lives on the Harness and not on the `AgentTemplate`. The CRD rejects at admission what the runtime would refuse at startup: no strategy, `tokenThreshold` without `eventRetentionSize` (and the reverse), `overlapSize` without `compactionInterval`, a `promptTemplate` without `{conversation_history}`. - **Controller:** the kagent compiler translates the Harness's compaction policy into `context_config` in `config.json` of every root agent the Harness runs. A summarizer `ModelConfig` other than the agent's own is resolved from the Harness namespace like the memory model, so its credentials and egress join the revision and a change to it compiles a new revision; the agent's own model stays implicit because the runtime already summarizes with it by default. Compaction records travel on `EventActions.Compaction`; the local DurableDir session store persists `Actions` as a whole (ADK's `session/database`), so a summary survives an actor restart and the same range is not summarized again. The 0.10 line, where the field already exists on the Agent CRD and the reconciler flagged it as unsupported, is kagent-dev#2791. ## Testing - `go test ./adk/pkg/agent/`: `TestCompactionStrategies` (table: each strategy alone and combined, unset settings, the three half-configured cases, no strategy, negative values), `TestCompactionConfig` (not configured → nil; default summarizer; dedicated summarizer model; custom prompt on the agent model; prompt without `{conversation_history}` rejected; prompt without any model rejected; the `context_config` block as the controller compiles it), `TestCompactionSlidingWindowFiresAfterInterval` (a scripted model drives the ADK runner: no record after turn 1, one record after turn 2 whose content is the summarizer's reply, and turn 3's request shows the summary and none of the compacted turns), `TestCompactionTailRetentionFiresOverTokenThreshold`. - `go test ./core/internal/translator/...`: `TestApplyCompaction` (not configured → no `context_config`; all four settings and a prompt on the agent model → mapped with no `summarizer_model`; a dedicated summarizer `ModelConfig` → its model in `config.json`, its credential in the environment, its host in the egress, itself in the provenance models; a missing summarizer `ModelConfig` → error). - `go test ./api/v1alpha3/` (envtest): the five new CEL cases reject each half configuration with its message, and the fully configured Harness is accepted. - `make -C go test`: all packages pass. - `make -C go lint`: 0 issues. ## End-to-end `TestAgentInstanceContextCompaction` (in the `test-e2e` job) runs the whole chain with `mockllm` as the summarizer: a clone of the suite's `kagent` Harness with `compactionInterval: 2` and a dedicated summarizer `ModelConfig`, admitting a plain `AgentTemplate`; both models point at one mock LLM behind a recording proxy on the test host; a default header on each `ModelConfig` tells the two models apart. Three turns through the public A2A API, then the recorded requests show what the runtime sent: the summarizer model was called exactly once, after the second turn, with both turns in the transcript and the custom prompt; the second turn's request still carried the first turn raw; the third turn's request carries the summary and none of the four compacted messages. Run before pushing against a local kind cluster with Substrate 0.0.26 and the controller and Go ADK images built from this branch: passes in 4.9 s. The real-model run (kind, images built from the branch, Anthropic `claude-haiku-4-5`, a five-turn tool-using conversation: turn 5 needs 2,524 prompt tokens with compaction against 50,247 without) was done on the 0.10 backport kagent-dev#2791. The runtime code is the same on both branches apart from the logging package (`log/slog` here, logr on 0.10). Refs kagent-dev#2728 (closed by kagent-dev#2791 for the 0.10 line; on `main` the field returns with the v1alpha3 API). --------- Signed-off-by: Timo Derstappen <[email protected]>
Implements post-invocation event history compaction for the Go ADK runtime
The upstream Go ADK v1.1.0 does not ship this so it is implemented within kagent via a new compaction package
Closes #1783