João Westerberg
90d · built 2026-09-08
Performance
What João Westerberg shipped in the selected window, measured in ETV, and how it compares with the 90 days before it.
Effective capacity
+1.3engineers
delivers like 2.3 (2.3x pre-AI)
Output (ETV)
12.8ETV
+137.0% vs 5.4 prior
Features share
28.9%
−15.1 pp vs prior window
Fixes share
2.9%
−0.8 pp vs prior window
Work mix
28.9% Features0.5% Maintenance62.6% Tests5.1% Docs2.9% Fixes
12 commits over 90 days, ending 2026-09-08.
Daily performance
Daily ETV, stacked by Features, Maintenance, Tests, Docs and Fixes.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 10 by ETV in the last 90 days.
- 6.7ETVfeat(compaction): summarize completed invocations as the conversation grows (#1232) Adds the compaction library, the engine behind it, and the sliding-window strategy the runner drives once an invocation finishes. compaction.Config is the public surface: which strategy to run, and a Summarizer to run it with. A Summarizer returns content and token usage rather than a finished event, so third-party code cannot declare an authorship, a state delta, an agent transfer or the range of history to delete. The framework builds the event and derives the range from the events it handed over. The default LLMSummarizer carries a timeout, an allow-list of generation settings, and a check that the generation was not truncated or filtered, because a truncated summary stored as a real one silently loses the turns it covers. The engine keeps one definition of coverage: inside the recorded range and not named as a hole. Window selection filters events out of the middle of its own span, by branch, by isolation scope and by what a retained tail holds back, so a range alone would cover gaps that nothing summarized. Prompt assembly substitutes a summary for the events it covers and leaves the rest raw, and an unanswered tool call is never separated from its response. A summarizer sees copies built field by field from what a summarizer is for, not the session's live events, so it cannot rewrite stored history through a pointer it was handed. A race guard re-reads the session before and after the plugin pass and discards a summary if anything landed inside its range while it was being produced. Telemetry is here rather than in a slice of its own because the compactor calls it directly. The span stays open until the caller reports what became of the summary, so the five discard paths no longer report success for an event that reached no session. Compaction never modifies or deletes history. It appends, and only the prompt shrinks.github.com-google-adk-go · e9da6686 · 2026-08-31
- 2.4ETVfeat(compaction): compact mid-invocation once the prompt crosses a threshold (#1234) 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.github.com-google-adk-go · 664cec63 · 2026-08-31
- 1.7ETVfeat(server): make context compaction reachable from every serving surface (#1235) * feat(session): carry a context-compaction record on a session event Adds session.EventCompaction and session.EventRef, the stored shape a compaction summary takes, and makes every backend keep it faithfully. A summary is an ordinary event whose Actions.Compaction names the timestamp range it stands in for, the events inside that range it does not stand in for, and the summary content itself. Nothing here produces one. This is the record as stored data, so the storage contract is settled before anything writes to it. The record is the framework's alone. A tool handler, an agent callback and a workflow tool node each hold an EventActions that lands on the persisted event, so each clears the field, and the REST mapper drops an inbound one. A record decides which stored events every later prompt drops, so anything able to plant one can erase history and put content of its own in its place. Two obligations land on session.Service, both enforced by the shared conformance suite. An event arriving without an ID is assigned one in place, because a stored event that cannot be named cannot be referred to by a record. And Actions.Compaction survives the round trip: a summary carries its content only there, with no content and no deltas, so a backend that decides what to persist by looking at content or deltas drops it silently and the same range is summarized and billed again every turn. The conformance cases use nanosecond timestamps so a backend that rounds is visible, and one case checks that a hole still names its event after a round trip. That is the property that loses conversation when it fails. * feat(compaction): summarize completed invocations as the conversation grows Adds the compaction library, the engine behind it, and the sliding-window strategy the runner drives once an invocation finishes. compaction.Config is the public surface: which strategy to run, and a Summarizer to run it with. A Summarizer returns content and token usage rather than a finished event, so third-party code cannot declare an authorship, a state delta, an agent transfer or the range of history to delete. The framework builds the event and derives the range from the events it handed over. The default LLMSummarizer carries a timeout, an allow-list of generation settings, and a check that the generation was not truncated or filtered, because a truncated summary stored as a real one silently loses the turns it covers. The engine keeps one definition of coverage: inside the recorded range and not named as a hole. Window selection filters events out of the middle of its own span, by branch, by isolation scope and by what a retained tail holds back, so a range alone would cover gaps that nothing summarized. Prompt assembly substitutes a summary for the events it covers and leaves the rest raw, and an unanswered tool call is never separated from its response. A summarizer sees copies built field by field from what a summarizer is for, not the session's live events, so it cannot rewrite stored history through a pointer it was handed. A race guard re-reads the session before and after the plugin pass and discards a summary if anything landed inside its range while it was being produced. Telemetry is here rather than in a slice of its own because the compactor calls it directly. The span stays open until the caller reports what became of the summary, so the five discard paths no longer report success for an event that reached no session. Compaction never modifies or deletes history. It appends, and only the prompt shrinks. * feat(compaction): compact mid-invocation once the prompt crosses a threshold 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. * feat(server): make context compaction reachable from every serving surface Threads EventsCompactionConfig through the REST server, the Pub/Sub and Eventarc triggers, the launcher, A2A and Agent Engine, so an application gets the same compaction behaviour whichever way it is served. Every surface refuses a config it cannot actually serve, at startup, by dry-running runner.New per application. The config is validated inside runner.New and a runner is built per request, so without this a process starts cleanly, reports healthy, and fails every request with an error naming nothing an operator can act on. A compaction failure no longer fails the turn that produced it, on any of them. Compaction runs after the agent has answered and after its events are persisted, so a failure there means only that a later prompt will be larger. Each surface recognises ErrCompaction, logs it and carries on, rather than returning a 500 that discards the answer, streaming an error to a client that already has one, or NACKing a Pub/Sub message that was handled. The trigger surfaces warn when a sliding window is configured there, because each delivery gets a session of its own and history never accumulates, so the interval is never reached. The launcher and server config fields say the setting is process-wide: one server serves many applications through its agent loader and they all get the same config and the same summarizer, so applications needing different compaction should run separately.github.com-google-adk-go · 96f795c5 · 2026-08-31
- 0.8ETVfeat(session): carry a context-compaction record on a session event (#1231) Adds session.EventCompaction and session.EventRef, the stored shape a compaction summary takes, and makes every backend keep it faithfully. A summary is an ordinary event whose Actions.Compaction names the timestamp range it stands in for, the events inside that range it does not stand in for, and the summary content itself. Nothing here produces one. This is the record as stored data, so the storage contract is settled before anything writes to it. The record is the framework's alone. A tool handler, an agent callback and a workflow tool node each hold an EventActions that lands on the persisted event, so each clears the field, and the REST mapper drops an inbound one. A record decides which stored events every later prompt drops, so anything able to plant one can erase history and put content of its own in its place. Two obligations land on session.Service, both enforced by the shared conformance suite. An event arriving without an ID is assigned one in place, because a stored event that cannot be named cannot be referred to by a record. And Actions.Compaction survives the round trip: a summary carries its content only there, with no content and no deltas, so a backend that decides what to persist by looking at content or deltas drops it silently and the same range is summarized and billed again every turn. The conformance cases use nanosecond timestamps so a backend that rounds is visible, and one case checks that a hole still names its event after a round trip. That is the property that loses conversation when it fails.github.com-google-adk-go · 5b62af7b · 2026-08-31
- 0.4ETVci: automate v1 backports from the v1-needed label (#1327) * ci: automate v1 backports from the v2 label Backporting a fix to the maintenance branch meant opening a second pull request by hand and resolving the same conflicts every time. Six of the fifteen commits on v1 are backports, several of them rolling up five main PRs, so the work was already being batched to make the cost bearable. The obstacle is not the cherry-pick, it is the module path. main is google.golang.org/adk/v2 and v1 is google.golang.org/adk, so every Go file's import block differs and any patch touching imports conflicts. That difference is a pure string rewrite, so the patch is rewritten before it is applied and the common case now lands untouched. Verified against history rather than assumed: replaying #1195 fails as a raw cherry-pick and applies cleanly rewritten, and replaying #1301 reproduces the hand-made backport in 95a40be6 byte for byte. Genuine drift is left alone. Replaying #1252 rejects the hunks touching IsolationScope, a field v1 never had, which is the correct outcome; the script applies what it can, leaves .rej files, and prints the commands to finish. The workflow then comments on the original PR asking for a manual backport rather than failing silently. A clean apply is still not a correct backport. The hand-made #1195 also added a helper that existed on main but never on v1, so a patch can apply and not compile. The script says so and prints the verify commands. Two details worth recording. The job needs a PAT or App token rather than the built-in GITHUB_TOKEN, because pull requests opened with GITHUB_TOKEN do not trigger workflow runs and the backport PR would arrive with no CI and no way to merge it; the run refuses to start without one and confirms afterwards that the checks actually registered. And it triggers on push rather than pull_request_target, which would hand a token that can push and open pull requests to a workflow running in a pull request's context; the work is driven off the label queue, so it does not need the event payload at all. The queue clears itself: a PR drops off once its number is referenced by a commit on v1 or by an open PR targeting v1. * fix(backport): read only the places that record a backport The queue skipped any PR whose number appeared anywhere in a v1 commit message, subject and body alike. Bodies are prose, and prose is full of numbers that mean something else. "Fixes google/adk-go#1152" names an issue. One commit explains that it bumped dependencies "rather than a cherry-pick of main's Dependabot commits (#1021, #1144, #1192, #1215, #1219, #1242, #1275)", naming seven PRs precisely because they were not backported. Fourteen numbers in the current history are references of that kind, and each one is a fix that would never appear in the queue and never be reported missing. A silent omission is the one failure this tool must not have, since not forgetting is the whole point of it. Read the two places a backport is actually recorded instead: the commit subject, where GitHub's squash puts "(#N)" and where a batched backport lists every number it carries, and the "* subject (#N)" bullets a squash leaves in the body for the commits it folded in. That second one matters and is easy to miss -- #1156 and #1217 are recorded only as bullets, and reading subjects alone would have re-queued both and opened duplicates. Checked against the whole v1 history: the twelve prose references become queueable again, and all forty-five real backport records are still matched. Open pull requests are matched on title alone. Every title this script writes carries its numbers, in the trailing "(#N)" of a single backport or the "(#a, #b, #c)" of a batch. * fix(backport): stop a failed API call from looking like an empty queue backported_prs ended in `| grep -oE '#[0-9]+' | tr -d '#' || true`, and that `|| true` covered the whole group feeding the pipe, the `gh pr list` call included. A network or auth blip while listing open v1 pull requests would therefore not fail; it would return a short exclusion set, and a short exclusion set means backporting something that is already in flight. Nightly, unattended, with nobody reading the log. The call is made up front now, and a failure stops the run. Refusing to act on an incomplete picture is the right instinct here: the queue is self-clearing, so a run skipped today is retried tomorrow at no cost, while a duplicate pull request has to be noticed and closed by a person. Also scopes the loop variable in the skip filter, which was assigning to a global, and drops a stale claim in confirm_checks that this only ever runs under a human's credentials. It runs in CI too now. * fix(backport): do not lose the pull request to a missing label The new pull request was labelled through `gh pr create --label`, which fails outright when the repository has no such label rather than skipping it. By that point the branch has been pushed, so a rename of `v1` would leave the replayed commits on the remote with nothing pointing at them and the run red for a reason that reads nothing like "the label moved". Label as a separate step and warn if it does not take. A backport that arrives unlabelled is a small annoyance; one that never arrives is the failure this tool exists to prevent. The label names also stop borrowing the branch constants, which they only happened to match. V2_LABEL is what the queue filters merged main PRs on and V1_LABEL is what goes on the PRs this opens; neither has any reason to change when a branch name does. * ci: say so when the backport token is not usable yet The token check only asked whether the secret was non-empty. A fine-grained PAT scoped to an organization is non-empty, and authenticates, from the moment it is created -- but it cannot see the repository until an org admin approves the request. The run would get past the check and fail at actions/checkout instead, reporting that the repository could not be read, which points at everything except the pending approval that actually caused it. Ask the API what the token can do, before the checkout. Unreachable names approval as the likely cause and says where to look; readable but not pushable names the two permissions to grant. Both beat inferring it from a checkout failure. Verified against four cases: empty secret, a working token, a malformed token, and a valid token pointed at a repository it cannot see, which is the pending-approval case. * ci: drive the queue off v1-needed instead of v2 The labels changed: v2-only is gone, and v1-needed replaces it as the signal that a change on main still owes a 1.x equivalent. v1 and v2 stay on as information about which branch a pull request targets. That inverts what the queue reads. v2 was an opt-in that also happened to describe the branch, which made it do two jobs at once and left v2-only carrying the "no" case as a second label to remember. One label with behaviour attached and two that only describe things is easier to get right at review time, and it is the label the queue now filters on. The constant is BACKPORT_LABEL rather than V2_LABEL, since the name no longer has anything to do with a branch. V1_LABEL is untouched: a backport pull request targets v1, so the informational label still applies to the ones this opens. Verified against the live repository: the queue query returns #1328, the one pull request currently carrying v1-needed, with the exclusion set and output formatting intact around it. * ci: run the backport on GITHUB_TOKEN instead of a PAT The workflow refused to start without a BACKPORT_TOKEN secret on the grounds that a pull request opened with the built-in GITHUB_TOKEN never triggers workflow runs, so the backport PR would arrive with no CI and could never be merged. That stopped being true in June 2026. A pull request opened by github-actions[bot] now does trigger its pull_request workflows, in an approval-required state: the runs are created and wait for someone with write access to click "Approve workflows to run". So the backport PR can get its CI without the repository holding a personal access token that is bound to one person's account, outlives the job, and needs an organization approval before it works at all. The cost is one click on a pull request a human reviews and merges anyway. What this needs instead is the "Allow GitHub Actions to create and approve pull requests" repository setting. A workflow token cannot read it, so it is not checked up front; gh pr create fails on it and the failure handler names it rather than leaving a pushed branch and a 403 to interpret. confirm_checks no longer fails the run when nothing has registered. The branch is pushed and the PR is open by then, so exiting non-zero would report a backport that succeeded as broken; held runs are now the expected case, and it prints how to release them. * ci: one backport PR per pull request, not one per run The review found three blockers and three near-blockers, and five of the six came from two design choices rather than from the code being wrong. Batching every pending backport onto one branch made a single conflict fatal to the whole run: apply_pr exited from inside the loop, so the PRs behind the conflicting one were never attempted and the ones that had already applied went away with the runner. It also made the batch title enumerate PRs that might have contributed no commit, and made the branch name a date that collides on the second batch of a day. Working out what was already backported by reading PR numbers out of commit subjects and PR titles meant parsing prose. An open v1 PR titled "address review feedback from #1301" silently dropped #1301 from the queue forever, and the die() protecting that lookup sat behind a pipeline in a command substitution, where errexit does not reach it. So: one branch and one pull request per original PR, replayed in its own worktree. A conflict now costs one backport. The branch name is a function of the PR number, so it cannot collide and it doubles as the in-flight check. And "is this already on v1" is answered by searching v1 for the "(cherry picked from commit <sha>)" trailer the script itself writes -- an exact match on something the automation owns, with no prose in the loop. What is left of the review is small and is fixed here: the push refuses any branch outside backport/v1/, a branch with no commits is never pushed, inherit_errexit is on, and an empty patch no longer counts as a backport. Failure modes are now distinguished -- a conflict is a normal outcome that comments once and keeps the PR queued, while a failed push or PR call fails the run, because nobody has been told. Dropped with the batching: --branch, --worktree, --force, --watch and the two-step resume flow, which existed to make a failed batch recoverable. --list, --pr and --skip-gomod remain. Verified against real history: replaying #1301 onto the tree before its hand-made backport reproduces 95a40be6 byte for byte, #1252 rejects exactly the IsolationScope hunks and leaves .rej files, and #1195 needs --skip-gomod. Note that #1195 does not apply cleanly with or without this change -- the claim in the original description was wrong, and is corrected there. * ci: make the backport script fail loudly instead of quietly Every one of these is the same shape: the script did the wrong thing and reported success. The replay is no longer tied to a named local branch. `checkout -B` was the only thing binding backport/v1/pr-<n> to the replayed commit, its exit status was unchecked, and the push resolved the name rather than the commit -- so with that branch already checked out in the clone, the commit landed on a detached HEAD and the push sent the pre-existing branch instead, inside a run reporting "backported 1 ... failed 0". The worktree now stays detached from start to finish, the push sends HEAD:refs/heads/<branch>, and nothing writes or deletes a local ref. The guards before the push check HEAD, which is now the thing being pushed. `git worktree add` and `git commit` are checked. `|| status=$?` at the call site makes errexit inert for the whole function, so a failing worktree add fell through to `git apply`, failed because the directory was not there, and posted "this change does not replay cleanly" on a merged pull request whose patch was fine. `die` no longer appears in the per-PR path. It is `exit 1`, which `|| status=$?` does not catch, so it aborted the drain mid-queue: every later PR skipped, no summary line. Those paths return 3, which main already collects into a red run after draining the rest. A failed `gh pr create` now takes the pushed branch back down. in_flight treats a pushed branch as a backport already under way, so leaving it behind removed that PR from the queue permanently -- one red run, then silence. The likeliest trigger is the "Actions cannot create pull requests" case the script already handles by name. The comment the bot posts on a conflict described a recovery procedure that destroys the work it asks for: "re-run with --pr" starts from a clean replay and rm -rf's the worktree first. It now says what the terminal already said -- finish it in the worktree, push HEAD. Rebase merges are asserted against rather than assumed away. The repository allows them, and for a rebase merge mergeCommit.oid is only the last commit of the branch, so a multi-commit PR would be backported in part, apply cleanly, and clear itself from the queue on the trailer. The PR's file set is compared against the merge commit's, and a merge commit that does not carry the whole PR is refused. Also: the trailer test is an exact line match, so a v1 commit quoting the phrase in prose no longer suppresses an unrelated backport; the --skip-gomod pathspec is anchored with :(top), so a run from a subdirectory no longer silently truncates the patch; the module rewrite leaves `<path> v2.x` in go.mod require lines alone rather than producing a go.mod the go tool rejects; the remote match is anchored, so it no longer accepts google/adk-go-experimental; the workflow input is matched with [[ =~ ]] rather than a line-oriented grep a newline gets past; the job is skipped outside google/adk-go; bash >= 4.4 is checked by name rather than failing on `shopt: invalid option`; and mktemp is given a template so it works on BSD. CONTRIBUTING no longer claims the current checkout is untouched, and notes that the backport PR owner is now the bot, so git blame on v1 points there with the human author in the trailer. Verified: #1301 still replays byte-identically onto the tree before 95a40be, with no local ref written and the worktree detached; the same replay is unaffected by a pre-existing backport/v1/pr-1301 checked out elsewhere, which is left untouched; a merge commit missing the PR's files is refused; a prose mention of the trailer no longer matches while a real trailer still does; --skip-gomod gives the same 5 files from the repository root and from tool/; #1252 still leaves its .rej files. * ci: lint the shell scripts The lint job is golangci-lint matrixed over Go modules, so nothing in CI reads scripts/backport.sh or .github/scripts/apidiff.sh. shellcheck with --enable=all is what surfaces the set -e suppression class -- a git call whose failure is swallowed while the script carries on -- which was behind three of the findings on this pull request. shellcheck ships on the runner image, so this is a job rather than an install step. * ci: fail closed when the v1 trailer search fails Ran shellcheck --enable=all once, as a temporary CI job, to answer the review point that nothing checks these files. Two of its findings were real; the job is removed again rather than kept, since a permanent shell lint across the repository is a separate decision from this change. The one that matters is the same failure mode already fixed once for `gh pr list`. already_backported piped `git log` into the loop through a process substitution, where a failure is invisible: it reads as "no candidates", meaning not yet backported, and opens a duplicate pull request. The output is now captured and checked, so a failed search stops the run instead of guessing. The other was an unquoted expansion in the rebase-merge guard's message, which would glob a path list against the working directory. The remaining findings are deliberate: the boolean predicates in pending_queue and the `|| status=$?` collecting per-PR outcomes both suppress errexit on purpose, and that suppression is now handled by checking each git call explicitly rather than relying on it. * fix(backport): print a push command that works from a detached worktree The conflict path's terminal instructions still said `git push <remote> <branch>`, which was correct while the worktree checked out a local branch of that name and stopped being correct when it went detached. From a detached HEAD there is no such ref, so a contributor following those steps gets 'src refspec does not match any' after doing the resolution work, and `gh pr create` without --head has nothing to infer from either. Same wording as the comment the bot posts, which was already fixed. Found while tracing what happens to a backport whose patch depends on an earlier one: it conflicts, and this is the text it prints. * fix(backport): close the round-three findings Three blockers, four that were filed as smaller but fail silently and permanently, and four cosmetic. The rebase-merge guard refused ordinary squash merges anywhere but CI. jq sorts by codepoint and sort(1) by locale, and under en_US.UTF-8 -- the normal workstation setting, and mine, which is why the guard's own test passed -- comm reports present files as missing and the run tells the operator to cherry-pick a healthy squash by hand. Both sides are now sorted and compared under LC_ALL=C, with core.quotePath off so a non-ASCII path cannot break the same comparison on the runner. The file list is also paginated now; gh pr view --json files stops at 100. The gh pr create redirect sat on the assignment rather than inside the substitution, so ${err} was always empty, the grep could never match, and the "Allow GitHub Actions to create and approve pull requests" guidance -- the one diagnosis the workflow header promises, on the failure most likely to happen first -- was dead code. The operator got a warning ending in a colon. Moving it inside also removes an ordering hazard: a failed mktemp made 2>"" an ambiguous redirect that fired after the PR was created, and the failure branch then deleted its branch. Both recovery procedures said git add -A immediately after git apply --reject wrote .rej files next to the sources, so following them put reject files on the backport branch and then on v1. They now delete the rejects, stage with -u, and carry the (cherry picked from commit <sha>) trailer, without which a hand-finished backport is invisible to the exclusion check once its branch is deleted. in_flight passed a bare name to ls-remote, which matches the tail of every ref, so somebody's <user>/backport/v1/pr-<n> would read as this backport already being under way and drop the PR from the queue for good. Fully qualified as refs/heads/ now. The patch pipeline's status was unchecked, and with errexit inert inside this function a failed git show left an empty file that was read as "nothing to backport" and tallied as a benign skip on a green run. Of the four return codes 2 was the one absorbing tooling failure; it now has to be earned. The conflict marker is matched only on comments by the bot or the authenticated user, so it can no longer be pre-posted by anyone to suppress the only signal a human gets. The job takes timeout-minutes, since it holds a serialized concurrency group and the default is six hours. Also: an explicit PR number that matches nothing now says so and why, rather than claiming the whole queue is empty on the command the conflict comment tells contributors to run; the return-code docstring lists all six paths that return 3; and the push guard's comment says it is an assertion rather than the thing holding the invariant. Verified: the locale false positive reproduces on this PR's own three files before the change and is gone after; the setting-specific guidance fires now that stderr is captured; the qualified ref still matches the real branch and no longer matches a prefixed one; a failed patch pipeline returns 3; the recovery text prints commands that work; and #1301 still replays byte-identically onto the tree before 95a40be. * fix(backport): make the merge-shape guard fail closed Self-review of the previous commit, before asking for another round. The guard swallowed a failed API call: `gh api | sort` inside an `if` meant an unreachable API produced an empty list, which read as "no files to compare" and skipped the check entirely. A guard whose purpose is to stop a silent half-backport must not disable itself silently -- if the file list cannot be fetched the merge shape is unknown, and unknown is not the same as fine. It now refuses. Raised the job timeout from 10 to 30 minutes. Ten was tuned for unwedging quickly and ignored what a kill costs: the job dies mid-drain, and a kill landing between the push and `gh pr create` leaves a branch with no pull request behind it, which in_flight then reads as a backport already under way and drops that PR from the queue. A full-history checkout plus a backlog of replays needs room. * fix(backport): stop the recovery steps dropping files the patch adds `git apply --reject` runs without --index, so a file the patch adds is left untracked in the worktree. Both recovery procedures then said `git add -u`, which stages tracked paths only, so following the printed steps verbatim committed the modified file and silently dropped the added one -- with the cherry-pick trailer intact, so already_backported matched it and the pull request left the queue for good. A partial change on v1, reached by doing exactly what the tool said. Reproduced on a patch that modifies one tracked file and adds another: the commit carried [existing.txt] and added.txt was gone. Back to `git add -A`, which is now both safe and complete because the `find -name '*.rej' -delete` added last round runs first. Verified on the same fixture: commit carries [added.txt existing.txt], no .rej. The posted comment's trailer was a literal placeholder where the terminal text interpolated the real sha, because comment_conflict was never given it. It takes the sha now. open_backport_pr ended on cleanup_worktree, so a worktree that refused to be removed made a landed backport return 1 and be tallied as a conflict, telling the contributor to redo by hand a pull request that was already open. Returns 0 explicitly. Settled the `gh api user` question by measurement: on any API error `gh api --jq` prints the response body to stdout, so 2>/dev/null does not suppress it and `|| echo ''` only appends. A bad token yields a JSON blob, which interpolated into the jq filter makes jq refuse to parse, `gh pr view` exit non-zero, and the handler warn and return 0 -- the conflict comment silently never posts. That is the CI path, since GITHUB_TOKEN is an installation token and GET /user wants user-to-server auth. The login is validated against ^[A-Za-z0-9-]{1,39}$ now, so anything else becomes empty and matches no author. in_flight keys on an open pull request rather than on the branch. Branch-existence conflated "under way" with "attempted and died", and only the second is silent: a run killed between the push and `gh pr create` left a branch that excluded its PR from every later run. CONTRIBUTING documents the new contract. Costing that alongside the fail-closed guard, as raised: paginating the file list up front made a 547-file pull request ~19 requests, each able to fail into a refusal. The guard now compares file counts first -- one request -- and fetches the full list only when they differ, to name what is missing. A squash carries the whole PR so the counts match; a rebase merge records one commit so they do not. Residual noted in the comment: equal counts over different sets would pass. * docs(backport): record the real residual of the merge-shape guard The comment said equal counts over different sets would pass. The comparison is over file names, so equal sets with different content pass too: a rebase whose last commit touches the same files as the whole branch clears both the count check and the comm. Comparing lists rather than content is what allows it, and the count step neither introduced nor widened it -- but the recorded residual should be the real one. What covers it is unchanged and now stated: the last commit's patch is written against a parent that is not on v1, so it fails to apply rather than landing silently. --------- Co-authored-by: João Westerberg <baptmont@users.noreply.github.com>github.com-google-adk-go · 440e3520 · 2026-09-03
- 0.3ETVtest(llmagent): drive context compaction against a recorded real model (#1236) * feat(session): carry a context-compaction record on a session event Adds session.EventCompaction and session.EventRef, the stored shape a compaction summary takes, and makes every backend keep it faithfully. A summary is an ordinary event whose Actions.Compaction names the timestamp range it stands in for, the events inside that range it does not stand in for, and the summary content itself. Nothing here produces one. This is the record as stored data, so the storage contract is settled before anything writes to it. The record is the framework's alone. A tool handler, an agent callback and a workflow tool node each hold an EventActions that lands on the persisted event, so each clears the field, and the REST mapper drops an inbound one. A record decides which stored events every later prompt drops, so anything able to plant one can erase history and put content of its own in its place. Two obligations land on session.Service, both enforced by the shared conformance suite. An event arriving without an ID is assigned one in place, because a stored event that cannot be named cannot be referred to by a record. And Actions.Compaction survives the round trip: a summary carries its content only there, with no content and no deltas, so a backend that decides what to persist by looking at content or deltas drops it silently and the same range is summarized and billed again every turn. The conformance cases use nanosecond timestamps so a backend that rounds is visible, and one case checks that a hole still names its event after a round trip. That is the property that loses conversation when it fails. * feat(compaction): summarize completed invocations as the conversation grows Adds the compaction library, the engine behind it, and the sliding-window strategy the runner drives once an invocation finishes. compaction.Config is the public surface: which strategy to run, and a Summarizer to run it with. A Summarizer returns content and token usage rather than a finished event, so third-party code cannot declare an authorship, a state delta, an agent transfer or the range of history to delete. The framework builds the event and derives the range from the events it handed over. The default LLMSummarizer carries a timeout, an allow-list of generation settings, and a check that the generation was not truncated or filtered, because a truncated summary stored as a real one silently loses the turns it covers. The engine keeps one definition of coverage: inside the recorded range and not named as a hole. Window selection filters events out of the middle of its own span, by branch, by isolation scope and by what a retained tail holds back, so a range alone would cover gaps that nothing summarized. Prompt assembly substitutes a summary for the events it covers and leaves the rest raw, and an unanswered tool call is never separated from its response. A summarizer sees copies built field by field from what a summarizer is for, not the session's live events, so it cannot rewrite stored history through a pointer it was handed. A race guard re-reads the session before and after the plugin pass and discards a summary if anything landed inside its range while it was being produced. Telemetry is here rather than in a slice of its own because the compactor calls it directly. The span stays open until the caller reports what became of the summary, so the five discard paths no longer report success for an event that reached no session. Compaction never modifies or deletes history. It appends, and only the prompt shrinks. * feat(compaction): compact mid-invocation once the prompt crosses a threshold 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. * feat(server): make context compaction reachable from every serving surface Threads EventsCompactionConfig through the REST server, the Pub/Sub and Eventarc triggers, the launcher, A2A and Agent Engine, so an application gets the same compaction behaviour whichever way it is served. Every surface refuses a config it cannot actually serve, at startup, by dry-running runner.New per application. The config is validated inside runner.New and a runner is built per request, so without this a process starts cleanly, reports healthy, and fails every request with an error naming nothing an operator can act on. A compaction failure no longer fails the turn that produced it, on any of them. Compaction runs after the agent has answered and after its events are persisted, so a failure there means only that a later prompt will be larger. Each surface recognises ErrCompaction, logs it and carries on, rather than returning a 500 that discards the answer, streaming an error to a client that already has one, or NACKing a Pub/Sub message that was handled. The trigger surfaces warn when a sliding window is configured there, because each delivery gets a session of its own and history never accumulates, so the interval is never reached. The launcher and server config fields say the setting is process-wide: one server serves many applications through its agent loader and they all get the same config and the same summarizer, so applications needing different compaction should run separately. * test(llmagent): drive context compaction against a recorded real model Adds the end-to-end test and the worked example. Every other test in the stack uses a fake summarizer, which is right for pinning behaviour but says nothing about whether a real model, handed a real transcript through the real prompt-assembly path, produces something usable. This one runs the full chain against recorded traffic: turns accumulate, the threshold trips, a summary is written, and the next prompt carries the summary in place of the turns it covers while the raw tail stays intact. The assertions are load-bearing rather than structural. A compaction that deletes its covered events without substituting anything fails here, and so does one whose summary never reaches the prompt. The example arms the sliding window and carries the tail-retention pair commented out, with what to delete in order to swap. Arming both is what the documentation warns against: the sliding window consumes the events tail retention would summarize, so the ceiling never applies.github.com-google-adk-go · cb612ec5 · 2026-08-31
- 0.2ETVfix(adka2a): mark emitted tasks with the ADK A2A extension (#1246) An ADK Python client chooses how to read a task from that task's metadata. Without the ADK A2A extension key it falls back to a converter that reads task artifacts exclusively and never looks at the status message. adk-go carries long-running function calls in the status message, so an input-required task appears to have nothing pending, and the client synthesizes a mock_function_call_for_required_user_input over the model's text. Its follow-up then answers a call id adk-go never issued, leaving the task stuck in input-required. The payload shape was never the problem: adk-python's own v2 server splits content the same way. adk-go simply never identified itself. Write the extension URI into the metadata of every task and task status update, the only events a2a merges into a2a.Task.Metadata, and declare the extension on the web launcher's agent card. Decorating the event sink rather than each event source covers all of them, including the input-required status update, which is emitted with no metadata at all. Fixes #913 Co-authored-by: anish <145943060+anishesg@users.noreply.github.com>github.com-google-adk-go · 3c1517af · 2026-08-19
- 0.1ETVci: guard the exported API against accidental breaking changes (#1297) * ci: guard the exported API against accidental breaking changes The build and the tests do not catch every break. Adding a trailing variadic parameter to an exported function changes that function's type: every ordinary call site still compiles, so nothing goes red, while any caller that held the function as a value stops compiling. That reached a release, and a test written specifically to pin backward compatibility did not catch it either, because it was an ordinary call expression. The check runs apidiff over every exported package and fails on incompatible changes. Adding to the API is always allowed. Two details that matter. It compares against the merge base rather than the target branch tip, because a branch cut before a recent change to the target would otherwise be reported as removing whatever the target gained in the meantime; the first draft did exactly that and produced two convincing false positives. And internal packages are excluded, since they are not public API. Verified end to end rather than assumed: run against the commit that introduced the variadics it reports both constructors and exits 1, and against the commit that restores them it exits 0 with no findings. * ci: pass the base ref to apidiff through the environment zizmor's template-injection audit flagged the run step, correctly. A ${{ }} expansion inside run: is substituted into the shell text before the shell sees it, so whatever the value holds becomes code rather than an argument. Passing it as an environment variable and quoting it in the script keeps it data. Caught by CI on the pull request that added the workflow, which is a reasonable advertisement for the check that job performs. * ci: allow a deliberate API break behind a label The check shipped with no way to land an intended break. The failure message said a maintainer could override it, but the only mechanism was disabling a required check, which is how checks get removed rather than overridden. A pull request labelled breaking-change is still compared and still prints what changed, so the break is on the record instead of waved through invisibly. It just stops failing. * ci: compare whole modules instead of one package at a time The check took eleven minutes warm and over fifteen cold. It invoked apidiff once per exported package, and every invocation reloads and type-checks the entire dependency graph. apidiff -m reads a whole module in one pass and reports the same findings in seven seconds. Module mode also reaches plugin/agentanalytics, which go list ./... never saw. That module is public API and was going unchecked. And it skips internal/ on its own, so the grep filtering is gone with it. Separately, a module that cannot be read now fails. Every apidiff call carried a trailing || true, so a base revision that failed to build wrote no snapshots, every package was then skipped for want of one, and the job went green having compared nothing at all. apidiff exits non-zero only when it could not do its job, so that case is easy to separate from a run that found a break, and the breaking-change label does not cover it: a comparison that did not happen is not a break anyone decided to make. Verified against the same cases as before. A trailing variadic added to runner.New and to NewBatchProcessor is reported and exits 1, a clean tree exits 0, and a module that does not compile fails even with the label set.github.com-google-adk-go · 362e5297 · 2026-08-11
- 0.1ETVfix(llminternal): close the live connection when SendHistory fails (#1261) RunLive's SendHistory failure path was the one return in the function's own body that released neither connCtx nor liveConn, so the websocket outlived the invocation. Cancelling the context would not have been enough: genai dials the live socket with websocket.DefaultDialer.Dial, which takes no context, and nothing in the SDK watches one -- Session.Close closing the underlying conn is the only thing that releases it. So an early return here stranded the connection for the life of the process, not merely until the invocation ended. go vet's lostcancel does not catch it because cancelConn is reached through the cleanup closure. Call cleanup() before returning, matching every other return in the body. The regression test drives a real websocket through the existing fake Live server and asserts the server observes the client's close. To keep the observation meaningful the send has to fail client-side -- a broken socket would fail it too, but leave nothing to observe -- so the test feeds SendHistory a turn carrying NaN, which json.Marshal rejects inside the SDK while the connection stays healthy. On the unfixed code the server sits until its read deadline expires and the test fails.github.com-google-adk-go · 73c71def · 2026-08-07
- 0.1ETVfix(compaction): carry durable facts across re-summarization (#1431) * fix(compaction): carry durable facts across re-summarization Tail retention seeds each summary with the previous one, so a value stated early is recompressed on every pass. The default summarizer prompt asked for a "concise" summary and to reiterate the user request, which under a rolling summary is an instruction to describe the most recent turns. Measured against a real model with eight arbitrary facts stated early and probed once the events holding them had been summarized away, that prompt lost every one of them in five conversations out of ten, and lost none in the other five. The loss is not gradual: each pass either copies a value forward or generalizes it to its label -- "the deployment region" for "europe-west4" -- and once generalized it cannot come back, because the next pass sees only the previous summary and the retained tail. The agent then reports that it does not have the information rather than inventing it, so the failure is quiet. An intermittent default is worse than a consistent one here: it passes whatever spot check someone runs before trusting it, then discards the early conversation in production. The prompt now requires concrete values to be listed and copied forward verbatim, and forbids generalizing them to save space. Under the same measurement that lost nothing across nine conversations. It costs about twice as many summarizer calls, because larger summaries cross TokenThreshold sooner, which is the trade this feature cannot avoid: bounding the prompt and retaining everything are not both available. The package doc claimed tail retention as the strategy that bounds prompt growth without saying what bounding costs; it now does, and points at session state for detail that must not be lost at all. * docs(examples): add a harness that measures compaction recall The compaction settings are measured by prompt size, and prompt size does not say whether the conversation survived. This plants checkable facts, buries them under filler until compaction has summarized them away, then asks about each one, so a config can be judged on whether the agent can still answer. It runs each arm several times by default and prints the per-run scores rather than only an average. Recall here is close to all-or-nothing -- a pass either copies a value forward or generalizes it to its label, and a label cannot be turned back into a value -- so configs tend to score full marks or nothing, and an average over too few runs describes neither. The terse arm supplies the summarizer prompt as it was before durable facts were carried forward. It stands in for any custom PromptTemplate that asks only for a concise summary, and it makes the cost of that choice reproducible: on one model, one config and the same conversation, the default recalled 8/8 and the terse prompt 0/8 over the same 20 compaction passes. * fix(examples): compare against the real prior prompt, not a shortened one Review found the harness could not reproduce the result it shipped to demonstrate. Its comparison arm was a hand-shortened prompt: it dropped the CRITICAL INSTRUCTIONS framing, the language instruction and the tool-name instruction, and reworded the closing sentence. That made the run a comparison against a third prompt rather than against the previous default, so it did not isolate the instruction this change adds. The arm is now the pre-change default reproduced verbatim, and renamed to prior since that is what it is. Re-measured, the conclusion is stronger than the one the wrong prompt produced: 24/24 for the default against 0/8 twice for the prior prompt, on the same model and config. Also from review: - buriedFacts matched fact labels with a substring test, so the label "7" matched INC-77312 and tarn-staging-91 and marked that fact buried on unrelated events. It now uses the same word-boundary matchers as scoring. - The disclaimer list had grown broad enough to catch correct answers: "I am unable to browse, but the region is europe-west4" scored as a miss. Narrowed to unambiguous denials, since the NO_RECORD sentinel already carries that job and a false miss flatters the change under test. - truncate sliced bytes and could split a multi-byte rune. A failed run no longer aborts the batch. A run is hundreds of model calls and a busy model returns 503 regularly; losing the runs that already succeeded to one transient error made the harness impractical at the sizes it asks for. The prompt comment now says why instruction 3 covers only user-stated values, rather than leaving a reader to guess whether tool-derived ones were considered, and names the one behavioural consequence: content arriving in a tool response now has a prompt arguing for its retention where it used to decay out.github.com-google-adk-go · cf61116c · 2026-08-31