mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-04 06:01:15 -07:00
Merge branch 'main' into topics-debug-logs
This commit is contained in:
@@ -41,6 +41,41 @@ module.exports = async ({ github, context, core }) => {
|
||||
now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const maintainerCache = new Map();
|
||||
async function isMaintainer(user, association) {
|
||||
if (user?.type === 'Bot') return true;
|
||||
if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association)) return true;
|
||||
|
||||
const username = user?.login;
|
||||
if (!username) return false;
|
||||
|
||||
if (maintainerCache.has(username)) {
|
||||
return maintainerCache.get(username);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username,
|
||||
});
|
||||
// Permission can be admin, write, read, none.
|
||||
// Roles like 'maintain' or 'triage' often map to 'write' or 'read' in the top-level field.
|
||||
const isM =
|
||||
['admin', 'write'].includes(data.permission) ||
|
||||
['admin', 'maintain', 'write'].includes(data.role_name);
|
||||
|
||||
maintainerCache.set(username, isM);
|
||||
return isM;
|
||||
} catch (err) {
|
||||
core.warning(
|
||||
`Could not check permissions for ${username}: ${err.message}`,
|
||||
);
|
||||
maintainerCache.set(username, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function processItems(query, callback) {
|
||||
core.info(`Searching: ${query}`);
|
||||
try {
|
||||
@@ -83,10 +118,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
const lastComment = comments[0];
|
||||
if (
|
||||
lastComment &&
|
||||
!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
lastComment.author_association,
|
||||
) &&
|
||||
lastComment.user?.type !== 'Bot'
|
||||
!(await isMaintainer(lastComment.user, lastComment.author_association))
|
||||
) {
|
||||
core.info(
|
||||
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
|
||||
@@ -188,11 +220,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (
|
||||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
|
||||
pr.user?.type === 'Bot'
|
||||
)
|
||||
return;
|
||||
if (await isMaintainer(pr.user, pr.author_association)) return;
|
||||
|
||||
core.info(`Nudging PR #${pr.number} for contribution policy.`);
|
||||
if (!dryRun) {
|
||||
@@ -216,11 +244,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (
|
||||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
|
||||
pr.user?.type === 'Bot'
|
||||
)
|
||||
return;
|
||||
if (await isMaintainer(pr.user, pr.author_association)) return;
|
||||
|
||||
core.info(
|
||||
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
|
||||
|
||||
@@ -18,6 +18,20 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.41.0 - 2026-05-05
|
||||
|
||||
- **Real-time Voice Mode:** Implemented real-time voice mode with cloud and
|
||||
local backends
|
||||
([#24174](https://github.com/google-gemini/gemini-cli/pull/24174) by
|
||||
@Abhijit-2592).
|
||||
- **Secure Environment Loading:** Enforced workspace trust and secured .env
|
||||
loading in headless mode
|
||||
([#25814](https://github.com/google-gemini/gemini-cli/pull/25814) by
|
||||
@ehedlund).
|
||||
- **Advanced Shell Validation:** Enhanced shell command validation and added
|
||||
core tools allowlist for improved security
|
||||
([#25720](https://github.com/google-gemini/gemini-cli/pull/25720) by @galz10).
|
||||
|
||||
## Announcements: v0.40.0 - 2026-04-28
|
||||
|
||||
- **Offline Search and Themes:** Bundled ripgrep for offline search support and
|
||||
|
||||
+108
-166
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.40.0
|
||||
# Latest stable release: v0.41.0
|
||||
|
||||
Released: April 28, 2026
|
||||
Released: May 05, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,177 +11,119 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Offline Search Support:** Bundled ripgrep binaries into the Single
|
||||
Executable Application (SEA) to enable powerful codebase searching even in
|
||||
environments without internet access.
|
||||
- **Enhanced Theme Customization:** Introduced GitHub-style colorblind-friendly
|
||||
themes to improve accessibility and provide more personalized visual options.
|
||||
- **MCP Resource Management:** Added new tools for listing and reading Model
|
||||
Context Protocol (MCP) resources, enhancing the agent's ability to discover
|
||||
and utilize external data.
|
||||
- **Improved Narrative Flow:** Enabled topic update narrations by default to
|
||||
provide better session structure and a clearer understanding of the agent's
|
||||
current focus.
|
||||
- **Streamlined Local Model Setup:** Introduced a simplified `gemini gemma`
|
||||
command for quickly setting up and running Gemma models locally.
|
||||
- **Prompt-Driven Memory Management:** Replaced the legacy `MemoryManagerAgent`
|
||||
with a more efficient prompt-driven memory editing system across four tiers of
|
||||
context.
|
||||
- **Real-time Voice Mode:** Introduced support for real-time voice interaction
|
||||
with both cloud-based and local processing backends.
|
||||
- **Enhanced Security:** Implemented mandatory workspace trust for headless
|
||||
environments and secured the loading of `.env` configuration files.
|
||||
- **Advanced Shell Validation:** Added a robust shell command validation layer
|
||||
and a core tools allowlist to prevent unauthorized execution.
|
||||
- **Improved Context Management:** Integrated a new `ContextManager` and
|
||||
`AgentChatHistory` to provide more reliable and efficient session handling.
|
||||
- **Auto-Memory Persistence:** Enabled the persistence of the auto-memory
|
||||
scratchpad, allowing for seamless skill extraction across turns.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by
|
||||
- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
|
||||
@gemini-cli-robot in
|
||||
[#25420](https://github.com/google-gemini/gemini-cli/pull/25420)
|
||||
- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075)
|
||||
by @rcleveng in
|
||||
[#25187](https://github.com/google-gemini/gemini-cli/pull/25187)
|
||||
- fix(core): prevent YOLO mode from being downgraded by @galz10 in
|
||||
[#25341](https://github.com/google-gemini/gemini-cli/pull/25341)
|
||||
- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in
|
||||
[#25342](https://github.com/google-gemini/gemini-cli/pull/25342)
|
||||
- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in
|
||||
[#25417](https://github.com/google-gemini/gemini-cli/pull/25417)
|
||||
- feat(test): add large conversation scenario for performance test by
|
||||
[#25847](https://github.com/google-gemini/gemini-cli/pull/25847)
|
||||
- fix(core): only show `list` suggestion if the partial input is empty by
|
||||
@cynthialong0-0 in
|
||||
[#25331](https://github.com/google-gemini/gemini-cli/pull/25331)
|
||||
- improve(core): require recurrence evidence before extracting skills by
|
||||
[#25821](https://github.com/google-gemini/gemini-cli/pull/25821)
|
||||
- feat(cli): secure .env loading and enforce workspace trust in headless mode by
|
||||
@ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814)
|
||||
- fix: fatal hard-crash on loop detection via unhandled AbortError by @hsm207 in
|
||||
[#20108](https://github.com/google-gemini/gemini-cli/pull/20108)
|
||||
- update package-lock.json by @ehedlund in
|
||||
[#25876](https://github.com/google-gemini/gemini-cli/pull/25876)
|
||||
- feat(core): enhance shell command validation and add core tools allowlist by
|
||||
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
|
||||
- fix(ui): corrected background color check in user message components by
|
||||
@devr0306 in [#25880](https://github.com/google-gemini/gemini-cli/pull/25880)
|
||||
- perf(core): fix slow boot by fetching experiments and quota asynchronously by
|
||||
@spencer426 in
|
||||
[#25758](https://github.com/google-gemini/gemini-cli/pull/25758)
|
||||
- feat(core,cli): add support for Gemma 4 models (experimental) by @Abhijit-2592
|
||||
in [#25604](https://github.com/google-gemini/gemini-cli/pull/25604)
|
||||
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
|
||||
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
|
||||
- docs: add Gemini CLI course link to README by @JayadityaGit in
|
||||
[#25925](https://github.com/google-gemini/gemini-cli/pull/25925)
|
||||
- feat(repo): add gemini-cli-bot metrics and workflows by @gundermanc in
|
||||
[#25888](https://github.com/google-gemini/gemini-cli/pull/25888)
|
||||
- fix(cli): allow output redirection for cli commands by @spencer426 in
|
||||
[#25894](https://github.com/google-gemini/gemini-cli/pull/25894)
|
||||
- fix(core): fail closed in YOLO mode when shell parsing fails for restricted
|
||||
rules by @ehedlund in
|
||||
[#25935](https://github.com/google-gemini/gemini-cli/pull/25935)
|
||||
- fix(cli-ui): revert backspace handling to fix Windows regression by @scidomino
|
||||
in [#25941](https://github.com/google-gemini/gemini-cli/pull/25941)
|
||||
- feat(voice): implement real-time voice mode with cloud and local backends by
|
||||
@Abhijit-2592 in
|
||||
[#24174](https://github.com/google-gemini/gemini-cli/pull/24174)
|
||||
- Changelog for v0.39.0 by @gemini-cli-robot in
|
||||
[#25848](https://github.com/google-gemini/gemini-cli/pull/25848)
|
||||
- feat(memory): persist auto-memory scratchpad for skill extraction by
|
||||
@SandyTao520 in
|
||||
[#25147](https://github.com/google-gemini/gemini-cli/pull/25147)
|
||||
- test(evals): add subagent delegation evaluation tests by @anj-s in
|
||||
[#24619](https://github.com/google-gemini/gemini-cli/pull/24619)
|
||||
- feat: add github colorblind themes by @Z1xus in
|
||||
[#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
|
||||
- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by
|
||||
@chrisjcthomas in
|
||||
[#25357](https://github.com/google-gemini/gemini-cli/pull/25357)
|
||||
- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in
|
||||
[#24397](https://github.com/google-gemini/gemini-cli/pull/24397)
|
||||
- Changelog for v0.38.0 by @gemini-cli-robot in
|
||||
[#25470](https://github.com/google-gemini/gemini-cli/pull/25470)
|
||||
- fix(evals): update eval tests for invoke_agent telemetry and project-scoped
|
||||
memory by @SandyTao520 in
|
||||
[#25502](https://github.com/google-gemini/gemini-cli/pull/25502)
|
||||
- Changelog for v0.38.1 by @gemini-cli-robot in
|
||||
[#25476](https://github.com/google-gemini/gemini-cli/pull/25476)
|
||||
- feat(core): integrate skill-creator into skill extraction agent by
|
||||
@SandyTao520 in
|
||||
[#25421](https://github.com/google-gemini/gemini-cli/pull/25421)
|
||||
- feat(cli): provide default post-submit prompt for skill command by @ruomengz
|
||||
in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327)
|
||||
- feat(core): add tools to list and read MCP resources by @ruomengz in
|
||||
[#25395](https://github.com/google-gemini/gemini-cli/pull/25395)
|
||||
- fix(evals): add typecheck coverage for evals, integration-tests, and
|
||||
memory-tests by @SandyTao520 in
|
||||
[#25480](https://github.com/google-gemini/gemini-cli/pull/25480)
|
||||
- Use OSC 777 for terminal notifications by @jackyliuxx in
|
||||
[#25300](https://github.com/google-gemini/gemini-cli/pull/25300)
|
||||
- fix(extensions): fix bundling for examples by @abhipatel12 in
|
||||
[#25542](https://github.com/google-gemini/gemini-cli/pull/25542)
|
||||
- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in
|
||||
[#25515](https://github.com/google-gemini/gemini-cli/pull/25515)
|
||||
- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in
|
||||
[#25090](https://github.com/google-gemini/gemini-cli/pull/25090)
|
||||
- docs(policy): mention that workspace policies are broken by @6112 in
|
||||
[#24367](https://github.com/google-gemini/gemini-cli/pull/24367)
|
||||
- fix(core): allow explicit write permissions to override governance file
|
||||
protections in sandboxes by @galz10 in
|
||||
[#25338](https://github.com/google-gemini/gemini-cli/pull/25338)
|
||||
- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by
|
||||
@mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427)
|
||||
- Reduce blank lines. by @gundermanc in
|
||||
[#25563](https://github.com/google-gemini/gemini-cli/pull/25563)
|
||||
- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in
|
||||
[#22542](https://github.com/google-gemini/gemini-cli/pull/22542)
|
||||
- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save
|
||||
backoff by @mahimashanware in
|
||||
[#25382](https://github.com/google-gemini/gemini-cli/pull/25382)
|
||||
- feat(core): Disable topic updates for subagents by @gundermanc in
|
||||
[#25567](https://github.com/google-gemini/gemini-cli/pull/25567)
|
||||
- feat(core): enable topic update narration by default and promote to general by
|
||||
@gundermanc in
|
||||
[#25586](https://github.com/google-gemini/gemini-cli/pull/25586)
|
||||
- docs: migrate installation and authentication to mdx with tabbed layouts by
|
||||
@g-samroberts in
|
||||
[#25155](https://github.com/google-gemini/gemini-cli/pull/25155)
|
||||
- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in
|
||||
[#25601](https://github.com/google-gemini/gemini-cli/pull/25601)
|
||||
- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by
|
||||
@sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702)
|
||||
- fix(cli): round slow render latency to avoid opentelemetry float warning by
|
||||
@scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709)
|
||||
- docs(tracker): introduce experimental task tracker feature by @anj-s in
|
||||
[#24556](https://github.com/google-gemini/gemini-cli/pull/24556)
|
||||
- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux
|
||||
in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414)
|
||||
- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in
|
||||
[#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
|
||||
- Changelog for v0.38.2 by @gemini-cli-robot in
|
||||
[#25593](https://github.com/google-gemini/gemini-cli/pull/25593)
|
||||
- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in
|
||||
[#25022](https://github.com/google-gemini/gemini-cli/pull/25022)
|
||||
- feat(test): refactor the memory usage test to use metrics from CLI process
|
||||
instead of test runner by @cynthialong0-0 in
|
||||
[#25708](https://github.com/google-gemini/gemini-cli/pull/25708)
|
||||
- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in
|
||||
[#25513](https://github.com/google-gemini/gemini-cli/pull/25513)
|
||||
- Fix/allow for session persistence by @ahsanfarooq210 in
|
||||
[#25176](https://github.com/google-gemini/gemini-cli/pull/25176)
|
||||
- Allow dots on GEMINI_API_KEY by @DKbyo in
|
||||
[#25497](https://github.com/google-gemini/gemini-cli/pull/25497)
|
||||
- feat(telemetry): add flag for enabling traces specifically by @spencer426 in
|
||||
[#25343](https://github.com/google-gemini/gemini-cli/pull/25343)
|
||||
- fix(core): resolve nested plan directory duplication and relative path
|
||||
policies by @mahimashanware in
|
||||
[#25138](https://github.com/google-gemini/gemini-cli/pull/25138)
|
||||
- feat: detect new files in @ recommendations with watcher based updates by
|
||||
@prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256)
|
||||
- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by
|
||||
[#25873](https://github.com/google-gemini/gemini-cli/pull/25873)
|
||||
- fix(cli): add missing response key to custom theme text schema by @gaurav0107
|
||||
in [#25822](https://github.com/google-gemini/gemini-cli/pull/25822)
|
||||
- fix(cli): provide manual update command when automatic update fails by
|
||||
@cocosheng-g in
|
||||
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
|
||||
- fix(cli): ensure theme dialog labels are rendered for all themes by
|
||||
@JayadityaGit in
|
||||
[#24599](https://github.com/google-gemini/gemini-cli/pull/24599)
|
||||
- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child
|
||||
processes by @euxaristia in
|
||||
[#22620](https://github.com/google-gemini/gemini-cli/pull/22620)
|
||||
- feat: add /new as alias for /clear and refine command description by @ved015
|
||||
in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865)
|
||||
- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in
|
||||
[#25626](https://github.com/google-gemini/gemini-cli/pull/25626)
|
||||
- fix(core): remove duplicate initialize call on agents refreshed by
|
||||
@adamfweidman in
|
||||
[#25670](https://github.com/google-gemini/gemini-cli/pull/25670)
|
||||
- test(e2e): default integration tests to Flash Preview by @SandyTao520 in
|
||||
[#25753](https://github.com/google-gemini/gemini-cli/pull/25753)
|
||||
- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing
|
||||
across four tiers by @SandyTao520 in
|
||||
[#25716](https://github.com/google-gemini/gemini-cli/pull/25716)
|
||||
- fix(cli): fix "/clear (new)" command by @mini2s in
|
||||
[#25801](https://github.com/google-gemini/gemini-cli/pull/25801)
|
||||
- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0'
|
||||
by @thekishandev in
|
||||
[#24414](https://github.com/google-gemini/gemini-cli/pull/24414)
|
||||
- fix(core): handle line endings in ignore file parsing by @xoma-zver in
|
||||
[#23895](https://github.com/google-gemini/gemini-cli/pull/23895)
|
||||
- Fix/command injection shell by @Famous077 in
|
||||
[#24170](https://github.com/google-gemini/gemini-cli/pull/24170)
|
||||
- fix(ui): removed background color for input by @devr0306 in
|
||||
[#25339](https://github.com/google-gemini/gemini-cli/pull/25339)
|
||||
- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in
|
||||
[#24496](https://github.com/google-gemini/gemini-cli/pull/24496)
|
||||
- fix(core): support jsonl session logs in memory and summary services by
|
||||
@SandyTao520 in
|
||||
[#25816](https://github.com/google-gemini/gemini-cli/pull/25816)
|
||||
- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in
|
||||
[#25841](https://github.com/google-gemini/gemini-cli/pull/25841)
|
||||
- fix(patch): cherry-pick 048bf6e to release/v0.40.0-preview.3-pr-25941 to patch
|
||||
version v0.40.0-preview.3 and create version 0.40.0-preview.4 by
|
||||
[#26052](https://github.com/google-gemini/gemini-cli/pull/26052)
|
||||
- test(cli): add unit tests for restore ACP command (#23402) by @cocosheng-g in
|
||||
[#26053](https://github.com/google-gemini/gemini-cli/pull/26053)
|
||||
- fix(ui): better error messages for ECONNRESET and ETIMEDOUT by @devr0306 in
|
||||
[#26059](https://github.com/google-gemini/gemini-cli/pull/26059)
|
||||
- feat(core): wire up the new ContextManager and AgentChatHistory by @joshualitt
|
||||
in [#25409](https://github.com/google-gemini/gemini-cli/pull/25409)
|
||||
- fix(cli): ensure sandbox proxy cleanup and remove handler leaks by @ehedlund
|
||||
in [#26065](https://github.com/google-gemini/gemini-cli/pull/26065)
|
||||
- fix(cli): correct alternate buffer warning logic for JetBrains by @Adib234 in
|
||||
[#26067](https://github.com/google-gemini/gemini-cli/pull/26067)
|
||||
- fix(cli): make MCP ping optional in list command and use configured timeout by
|
||||
@cocosheng-g in
|
||||
[#26068](https://github.com/google-gemini/gemini-cli/pull/26068)
|
||||
- fix(core): better error message for failed cloudshell-gca auth by @devr0306 in
|
||||
[#26079](https://github.com/google-gemini/gemini-cli/pull/26079)
|
||||
- feat(cli): provide manual session UUID via command line arg by @cocosheng-g in
|
||||
[#26060](https://github.com/google-gemini/gemini-cli/pull/26060)
|
||||
- Changelog for v0.40.0-preview.2 by @gemini-cli-robot in
|
||||
[#25846](https://github.com/google-gemini/gemini-cli/pull/25846)
|
||||
- (docs) update sandboxing documentation by @g-samroberts in
|
||||
[#25930](https://github.com/google-gemini/gemini-cli/pull/25930)
|
||||
- fix(core): enforce parallel task tracker updates by @anj-s in
|
||||
[#24477](https://github.com/google-gemini/gemini-cli/pull/24477)
|
||||
- Update policy so transient errors are not marked terminal by @DavidAPierce in
|
||||
[#26066](https://github.com/google-gemini/gemini-cli/pull/26066)
|
||||
- Implement bot that performs time-series metric analysis and suggests repo
|
||||
management improvements by @gundermanc in
|
||||
[#25945](https://github.com/google-gemini/gemini-cli/pull/25945)
|
||||
- fix(core): handle non-string model flags in resolution by @Adib234 in
|
||||
[#26069](https://github.com/google-gemini/gemini-cli/pull/26069)
|
||||
- fix(ux): added error message for ENOTDIR by @devr0306 in
|
||||
[#26128](https://github.com/google-gemini/gemini-cli/pull/26128)
|
||||
- Changelog for v0.40.0-preview.3 by @gemini-cli-robot in
|
||||
[#25904](https://github.com/google-gemini/gemini-cli/pull/25904)
|
||||
- fix(cli): prevent ACP stdout pollution from SessionEnd hooks by @cocosheng-g
|
||||
in [#26125](https://github.com/google-gemini/gemini-cli/pull/26125)
|
||||
- feat(cli): support boolean and number casting for env vars in settings.json by
|
||||
@cocosheng-g in
|
||||
[#26118](https://github.com/google-gemini/gemini-cli/pull/26118)
|
||||
- fix(cli): preserve Request headers in DevTools activity logger by @Adib234 in
|
||||
[#26078](https://github.com/google-gemini/gemini-cli/pull/26078)
|
||||
- fix(patch): cherry-pick 2194da2 to release/v0.41.0-preview.0-pr-26153 to patch
|
||||
version v0.41.0-preview.0 and create version 0.41.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#25942](https://github.com/google-gemini/gemini-cli/pull/25942)
|
||||
- fix(patch): cherry-pick 54b7586 to release/v0.40.0-preview.4-pr-26066
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#26124](https://github.com/google-gemini/gemini-cli/pull/26124)
|
||||
[#26269](https://github.com/google-gemini/gemini-cli/pull/26269)
|
||||
- fix(patch): cherry-pick 1d72a12 to release/v0.41.0-preview.1-pr-26479 to patch
|
||||
version v0.41.0-preview.1 and create version 0.41.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#26508](https://github.com/google-gemini/gemini-cli/pull/26508)
|
||||
- fix(patch): cherry-pick 7cc19c2 to release/v0.41.0-preview.2-pr-26507 to patch
|
||||
version v0.41.0-preview.2 and create version 0.41.0-preview.3 by
|
||||
@gemini-cli-robot in
|
||||
[#26530](https://github.com/google-gemini/gemini-cli/pull/26530)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.39.1...v0.40.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.40.1...v0.41.0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Release extensions
|
||||
|
||||
Release Gemini CLI extensions to your users through a Git repository or GitHub
|
||||
Releases.
|
||||
Releases. This guide explains how to share your work, list it in the gallery,
|
||||
and manage updates.
|
||||
|
||||
Git repository releases are the simplest approach and offer the most flexibility
|
||||
for managing development branches. GitHub Releases are more efficient for
|
||||
@@ -153,29 +154,62 @@ jobs:
|
||||
release/win32.arm64.my-tool.zip
|
||||
```
|
||||
|
||||
## Migrating an Extension Repository
|
||||
## Migrate an extension repository
|
||||
|
||||
If you need to move your extension to a new repository (for example, from a
|
||||
personal account to an organization) or rename it, you can use the `migratedTo`
|
||||
property in your `gemini-extension.json` file to seamlessly transition your
|
||||
If you move your extension to a new repository or rename it, use the
|
||||
`migratedTo` property in `gemini-extension.json` to seamlessly transition your
|
||||
users.
|
||||
|
||||
1. **Create the new repository**: Setup your extension in its new location.
|
||||
2. **Update the old repository**: In your original repository, update the
|
||||
`gemini-extension.json` file to include the `migratedTo` property, pointing
|
||||
to the new repository URL, and bump the version number. You can optionally
|
||||
change the `name` of your extension at this time in the new repository.
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.1.0",
|
||||
"migratedTo": "https://github.com/new-owner/new-extension-repo"
|
||||
}
|
||||
```
|
||||
3. **Release the update**: Publish this new version in your old repository.
|
||||
1. **Create the new repository:** Set up your extension in its new location.
|
||||
2. **Update the old repository:** In your original repository, update the
|
||||
`gemini-extension.json` file to include the `migratedTo` property pointing
|
||||
to the new repository URL, and increment the version number.
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.1.0",
|
||||
"migratedTo": "https://github.com/new-owner/new-extension-repo"
|
||||
}
|
||||
```
|
||||
3. **Release the update:** Publish this new version in your old repository.
|
||||
|
||||
When users check for updates, Gemini CLI will detect the `migratedTo` field,
|
||||
verify that the new repository contains a valid extension update, and
|
||||
automatically update their local installation to track the new source and name
|
||||
moving forward. All extension settings will automatically migrate to the new
|
||||
installation.
|
||||
When users check for updates, Gemini CLI detects the `migratedTo` field,
|
||||
verifies the new repository, and automatically updates their local installation
|
||||
to track the new source. All settings migrate automatically.
|
||||
|
||||
## How updates work
|
||||
|
||||
Gemini CLI automatically checks for extension updates based on the installation
|
||||
method. Understanding these mechanisms helps you ensure your users always have
|
||||
the latest version.
|
||||
|
||||
### Sync manifest and tags
|
||||
|
||||
For GitHub releases, always ensure the `version` in `gemini-extension.json`
|
||||
matches your GitHub release tag. While the CLI uses tags for update detection,
|
||||
it displays the manifest version in the UI. Keeping them in sync prevents
|
||||
confusion.
|
||||
|
||||
### Update mechanisms
|
||||
|
||||
<details>
|
||||
<summary>Technical update details</summary>
|
||||
|
||||
The CLI uses different strategies depending on the installation type:
|
||||
|
||||
- **GitHub releases:** The CLI queries the GitHub API for the latest release
|
||||
tag. It ignores the `version` field in the manifest for detection.
|
||||
- **Git clones:** The CLI runs `git ls-remote` to compare the latest remote
|
||||
commit hash with your local `HEAD`.
|
||||
- **Local extensions:** The CLI compares the `version` field in the source
|
||||
directory's manifest with the installed version.
|
||||
|
||||
To verify an extension's installation type, inspect the `type` field in the
|
||||
metadata file at `~/.gemini/extensions/<name>/.gemini-extension-install.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!IMPORTANT]
|
||||
> The `migratedTo` flow requires at least one release on the new repository for
|
||||
> the CLI to recognize it as a valid update source.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { type SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import {
|
||||
ToolCallDecision,
|
||||
getShellConfiguration,
|
||||
isWindows,
|
||||
type WorktreeSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
@@ -22,6 +23,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getShellConfiguration: vi.fn(),
|
||||
isWindows: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,6 +46,7 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
|
||||
const isWindowsMock = vi.mocked(isWindows);
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = async (
|
||||
@@ -106,6 +109,7 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
});
|
||||
isWindowsMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders the summary display with a title', async () => {
|
||||
@@ -149,7 +153,7 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// Standard UUID characters should not be escaped/quoted by default for bash.
|
||||
// Standard UUID characters are NOT wrapped in double quotes on non-Windows.
|
||||
expect(output).toContain('gemini --resume 1234-abcd-5678-efgh');
|
||||
unmount();
|
||||
});
|
||||
@@ -167,7 +171,8 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders a standard UUID-formatted session ID in the footer (powershell)', async () => {
|
||||
it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => {
|
||||
isWindowsMock.mockReturnValue(true);
|
||||
getShellConfigurationMock.mockReturnValue({
|
||||
executable: 'powershell.exe',
|
||||
argsPrefix: ['-NoProfile', '-Command'],
|
||||
@@ -181,9 +186,8 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// PowerShell doesn't wraps UUID in single quotes because
|
||||
// it contains no special characters.
|
||||
expect(output).toContain('gemini --resume 1234-abcd-5678-efgh');
|
||||
// PowerShell doesn't wrap UUID in quotes by default, but we wrap it in double quotes on Windows.
|
||||
expect(output).toContain('gemini --resume "1234-abcd-5678-efgh"');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -201,7 +205,8 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// PowerShell wraps in single quotes and escapes internal single quotes by doubling them
|
||||
// PowerShell wraps in single quotes and escapes internal single quotes by doubling them.
|
||||
// Since it's already quoted, we don't add redundant double quotes.
|
||||
expect(output).toContain("gemini --resume '''; rm -rf / #'");
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import type React from 'react';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
isWindows,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
@@ -24,11 +28,17 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
const worktreeSettings = config.getWorktreeSettings();
|
||||
|
||||
const escapedSessionId = escapeShellArg(stats.sessionId, shell);
|
||||
let footer = `To resume this session: gemini --resume ${escapedSessionId}`;
|
||||
const footerSessionId =
|
||||
isWindows() &&
|
||||
!escapedSessionId.startsWith('"') &&
|
||||
!escapedSessionId.startsWith("'")
|
||||
? `"${escapedSessionId}"`
|
||||
: escapedSessionId;
|
||||
let footer = `To resume this session: gemini --resume ${footerSessionId}`;
|
||||
|
||||
if (worktreeSettings) {
|
||||
footer =
|
||||
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` +
|
||||
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${footerSessionId}\n` +
|
||||
`To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
contentPartsToGeminiParts,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './content-utils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { ContentPart } from './types.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
describe('geminiPartsToContentParts', () => {
|
||||
it('converts text parts', () => {
|
||||
@@ -191,11 +192,17 @@ describe('contentPartsToGeminiParts', () => {
|
||||
const content = [
|
||||
{ type: 'custom_widget', payload: 123 },
|
||||
] as unknown as ContentPart[];
|
||||
|
||||
const warnSpy = vi.spyOn(debugLogger, 'warn');
|
||||
const result = contentPartsToGeminiParts(content);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
text: JSON.stringify({ type: 'custom_widget', payload: 123 }),
|
||||
});
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import type { ContentPart } from './types.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
/**
|
||||
* Converts Gemini API Part objects to framework-agnostic ContentPart objects.
|
||||
@@ -93,6 +94,9 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
|
||||
result.push({ text: part.text });
|
||||
break;
|
||||
default:
|
||||
debugLogger.warn(
|
||||
`Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`,
|
||||
);
|
||||
// Serialize unknown ContentPart variants instead of dropping them
|
||||
result.push({ text: JSON.stringify(part) });
|
||||
break;
|
||||
|
||||
@@ -1330,6 +1330,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
expect(err?.message).toBe('Connection refused');
|
||||
expect(err?.fatal).toBe(true);
|
||||
expect(err?._meta?.['stack']).toBeDefined();
|
||||
|
||||
const streamEnd = events.find(
|
||||
(e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
|
||||
|
||||
@@ -166,6 +166,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
} else {
|
||||
this._emitErrorAndAgentEnd(err);
|
||||
}
|
||||
} finally {
|
||||
this._clearActiveStream();
|
||||
}
|
||||
}
|
||||
@@ -390,6 +391,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (err instanceof Error) {
|
||||
meta['errorName'] = err.constructor.name;
|
||||
meta['stack'] = err.stack;
|
||||
if ('exitCode' in err && typeof err.exitCode === 'number') {
|
||||
meta['exitCode'] = err.exitCode;
|
||||
}
|
||||
|
||||
@@ -60,9 +60,7 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
|
||||
|
||||
// Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
|
||||
expect(projection[0].role).toBe('user');
|
||||
expect(projection[0].parts![0].text).toBe(
|
||||
'[Continuing from previous AI thoughts...]',
|
||||
);
|
||||
expect(projection[0].parts![0].text).toContain('User turn 17');
|
||||
|
||||
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
|
||||
const contentNodes = projection.filter(
|
||||
|
||||
@@ -72,7 +72,11 @@ export class ContextManager {
|
||||
event.targets,
|
||||
event.returnedNodes,
|
||||
);
|
||||
this.evaluateTriggers(new Set());
|
||||
// We explicitly DO NOT call evaluateTriggers here.
|
||||
// The Context Manager is a one-way assembly line. It only evaluates triggers
|
||||
// when fundamentally new organic context is added via PristineHistoryUpdated.
|
||||
// Re-evaluating after a processor finishes creates infinite feedback loops if
|
||||
// the processor fails to reduce the token count below the threshold.
|
||||
});
|
||||
|
||||
this.historyObserver.start();
|
||||
@@ -126,10 +130,15 @@ export class ContextManager {
|
||||
// Walk backwards finding nodes that fall out of the retained budget
|
||||
for (let i = this.buffer.nodes.length - 1; i >= 0; i--) {
|
||||
const node = this.buffer.nodes[i];
|
||||
const priorTokens = rollingTokens;
|
||||
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
|
||||
node,
|
||||
]);
|
||||
if (rollingTokens > this.sidecar.config.budget.retainedTokens) {
|
||||
|
||||
// Loose Boundary Policy: If this node is the one that pushes us over the retained limit,
|
||||
// we KEEP it to prevent aggressive undershooting. We only age out nodes that are
|
||||
// strictly *older* than the boundary node.
|
||||
if (priorTokens > this.sidecar.config.budget.retainedTokens) {
|
||||
// Only age out if not protected
|
||||
if (!protectedIds.has(node.id)) {
|
||||
agedOutNodes.add(node.id);
|
||||
|
||||
@@ -61,4 +61,169 @@ describe('render', () => {
|
||||
|
||||
expect(result.history).toEqual([{ text: '1' }, { text: '2' }]);
|
||||
});
|
||||
|
||||
it('simulates the boundary knapsack problem (loose boundary policy)', async () => {
|
||||
// 10k, 20k, 40k, 5k
|
||||
const mockNodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'D',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
{
|
||||
id: 'C',
|
||||
type: NodeType.AGENT_THOUGHT,
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
{
|
||||
id: 'B',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
{
|
||||
id: 'A',
|
||||
type: NodeType.AGENT_THOUGHT,
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
];
|
||||
|
||||
const tokenMap: Record<string, number> = {
|
||||
D: 5000,
|
||||
C: 40000,
|
||||
B: 20000,
|
||||
A: 10000,
|
||||
};
|
||||
|
||||
const orchestrator = {
|
||||
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
|
||||
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
|
||||
),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
|
||||
const sidecar = {
|
||||
config: {
|
||||
budget: { maxTokens: 150000, retainedTokens: 65000 },
|
||||
},
|
||||
} as unknown as ContextProfile;
|
||||
|
||||
const currentTokens = 160000;
|
||||
|
||||
const env = {
|
||||
llmClient: {
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
|
||||
},
|
||||
tokenCalculator: {
|
||||
calculateConcreteListTokens: vi.fn((nodes) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
calculateTokenBreakdown: vi.fn(() => ({})),
|
||||
},
|
||||
graphMapper: {
|
||||
fromGraph: vi.fn((nodes: readonly ConcreteNode[]) =>
|
||||
nodes.map((n) => ({ text: n.id })),
|
||||
),
|
||||
},
|
||||
} as unknown as ContextEnvironment;
|
||||
|
||||
const tracer = {
|
||||
logEvent: vi.fn(),
|
||||
} as unknown as ContextTracer;
|
||||
|
||||
const result = await render(
|
||||
mockNodes,
|
||||
orchestrator,
|
||||
sidecar,
|
||||
tracer,
|
||||
env,
|
||||
new Map(),
|
||||
0,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const surviving = result.history.map((c: any) => c.text);
|
||||
// Loose Boundary: A (10k), B (20k), C (40k). Total = 70k.
|
||||
// Adding C pushes rolling total (70k) above retainedTokens (65k).
|
||||
// Under loose policy, C survives. D is strictly older and drops.
|
||||
expect(surviving).toEqual(['C', 'B', 'A']); // D is dropped
|
||||
});
|
||||
|
||||
it('drops nodes that are STRICTLY older than the boundary node', async () => {
|
||||
const mockNodes: ConcreteNode[] = [
|
||||
{
|
||||
id: 'A',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
{
|
||||
id: 'B',
|
||||
type: NodeType.AGENT_THOUGHT,
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
{
|
||||
id: 'C',
|
||||
type: NodeType.USER_PROMPT,
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
];
|
||||
|
||||
const tokenMap: Record<string, number> = {
|
||||
C: 40000,
|
||||
B: 40000,
|
||||
A: 10000,
|
||||
};
|
||||
|
||||
const orchestrator = {
|
||||
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
|
||||
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
|
||||
),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
|
||||
const sidecar = {
|
||||
config: {
|
||||
budget: { maxTokens: 150000, retainedTokens: 65000 },
|
||||
},
|
||||
} as unknown as ContextProfile;
|
||||
|
||||
const currentTokens = 160000;
|
||||
|
||||
const env = {
|
||||
llmClient: {
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
|
||||
},
|
||||
tokenCalculator: {
|
||||
calculateConcreteListTokens: vi.fn((nodes) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
calculateTokenBreakdown: vi.fn(() => ({})),
|
||||
},
|
||||
graphMapper: {
|
||||
fromGraph: vi.fn((nodes: readonly ConcreteNode[]) =>
|
||||
nodes.map((n) => ({ text: n.id })),
|
||||
),
|
||||
},
|
||||
} as unknown as ContextEnvironment;
|
||||
|
||||
const tracer = {
|
||||
logEvent: vi.fn(),
|
||||
} as unknown as ContextTracer;
|
||||
|
||||
const result = await render(
|
||||
mockNodes,
|
||||
orchestrator,
|
||||
sidecar,
|
||||
tracer,
|
||||
env,
|
||||
new Map(),
|
||||
0,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const surviving = result.history.map((c: any) => c.text);
|
||||
// C(40k), B(40k). Adding B pushes total to 80k. B is the boundary node and survives. A drops.
|
||||
expect(surviving).toEqual(['B', 'C']); // A is dropped
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ContextTracer } from '../tracer.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import { performCalibration } from '../utils/tokenCalibration.js';
|
||||
|
||||
/**
|
||||
* Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
|
||||
@@ -68,6 +69,7 @@ export async function render(
|
||||
tracer.logEvent('Render', 'Render Context for LLM', {
|
||||
renderedContext: contents,
|
||||
});
|
||||
performCalibration(env, visibleNodes, contents);
|
||||
return { history: contents, didApplyManagement: false };
|
||||
}
|
||||
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
|
||||
@@ -83,9 +85,12 @@ export async function render(
|
||||
// Start from newest and count backwards
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const node = nodes[i];
|
||||
const priorTokens = rollingTokens;
|
||||
const nodeTokens = env.tokenCalculator.calculateConcreteListTokens([node]);
|
||||
rollingTokens += nodeTokens;
|
||||
if (rollingTokens > sidecar.config.budget.retainedTokens) {
|
||||
|
||||
// Loose Boundary Policy: Keep the node that crosses the boundary
|
||||
if (priorTokens > sidecar.config.budget.retainedTokens) {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
@@ -113,5 +118,6 @@ export async function render(
|
||||
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
|
||||
renderedContextSanitized: contents,
|
||||
});
|
||||
performCalibration(env, visibleNodes, contents);
|
||||
return { history: contents, didApplyManagement: true };
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ export async function initializeContextManager(
|
||||
tracer,
|
||||
4,
|
||||
eventBus,
|
||||
{
|
||||
calibrateTokenCalculation:
|
||||
!!process.env['GEMINI_CONTEXT_CALIBRATE_TOKEN_CALCULATIONS'],
|
||||
},
|
||||
);
|
||||
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
|
||||
@@ -13,6 +13,10 @@ import type { ContextGraphMapper } from '../graph/mapper.js';
|
||||
|
||||
export type { ContextTracer, ContextEventBus };
|
||||
|
||||
export interface RenderOptions {
|
||||
calibrateTokenCalculation?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextEnvironment {
|
||||
readonly llmClient: BaseLlmClient;
|
||||
readonly promptId: string;
|
||||
@@ -26,4 +30,5 @@ export interface ContextEnvironment {
|
||||
readonly inbox: LiveInbox;
|
||||
readonly behaviorRegistry: NodeBehaviorRegistry;
|
||||
readonly graphMapper: ContextGraphMapper;
|
||||
readonly renderOptions?: RenderOptions;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type { ContextTracer } from '../tracer.js';
|
||||
import type { ContextEnvironment } from './environment.js';
|
||||
import type { ContextEnvironment, RenderOptions } from './environment.js';
|
||||
import type { ContextEventBus } from '../eventBus.js';
|
||||
import { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import { LiveInbox } from './inbox.js';
|
||||
@@ -29,6 +29,7 @@ export class ContextEnvironmentImpl implements ContextEnvironment {
|
||||
readonly tracer: ContextTracer,
|
||||
readonly charsPerToken: number,
|
||||
readonly eventBus: ContextEventBus,
|
||||
readonly renderOptions?: RenderOptions,
|
||||
) {
|
||||
this.behaviorRegistry = new NodeBehaviorRegistry();
|
||||
registerBuiltInBehaviors(this.behaviorRegistry);
|
||||
|
||||
+193
-23
File diff suppressed because one or more lines are too long
@@ -9,11 +9,7 @@ import fs from 'node:fs';
|
||||
import { SimulationHarness } from './simulationHarness.js';
|
||||
import { createMockLlmClient } from '../testing/contextTestUtils.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
import { createToolMaskingProcessor } from '../processors/toolMaskingProcessor.js';
|
||||
import { createBlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
|
||||
import { createStateSnapshotProcessor } from '../processors/stateSnapshotProcessor.js';
|
||||
import { createHistoryTruncationProcessor } from '../processors/historyTruncationProcessor.js';
|
||||
import { createStateSnapshotAsyncProcessor } from '../processors/stateSnapshotAsyncProcessor.js';
|
||||
import { stressTestProfile } from '../config/profiles.js';
|
||||
|
||||
expect.addSnapshotSerializer({
|
||||
test: (val) =>
|
||||
@@ -52,57 +48,22 @@ describe('System Lifecycle Golden Tests', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const getAggressiveConfig = (): ContextProfile => ({
|
||||
name: 'Aggressive Test',
|
||||
config: {
|
||||
budget: { maxTokens: 1000, retainedTokens: 500 }, // Extremely tight limits
|
||||
},
|
||||
buildPipelines: (env) => [
|
||||
{
|
||||
name: 'Pressure Relief', // Emits from eventBus 'retained_exceeded'
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createBlobDegradationProcessor('BlobDegradationProcessor', env),
|
||||
createToolMaskingProcessor('ToolMaskingProcessor', env, {
|
||||
stringLengthThresholdTokens: 50,
|
||||
}),
|
||||
createStateSnapshotProcessor('StateSnapshotProcessor', env, {}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Immediate Sanitization', // The magic string the projector is hardcoded to use
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createHistoryTruncationProcessor(
|
||||
'HistoryTruncationProcessor',
|
||||
env,
|
||||
{},
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
buildAsyncPipelines: (env) => [
|
||||
{
|
||||
name: 'Async',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [
|
||||
createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
env,
|
||||
{},
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mockLlmClient = createMockLlmClient([
|
||||
'<MOCKED_STATE_SNAPSHOT_SUMMARY>',
|
||||
]);
|
||||
// Uses dynamic role-based mocking to differentiate Snapshot vs Distillation output automatically.
|
||||
const mockLlmClient = createMockLlmClient();
|
||||
|
||||
it('Scenario 1: Organic Growth with Huge Tool Output & Images', async () => {
|
||||
// Override stressTestProfile limits slightly to ensure immediate overflow
|
||||
// without having to push 50,000 characters to cross the generalist boundaries.
|
||||
const customProfile: ContextProfile = {
|
||||
...stressTestProfile,
|
||||
config: {
|
||||
...stressTestProfile.config,
|
||||
budget: { maxTokens: 1000, retainedTokens: 500 },
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await SimulationHarness.create(
|
||||
getAggressiveConfig(),
|
||||
customProfile,
|
||||
mockLlmClient,
|
||||
);
|
||||
|
||||
@@ -169,6 +130,9 @@ describe('System Lifecycle Golden Tests', () => {
|
||||
{ role: 'model', parts: [{ text: 'Yes we can.' }] },
|
||||
]);
|
||||
|
||||
// Give the background tasks a moment to inject the snapshot into the graph
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Get final state
|
||||
const goldenState = await harness.getGoldenState();
|
||||
|
||||
@@ -212,54 +176,117 @@ describe('System Lifecycle Golden Tests', () => {
|
||||
expect(goldenState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Scenario 3: Async-Driven Background GC', async () => {
|
||||
const gcConfig: ContextProfile = {
|
||||
name: 'GC Test Config',
|
||||
it('Scenario 3: Node Distillation of Large Historical Messages', async () => {
|
||||
// 1 Turn = ~2520 tokens.
|
||||
// retainedTokens = 4000 ensures Turn 0 is kept intact until Turn 1 pushes the total to ~5040.
|
||||
const customProfile: ContextProfile = {
|
||||
...stressTestProfile,
|
||||
config: {
|
||||
budget: { maxTokens: 200, retainedTokens: 100 },
|
||||
},
|
||||
buildPipelines: () => [],
|
||||
buildAsyncPipelines: (env) => [
|
||||
{
|
||||
name: 'Async',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [
|
||||
createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
env,
|
||||
{},
|
||||
),
|
||||
],
|
||||
...stressTestProfile.config,
|
||||
budget: { maxTokens: 10000, retainedTokens: 4000 },
|
||||
processorOptions: {
|
||||
...stressTestProfile.config?.processorOptions,
|
||||
NodeDistillation: {
|
||||
type: 'NodeDistillationProcessor',
|
||||
options: {
|
||||
nodeThresholdTokens: 1000, // 1250 > 1000, so older messages will be distilled
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Disable async pipelines (StateSnapshots) so they don't compete with the Normalization pipeline
|
||||
buildAsyncPipelines: () => [],
|
||||
};
|
||||
|
||||
const harness = await SimulationHarness.create(gcConfig, mockLlmClient);
|
||||
const harness = await SimulationHarness.create(
|
||||
customProfile,
|
||||
mockLlmClient,
|
||||
);
|
||||
|
||||
// Turn 0
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'A'.repeat(50) }] },
|
||||
{ role: 'model', parts: [{ text: 'B'.repeat(50) }] },
|
||||
{ role: 'user', parts: [{ text: 'A'.repeat(5000) }] },
|
||||
{ role: 'model', parts: [{ text: 'B'.repeat(5000) }] },
|
||||
]);
|
||||
|
||||
// Turn 1 (Should trigger StateSnapshotasync pipeline because we exceed 100 retainedTokens)
|
||||
// Turn 1
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'C'.repeat(50) }] },
|
||||
{ role: 'model', parts: [{ text: 'D'.repeat(50) }] },
|
||||
{ role: 'user', parts: [{ text: 'C'.repeat(5000) }] },
|
||||
{ role: 'model', parts: [{ text: 'D'.repeat(5000) }] },
|
||||
]);
|
||||
|
||||
// Give the async background pipeline an extra beat to complete its async execution and emit variants
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Turn 2
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'E'.repeat(50) }] },
|
||||
{ role: 'model', parts: [{ text: 'F'.repeat(50) }] },
|
||||
{ role: 'user', parts: [{ text: 'E'.repeat(5000) }] },
|
||||
{ role: 'model', parts: [{ text: 'F'.repeat(5000) }] },
|
||||
]);
|
||||
|
||||
const goldenState = await harness.getGoldenState();
|
||||
|
||||
// We should see ROLLING_SUMMARY nodes injected into the graph, proving the async pipeline ran in the background
|
||||
// We should see MOCKED_DISTILLED_NODE replacing older bloated messages, while recent messages are untouched.
|
||||
expect(goldenState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Scenario 4: Async-Driven Background GC via State Snapshots', async () => {
|
||||
// Mathematical Token Budgeting:
|
||||
// 200 chars ≈ 50 tokens.
|
||||
// 1 Turn (User + Model + Overhead) ≈ 50 + 50 + 20 = 120 Tokens.
|
||||
const customProfile: ContextProfile = {
|
||||
...stressTestProfile,
|
||||
config: {
|
||||
...stressTestProfile.config,
|
||||
// Retain 3 Turns (~360 tokens). Max 5 Turns (~600 tokens).
|
||||
budget: { maxTokens: 600, retainedTokens: 360 },
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await SimulationHarness.create(
|
||||
customProfile,
|
||||
mockLlmClient,
|
||||
);
|
||||
|
||||
const createMessage = (index: number) =>
|
||||
`Msg ${index} `.repeat(25).padEnd(200, '.');
|
||||
|
||||
// Turn 0 (~120 tokens) Total: 120
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: createMessage(0) }] },
|
||||
{ role: 'model', parts: [{ text: createMessage(1) }] },
|
||||
]);
|
||||
|
||||
// Turn 1 (~120 tokens) Total: 240
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: createMessage(2) }] },
|
||||
{ role: 'model', parts: [{ text: createMessage(3) }] },
|
||||
]);
|
||||
|
||||
// Turn 2 (~120 tokens) Total: 360 (At retainedTokens boundary)
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: createMessage(4) }] },
|
||||
{ role: 'model', parts: [{ text: createMessage(5) }] },
|
||||
]);
|
||||
|
||||
// Turn 3 (~120 tokens) Total: 480 (Exceeds retainedTokens! Triggers GC on Turn 0 & 1)
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: createMessage(6) }] },
|
||||
{ role: 'model', parts: [{ text: createMessage(7) }] },
|
||||
]);
|
||||
|
||||
// Give the async background snapshot pipeline time to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Turn 4 (~120 tokens).
|
||||
// If GC succeeded, Turn 0 and 1 are now a ~10 token snapshot.
|
||||
// Total should be: 10 (Snapshot) + 120 (Turn 2) + 120 (Turn 3) + 120 (Turn 4) = ~370 tokens.
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: createMessage(8) }] },
|
||||
{ role: 'model', parts: [{ text: createMessage(9) }] },
|
||||
]);
|
||||
|
||||
const goldenState = await harness.getGoldenState();
|
||||
|
||||
// We should see a MOCKED_STATE_SNAPSHOT_SUMMARY rolling up Turns 0 and 1,
|
||||
// while Turns 2, 3, and 4 remain fully intact.
|
||||
expect(goldenState).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
} from '../graph/types.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type {
|
||||
BaseLlmClient,
|
||||
GenerateContentOptions,
|
||||
} from '../../core/baseLlmClient.js';
|
||||
import type { Content, GenerateContentResponse } from '@google/genai';
|
||||
import { InboxSnapshotImpl } from '../pipeline/inbox.js';
|
||||
import type { InboxMessage, ProcessArgs } from '../pipeline.js';
|
||||
@@ -98,38 +101,38 @@ export function createDummyToolNode(
|
||||
|
||||
export interface MockLlmClient extends BaseLlmClient {
|
||||
generateContent: Mock;
|
||||
countTokens: Mock;
|
||||
}
|
||||
|
||||
export function createMockLlmClient(
|
||||
responses?: Array<string | GenerateContentResponse>,
|
||||
): MockLlmClient {
|
||||
const generateContentMock = vi.fn();
|
||||
|
||||
if (responses && responses.length > 0) {
|
||||
for (const response of responses) {
|
||||
if (typeof response === 'string') {
|
||||
generateContentMock.mockResolvedValueOnce(
|
||||
createMockGenerateContentResponse(response),
|
||||
const generateContentMock = vi
|
||||
.fn()
|
||||
.mockImplementation((options: GenerateContentOptions) => {
|
||||
// Array-based logic for backwards compatibility, if provided
|
||||
if (responses && responses.length > 0) {
|
||||
const callCount = generateContentMock.mock.calls.length - 1;
|
||||
const idx =
|
||||
callCount < responses.length ? callCount : responses.length - 1;
|
||||
const res = responses[idx];
|
||||
return Promise.resolve(
|
||||
typeof res === 'string'
|
||||
? createMockGenerateContentResponse(res)
|
||||
: res,
|
||||
);
|
||||
} else {
|
||||
generateContentMock.mockResolvedValueOnce(response);
|
||||
}
|
||||
}
|
||||
// Fallback to the last response for any subsequent calls
|
||||
const lastResponse = responses[responses.length - 1];
|
||||
if (typeof lastResponse === 'string') {
|
||||
generateContentMock.mockResolvedValue(
|
||||
createMockGenerateContentResponse(lastResponse),
|
||||
|
||||
const lastContent = options.contents[options.contents.length - 1];
|
||||
const lastPart = lastContent?.parts?.[lastContent.parts.length - 1];
|
||||
const lastPartString = JSON.stringify(lastPart ?? {});
|
||||
const contentSample = `${lastPartString.slice(0, 10)}...${lastPartString.slice(-10)}`;
|
||||
return Promise.resolve(
|
||||
createMockGenerateContentResponse(
|
||||
`Mock response from: ${options.role}, for: ${contentSample}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
generateContentMock.mockResolvedValue(lastResponse);
|
||||
}
|
||||
} else {
|
||||
// Default fallback
|
||||
generateContentMock.mockResolvedValue(
|
||||
createMockGenerateContentResponse('Mock LLM response'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import type { ConcreteNode } from '../graph/types.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export function performCalibration(
|
||||
env: ContextEnvironment,
|
||||
finalNodes: readonly ConcreteNode[],
|
||||
finalContents: Content[],
|
||||
) {
|
||||
if (!env.renderOptions?.calibrateTokenCalculation) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const exactResp = await env.llmClient.countTokens({
|
||||
contents: finalContents,
|
||||
});
|
||||
const exactTokens =
|
||||
typeof exactResp.totalTokens === 'number' ? exactResp.totalTokens : 0;
|
||||
const estimatedTokens =
|
||||
env.tokenCalculator.calculateConcreteListTokens(finalNodes);
|
||||
|
||||
const delta = Math.abs(exactTokens - estimatedTokens);
|
||||
const tolerance = Math.max(exactTokens, estimatedTokens) * 0.2; // 20% tolerance
|
||||
|
||||
env.tracer.logEvent('Render', 'Token Calibration Measurement', {
|
||||
exactTokens,
|
||||
estimatedTokens,
|
||||
delta,
|
||||
isWithinTolerance: delta <= tolerance,
|
||||
});
|
||||
|
||||
if (delta > tolerance) {
|
||||
debugLogger.error(
|
||||
`[Token Calibration] Large deviation detected: exact ${exactTokens} vs estimated ${estimatedTokens} (delta: ${delta})`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore API failures during background calibration
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -111,6 +111,11 @@ interface _CommonGenerateOptions {
|
||||
};
|
||||
}
|
||||
|
||||
export interface CountTokenOptions {
|
||||
modelConfigKey?: ModelConfigKey;
|
||||
contents: Content[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A client dedicated to stateless, utility-focused LLM calls.
|
||||
*/
|
||||
@@ -225,6 +230,20 @@ export class BaseLlmClient {
|
||||
return text;
|
||||
}
|
||||
|
||||
async countTokens(
|
||||
options: CountTokenOptions,
|
||||
): Promise<{ totalTokens: number }> {
|
||||
const model = options.modelConfigKey
|
||||
? this.config.modelConfigService.getResolvedConfig(options.modelConfigKey)
|
||||
.model
|
||||
: this.config.getActiveModel();
|
||||
const result = await this.contentGenerator.countTokens({
|
||||
model,
|
||||
contents: options.contents,
|
||||
});
|
||||
return { totalTokens: result.totalTokens || 0 };
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
options: GenerateContentOptions,
|
||||
): Promise<GenerateContentResponse> {
|
||||
|
||||
Reference in New Issue
Block a user