Compare commits

...

28 Commits

Author SHA1 Message Date
mkorwel a0f32ff7d6 chore: fix circular dependency and complete dynamic model configuration migration 2026-03-13 17:43:18 +00:00
Kevin Wang 1693afe027 pull and merge latest changes 2026-03-13 09:29:33 +00:00
kevinjwang1 d179e3673b Merge branch 'main' into splitModelConfigurability 2026-03-13 02:02:55 -07:00
Kevin Wang e4d6a5cc7c Make ensure default definitions are merged with user defined definitions 2026-03-13 08:53:20 +00:00
Kevin Wang fc4fdc3cbe regenerate docs 2026-03-13 08:38:05 +00:00
Kevin Wang bdbb996a2e Add ModelDefinitions to ModelConfigService 2026-03-13 08:25:03 +00:00
Abhi 2d05396dd2 refactor(ui): extract pure session browser utilities (#22256) 2026-03-13 06:22:52 +00:00
Sandy Tao 7b4a822b0e feat(core): instrument file system tools for JIT context discovery (#22082) 2026-03-13 03:44:42 +00:00
Bryan Morgan d44615ac2f feat(core): increase sub-agent turn and time limits (#22196) 2026-03-13 02:39:49 +00:00
joshualitt de656f01d7 feat(core): Fully migrate packages/core to AgentLoopContext. (#22115) 2026-03-13 01:56:31 +00:00
Abhi 1d2585dba6 fix(core): explicitly pass messageBus to policy engine for MCP tool saves (#22255) 2026-03-13 01:31:13 +00:00
Christian Gunderman 97bc3f28c5 build(ci): fix false positive evals trigger on merge commits (#22237) 2026-03-13 00:38:54 +00:00
gemini-cli-robot 3038fdce2e Changelog for v0.33.1 (#22235)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-13 00:12:06 +00:00
ruomeng bb060d7a98 docs(plan): document hooks with plan mode (#22197) 2026-03-12 23:35:56 +00:00
Qiaochu Hu 9a73aa4072 docs: clarify that tools.core is an allowlist for ALL built-in tools (#18813)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: hobostay <hobostay@users.noreply.github.com>
2026-03-12 22:40:05 +00:00
Yuna Seol d7d53981f3 feat(core): add trajectoryId to ConversationOffered telemetry (#22214)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-12 22:12:08 +00:00
krishdef7 19e0b1ff7d fix(cli): escape @ symbols on paste to prevent unintended file expansion (#21239) 2026-03-12 22:05:12 +00:00
Alisa 4d393f9dca feat(a2a): enable native gRPC support and protocol routing (#21403)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-03-12 21:36:50 +00:00
Douglas Reid 5abc170b08 docs(local model routing): add docs on how to use Gemma for local model routing (#21365)
Co-authored-by: Douglas Reid <21148125+douglas-reid@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2026-03-12 21:36:32 +00:00
Sandy Tao ceb4c5f6a7 fix: remove unused img.png from project root (#22222) 2026-03-12 21:28:57 +00:00
Sehoon Shon b6beab9480 perf(cli): enable code splitting and deferred UI loading (#22117) 2026-03-12 20:37:03 +00:00
gemini-cli-robot c2691f44b6 Changelog for v0.34.0-preview.1 (#22194)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-12 20:30:45 +00:00
Aishanee Shah 663d9c0537 feat(core): implement Stage 1 improvements for webfetch tool (#21313) 2026-03-12 20:13:00 +00:00
Gaurav 4863816b81 fix(browser): fix duplicate function declaration error in browser agent (#22207) 2026-03-12 19:58:09 +00:00
Adarsh Pandey 829c532703 fix(core/ide): add Antigravity CLI fallbacks (#22030) 2026-03-12 19:28:36 +00:00
Adam Weidman c68303c553 fix(core): add proxy routing support for remote A2A subagents (#22199) 2026-03-12 19:25:51 +00:00
Abhi 7242d71c01 fix(core): prevent duplicate tool schemas for instantiated tools (#22204) 2026-03-12 19:09:23 +00:00
Yuna Seol 391715c33c fix(core): fix startup stats to use int values for timestamps and durations (#22201)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-12 19:06:12 +00:00
140 changed files with 4403 additions and 1893 deletions
+6 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.33.0
# Latest stable release: v0.33.1
Released: March 11, 2026
Released: March 12, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -29,6 +29,9 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version
v0.33.0 and create version 0.33.1 by @gemini-cli-robot in
[#22206](https://github.com/google-gemini/gemini-cli/pull/22206)
- Docs: Update model docs to remove Preview Features. by @jkcinouye in
[#20084](https://github.com/google-gemini/gemini-cli/pull/20084)
- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in
@@ -228,4 +231,4 @@ npm install -g @google/gemini-cli
[#21952](https://github.com/google-gemini/gemini-cli/pull/21952)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.0
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.1
+6 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.34.0-preview.0
# Preview release: v0.34.0-preview.1
Released: March 11, 2026
Released: March 12, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -28,6 +28,9 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
[CONFLICTS] by @gemini-cli-robot in
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
- feat(cli): add chat resume footer on session quit by @lordshashank in
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
- Support bold and other styles in svg snapshots by @jacob314 in
@@ -465,4 +468,4 @@ npm install -g @google/gemini-cli@preview
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.1
+18 -1
View File
@@ -26,6 +26,20 @@ policies.
the CLI will use an available fallback model for the current turn or the
remainder of the session.
### Local Model Routing (Experimental)
Gemini CLI supports using a local model for routing decisions. When configured,
Gemini CLI will use a locally-running **Gemma** model to make routing decisions
(instead of sending routing decisions to a hosted model). This feature can help
reduce costs associated with hosted model usage while offering similar routing
decision latency and quality.
In order to use this feature, the local Gemma model **must** be served behind a
Gemini API and accessible via HTTP at an endpoint configured in `settings.json`.
For more details on how to configure local model routing, see
[Local Model Routing](../core/local-model-routing.md).
### Model selection precedence
The model used by Gemini CLI is determined by the following order of precedence:
@@ -38,5 +52,8 @@ The model used by Gemini CLI is determined by the following order of precedence:
3. **`model.name` in `settings.json`:** If neither of the above are set, the
model specified in the `model.name` property of your `settings.json` file
will be used.
4. **Default model:** If none of the above are set, the default model will be
4. **Local model (experimental):** If the Gemma local model router is enabled
in your `settings.json` file, the CLI will use the local Gemma model
(instead of Gemini models) to route the request to an appropriate model.
5. **Default model:** If none of the above are set, the default model will be
used. The default model is `auto`
+71 -10
View File
@@ -109,16 +109,6 @@ switch back to another mode.
- **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode.
- **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning."
## Customization and best practices
Plan Mode is secure by default, but you can adapt it to fit your specific
workflows. You can customize how Gemini CLI plans by using skills, adjusting
safety policies, or changing where plans are stored.
## Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Tool Restrictions
Plan Mode enforces strict safety policies to prevent accidental changes.
@@ -146,6 +136,12 @@ These are the only allowed tools:
- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized
instructions and resources in a read-only manner)
## Customization and best practices
Plan Mode is secure by default, but you can adapt it to fit your specific
workflows. You can customize how Gemini CLI plans by using skills, adjusting
safety policies, changing where plans are stored, or adding hooks.
### Custom planning with skills
You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI
@@ -294,6 +290,71 @@ modes = ["plan"]
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
### Using hooks with Plan Mode
You can use the [hook system](../hooks/writing-hooks.md) to automate parts of
the planning workflow or enforce additional checks when Gemini CLI transitions
into or out of Plan Mode.
Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the
`enter_plan_mode` and `exit_plan_mode` tool calls.
> [!WARNING] When hooks are triggered by **tool executions**, they do **not**
> run when you manually toggle Plan Mode using the `/plan` command or the
> `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes,
> ensure the transition is initiated by the agent (e.g., by asking "start a plan
> for...").
#### Example: Archive approved plans to GCS (`AfterTool`)
If your organizational policy requires a record of all execution plans, you can
use an `AfterTool` hook to securely copy the plan artifact to Google Cloud
Storage whenever Gemini CLI exits Plan Mode to start the implementation.
**`.gemini/hooks/archive-plan.sh`:**
```bash
#!/usr/bin/env bash
# Extract the plan path from the tool input JSON
plan_path=$(jq -r '.tool_input.plan_path // empty')
if [ -f "$plan_path" ]; then
# Generate a unique filename using a timestamp
filename="$(date +%s)_$(basename "$plan_path")"
# Upload the plan to GCS in the background so it doesn't block the CLI
gsutil cp "$plan_path" "gs://my-audit-bucket/gemini-plans/$filename" > /dev/null 2>&1 &
fi
# AfterTool hooks should generally allow the flow to continue
echo '{"decision": "allow"}'
```
To register this `AfterTool` hook, add it to your `settings.json`:
```json
{
"hooks": {
"AfterTool": [
{
"matcher": "exit_plan_mode",
"hooks": [
{
"name": "archive-plan",
"type": "command",
"command": "./.gemini/hooks/archive-plan.sh"
}
]
}
]
}
}
```
## Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Planning workflows
Plan Mode provides building blocks for structured research and design. These are
+1
View File
@@ -55,6 +55,7 @@ they appear in the UI.
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
+2
View File
@@ -15,6 +15,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
modular GEMINI.md import feature using @file.md syntax.
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
fine-grained control over tool execution.
- **[Local Model Routing (experimental)](./local-model-routing.md):** Learn how
to enable use of a local Gemma model for model routing decisions.
## Role of the core
+193
View File
@@ -0,0 +1,193 @@
# Local Model Routing (experimental)
Gemini CLI supports using a local model for
[routing decisions](../cli/model-routing.md). When configured, Gemini CLI will
use a locally-running **Gemma** model to make routing decisions (instead of
sending routing decisions to a hosted model).
This feature can help reduce costs associated with hosted model usage while
offering similar routing decision latency and quality.
> **Note: Local model routing is currently an experimental feature.**
## Setup
Using a Gemma model for routing decisions requires that an implementation of a
Gemma model be running locally on your machine, served behind an HTTP endpoint
and accessed via the Gemini API.
To serve the Gemma model, follow these steps:
### Download the LiteRT-LM runtime
The [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime offers
pre-built binaries for locally-serving models. Download the binary appropriate
for your system.
#### Windows
1. Download
[lit.windows_x86_64.exe](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.windows_x86_64.exe).
2. Using GPU on Windows requires the DirectXShaderCompiler. Download the
[dxc zip from the latest release](https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.8.2505.1/dxc_2025_07_14.zip).
Unzip the archive and from the architecture-appropriate `bin\` directory, and
copy the `dxil.dll` and `dxcompiler.dll` into the same location as you saved
`lit.windows_x86_64.exe`.
3. (Optional) Test starting the runtime:
`.\lit.windows_x86_64.exe serve --verbose`
#### Linux
1. Download
[lit.linux_x86_64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.linux_x86_64).
2. Ensure the binary is executable: `chmod a+x lit.linux_x86_64`
3. (Optional) Test starting the runtime: `./lit.linux_x86_64 serve --verbose`
#### MacOS
1. Download
[lit-macos-arm64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.macos_arm64).
2. Ensure the binary is executable: `chmod a+x lit.macos_arm64`
3. (Optional) Test starting the runtime: `./lit.macos_arm64 serve --verbose`
> **Note**: MacOS can be configured to only allows binaries from "App Store &
> Known Developers". If you encounter an error message when attempting to run
> the binary, you will need to allow the application. One option is to visit
> `System Settings -> Privacy & Security`, scroll to `Security`, and click
> `"Allow Anyway"` for `"lit.macos_arm64"`. Another option is to run
> `xattr -d com.apple.quarantine lit.macos_arm64` from the commandline.
### Download the Gemma Model
Before using Gemma, you will need to download the model (and agree to the Terms
of Service).
This can be done via the LiteRT-LM runtime.
#### Windows
```bash
$ .\lit.windows_x86_64.exe pull gemma3-1b-gpu-custom
[Legal] The model you are about to download is governed by
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
Full Terms: https://ai.google.dev/gemma/terms
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
Do you accept these terms? (Y/N): Y
Terms accepted.
Downloading model 'gemma3-1b-gpu-custom' ...
Downloading... 968.6 MB
Download complete.
```
#### Linux
```bash
$ ./lit.linux_x86_64 pull gemma3-1b-gpu-custom
[Legal] The model you are about to download is governed by
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
Full Terms: https://ai.google.dev/gemma/terms
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
Do you accept these terms? (Y/N): Y
Terms accepted.
Downloading model 'gemma3-1b-gpu-custom' ...
Downloading... 968.6 MB
Download complete.
```
#### MacOS
```bash
$ ./lit.lit.macos_arm64 pull gemma3-1b-gpu-custom
[Legal] The model you are about to download is governed by
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
Full Terms: https://ai.google.dev/gemma/terms
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
Do you accept these terms? (Y/N): Y
Terms accepted.
Downloading model 'gemma3-1b-gpu-custom' ...
Downloading... 968.6 MB
Download complete.
```
### Start LiteRT-LM Runtime
Using the command appropriate to your system, start the LiteRT-LM runtime.
Configure the port that you want to use for your Gemma model. For the purposes
of this document, we will use port `9379`.
Example command for MacOS: `./lit.macos_arm64 serve --port=9379 --verbose`
### (Optional) Verify Model Serving
Send a quick prompt to the model via HTTP to validate successful model serving.
This will cause the runtime to download the model and run it once.
You should see a short joke in the server output as an indicator of success.
#### Windows
```
# Run this in PowerShell to send a request to the server
$uri = "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent"
$body = @{contents = @( @{
role = "user"
parts = @( @{ text = "Tell me a joke." } )
})} | ConvertTo-Json -Depth 10
Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json"
```
#### Linux/MacOS
```bash
$ curl "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" \
-H 'Content-Type: application/json' \
-X POST \
-d '{"contents":[{"role":"user","parts":[{"text":"Tell me a joke."}]}]}'
```
## Configuration
To use a local Gemma model for routing, you must explicitly enable it in your
`settings.json`:
```json
{
"experimental": {
"gemmaModelRouter": {
"enabled": true,
"classifier": {
"host": "http://localhost:9379",
"model": "gemma3-1b-gpu-custom"
}
}
}
}
```
> Use the port you started your LiteRT-LM runtime on in the setup steps.
### Configuration schema
| Field | Type | Required | Description |
| :----------------- | :------ | :------- | :----------------------------------------------------------------------------------------- |
| `enabled` | boolean | Yes | Must be `true` to enable the feature. |
| `classifier` | object | Yes | The configuration for the local model endpoint. It includes the host and model specifiers. |
| `classifier.host` | string | Yes | The URL to the local model server. Should be `http://localhost:<port>`. |
| `classifier.model` | string | Yes | The model name to use for decisions. Must be `"gemma3-1b-gpu-custom"`. |
> **Note: You will need to restart after configuration changes for local model
> routing to take effect.**
+146
View File
@@ -245,6 +245,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hide helpful tips in the UI
- **Default:** `false`
- **`ui.escapePastedAtSymbols`** (boolean):
- **Description:** When enabled, @ symbols in pasted text are escaped to
prevent unintended @path expansion.
- **Default:** `false`
- **`ui.showShortcutsHint`** (boolean):
- **Description:** Show the "? for shortcuts" hint above the input.
- **Default:** `true`
@@ -672,6 +677,141 @@ their corresponding top-level category object in your `settings.json` file.
used.
- **Default:** `[]`
- **`modelConfigs.modelDefinitions`** (object):
- **Description:** Registry of model metadata, including tier, family, and
features.
- **Default:**
```json
{
"gemini-3.1-pro-preview": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3.1-pro-preview-customtools": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3-pro-preview": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3-flash-preview": {
"tier": "flash",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": true
}
},
"gemini-2.5-pro": {
"tier": "pro",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"gemini-2.5-flash": {
"tier": "flash",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"gemini-2.5-flash-lite": {
"tier": "flash-lite",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"auto": {
"tier": "auto",
"isPreview": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"pro": {
"tier": "pro",
"isPreview": false,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"flash": {
"tier": "flash",
"isPreview": false,
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"flash-lite": {
"tier": "flash-lite",
"isPreview": false,
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"auto-gemini-3": {
"displayName": "Auto (Gemini 3)",
"tier": "auto",
"isPreview": true,
"dialogLocation": "main",
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"auto-gemini-2.5": {
"displayName": "Auto (Gemini 2.5)",
"tier": "auto",
"isPreview": false,
"dialogLocation": "main",
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
"features": {
"thinking": false,
"multimodalToolUse": false
}
}
}
```
- **Requires restart:** Yes
#### `agents`
- **`agents.overrides`** (object):
@@ -1063,6 +1203,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.dynamicModelConfiguration`** (boolean):
- **Description:** Enable dynamic model configuration (definitions,
resolutions, and chains) via settings.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.enabled`** (boolean):
- **Description:** Enable the Gemma Model Router (experimental). Requires a
local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
+3 -1
View File
@@ -342,7 +342,9 @@ policies, as it is much more robust than manually writing Fully Qualified Names
**1. Targeting a specific tool on a server**
Combine `mcpName` and `toolName` to target a single operation.
Combine `mcpName` and `toolName` to target a single operation. When using
`mcpName`, the `toolName` field should strictly be the simple name of the tool
(e.g., `search`), **not** the Fully Qualified Name (e.g., `mcp_server_search`).
```toml
# Allows the `search` tool on the `my-jira-server` MCP
+8
View File
@@ -120,6 +120,14 @@ tools to detect if they are being run from within the Gemini CLI.
## Command restrictions
<!-- prettier-ignore -->
> [!WARNING]
> The `tools.core` setting is an **allowlist for _all_ built-in
> tools**, not just shell commands. When you set `tools.core` to any value,
> _only_ the tools explicitly listed will be enabled. This includes all built-in
> tools like `read_file`, `write_file`, `glob`, `grep_search`, `list_directory`,
> `replace`, etc.
You can restrict the commands that can be executed by the `run_shell_command`
tool by using the `tools.core` and `tools.exclude` settings in your
configuration file.
+9 -4
View File
@@ -82,11 +82,14 @@ const commonAliases = {
const cliConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
},
entryPoints: ['packages/cli/index.ts'],
outfile: 'bundle/gemini.js',
entryPoints: { gemini: 'packages/cli/index.ts' },
outdir: 'bundle',
splitting: true,
define: {
__filename: '__chunk_filename',
__dirname: '__chunk_dirname',
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
pkg.config?.sandboxImageUri,
@@ -103,11 +106,13 @@ const cliConfig = {
const a2aServerConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
},
entryPoints: ['packages/a2a-server/src/http/server.ts'],
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
define: {
__filename: '__chunk_filename',
__dirname: '__chunk_dirname',
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
},
plugins: createWasmPlugins(),
-5
View File
@@ -35,11 +35,6 @@ const commonRestrictedSyntaxRules = [
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
{
selector: 'CallExpression[callee.name="fetch"]',
message:
'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.',
},
];
export default tseslint.config(
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

@@ -26,7 +26,7 @@ describe('Task Event-Driven Scheduler', () => {
mockConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
}) as Config;
messageBus = mockConfig.getMessageBus();
messageBus = mockConfig.messageBus;
mockEventBus = {
publish: vi.fn(),
on: vi.fn(),
@@ -360,7 +360,7 @@ describe('Task Event-Driven Scheduler', () => {
isEventDrivenSchedulerEnabled: () => true,
getApprovalMode: () => ApprovalMode.YOLO,
}) as Config;
const yoloMessageBus = yoloConfig.getMessageBus();
const yoloMessageBus = yoloConfig.messageBus;
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
+14 -7
View File
@@ -5,6 +5,7 @@
*/
import {
type AgentLoopContext,
Scheduler,
type GeminiClient,
GeminiEventType,
@@ -114,7 +115,8 @@ export class Task {
this.scheduler = this.setupEventDrivenScheduler();
this.geminiClient = this.config.getGeminiClient();
const loopContext: AgentLoopContext = this.config;
this.geminiClient = loopContext.geminiClient;
this.pendingToolConfirmationDetails = new Map();
this.taskState = 'submitted';
this.eventBus = eventBus;
@@ -143,7 +145,8 @@ export class Task {
// process. This is not scoped to the individual task but reflects the global connection
// state managed within the @gemini-cli/core module.
async getMetadata(): Promise<TaskMetadata> {
const toolRegistry = this.config.getToolRegistry();
const loopContext: AgentLoopContext = this.config;
const toolRegistry = loopContext.toolRegistry;
const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
const serverStatuses = getAllMCPServerStatuses();
const servers = Object.keys(mcpServers).map((serverName) => ({
@@ -376,7 +379,8 @@ export class Task {
private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
private setupEventDrivenScheduler(): Scheduler {
const messageBus = this.config.getMessageBus();
const loopContext: AgentLoopContext = this.config;
const messageBus = loopContext.messageBus;
const scheduler = new Scheduler({
schedulerId: this.id,
context: this.config,
@@ -395,9 +399,11 @@ export class Task {
dispose(): void {
if (this.messageBusListener) {
this.config
.getMessageBus()
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener);
const loopContext: AgentLoopContext = this.config;
loopContext.messageBus.unsubscribe(
MessageBusType.TOOL_CALLS_UPDATE,
this.messageBusListener,
);
this.messageBusListener = undefined;
}
@@ -948,7 +954,8 @@ export class Task {
try {
if (correlationId) {
await this.config.getMessageBus().publish({
const loopContext: AgentLoopContext = this.config;
await loopContext.messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed:
@@ -59,6 +59,9 @@ describe('a2a-server memory commands', () => {
} as unknown as ToolRegistry;
mockConfig = {
get toolRegistry() {
return mockToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
} as unknown as Config;
@@ -168,7 +171,6 @@ describe('a2a-server memory commands', () => {
]);
expect(mockAddMemory).toHaveBeenCalledWith(fact);
expect(mockConfig.getToolRegistry).toHaveBeenCalled();
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory');
expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith(
{ fact },
+3 -1
View File
@@ -15,6 +15,7 @@ import type {
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { AgentLoopContext } from '@google/gemini-cli-core';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
@@ -95,7 +96,8 @@ export class AddMemoryCommand implements Command {
return { name: this.name, data: result.content };
}
const toolRegistry = context.config.getToolRegistry();
const loopContext: AgentLoopContext = context.config;
const toolRegistry = loopContext.toolRegistry;
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
+21 -5
View File
@@ -16,6 +16,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
type MessageBus,
PolicyDecision,
tmpdir,
type Config,
@@ -31,9 +32,27 @@ export function createMockConfig(
const tmpDir = tmpdir();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
get toolRegistry(): ToolRegistry {
get config() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (this as unknown as Config).getToolRegistry();
return this as unknown as Config;
},
get toolRegistry() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = this as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return config.getToolRegistry?.() as unknown as ToolRegistry;
},
get messageBus() {
return (
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(this as unknown as Config).getMessageBus?.() as unknown as MessageBus
);
},
get geminiClient() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = this as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return config.getGeminiClient?.() as unknown as GeminiClient;
},
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn(),
@@ -81,9 +100,6 @@ export function createMockConfig(
...overrides,
} as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).config =
mockConfig;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
'test-prompt-id';
+4
View File
@@ -3632,6 +3632,8 @@ describe('loadCliConfig acpMode and clientName', () => {
it('should set acpMode to true and detect clientName when --acp flag is used', async () => {
process.argv = ['node', 'script.js', '--acp'];
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
@@ -3645,6 +3647,8 @@ describe('loadCliConfig acpMode and clientName', () => {
it('should set acpMode to true but leave clientName undefined for generic terminals', async () => {
process.argv = ['node', 'script.js', '--acp'];
vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
+1
View File
@@ -857,6 +857,7 @@ export async function loadCliConfig(
disableLLMCorrection: settings.tools?.disableLLMCorrection,
rawOutput: argv.rawOutput,
acceptRawOutputRisk: argv.acceptRawOutputRisk,
dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: settings.hooksConfig.enabled,
+53
View File
@@ -540,6 +540,16 @@ const SETTINGS_SCHEMA = {
description: 'Hide helpful tips in the UI',
showInDialog: true,
},
escapePastedAtSymbols: {
type: 'boolean',
label: 'Escape Pasted @ Symbols',
category: 'UI',
requiresRestart: false,
default: false,
description:
'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.',
showInDialog: true,
},
showShortcutsHint: {
type: 'boolean',
label: 'Show Shortcuts Hint',
@@ -1029,6 +1039,20 @@ const SETTINGS_SCHEMA = {
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
showInDialog: false,
},
modelDefinitions: {
type: 'object',
label: 'Model Definitions',
category: 'Model',
requiresRestart: true,
default: DEFAULT_MODEL_CONFIGS.modelDefinitions,
description:
'Registry of model metadata, including tier, family, and features.',
showInDialog: false,
additionalProperties: {
type: 'object',
ref: 'ModelDefinition',
},
},
},
},
@@ -1900,6 +1924,16 @@ const SETTINGS_SCHEMA = {
'Enable web fetch behavior that bypasses LLM summarization.',
showInDialog: true,
},
dynamicModelConfiguration: {
type: 'boolean',
label: 'Dynamic Model Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
showInDialog: false,
},
gemmaModelRouter: {
type: 'object',
label: 'Gemma Model Router',
@@ -2716,6 +2750,25 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
ModelDefinition: {
type: 'object',
description: 'Model metadata registry entry.',
properties: {
displayName: { type: 'string' },
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
family: { type: 'string' },
isPreview: { type: 'boolean' },
dialogLocation: { enum: ['main', 'manual'] },
dialogDescription: { type: 'string' },
features: {
type: 'object',
properties: {
thinking: { type: 'boolean' },
multimodalToolUse: { type: 'boolean' },
},
},
},
},
};
export function getSettingsSchema(): SettingsSchemaType {
+38 -217
View File
@@ -4,13 +4,38 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink';
import { AppContainer } from './ui/AppContainer.js';
import {
type StartupWarning,
WarningPriority,
type Config,
type ResumedSessionData,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
sessionId,
logUserPrompt,
AuthType,
UserPromptEvent,
coreEvents,
CoreEvent,
getOauthClient,
patchStdio,
writeToStdout,
writeToStderr,
shouldEnterAlternateScreen,
startupProfiler,
ExitCodes,
SessionStartSource,
SessionEndReason,
ValidationCancelledError,
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
import * as cliConfig from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import { createHash } from 'node:crypto';
import v8 from 'node:v8';
import os from 'node:os';
@@ -37,47 +62,11 @@ import {
runExitCleanup,
registerTelemetryConfig,
setupSignalHandlers,
setupTtyCheck,
} from './utils/cleanup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
} from './utils/sessionCleanup.js';
import {
type StartupWarning,
WarningPriority,
type Config,
type ResumedSessionData,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
sessionId,
logUserPrompt,
AuthType,
getOauthClient,
UserPromptEvent,
debugLogger,
recordSlowRender,
coreEvents,
CoreEvent,
createWorkingStdio,
patchStdio,
writeToStdout,
writeToStderr,
disableMouseEvents,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
ExitCodes,
SessionStartSource,
SessionEndReason,
getVersion,
ValidationCancelledError,
ValidationRequiredError,
type AdminControlsSettings,
} from '@google/gemini-cli-core';
import {
initializeApp,
type InitializationResult,
@@ -85,21 +74,9 @@ import {
import { validateAuthMethod } from './config/auth.js';
import { runAcpClient } from './acp/acpClient.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { MouseProvider } from './ui/contexts/MouseContext.js';
import { StreamingState } from './ui/types.js';
import { computeTerminalTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import {
relaunchAppInChildProcess,
relaunchOnExitCode,
@@ -107,19 +84,13 @@ import {
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
import { cleanupBackgroundLogs } from './utils/logCleanup.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
const SLOW_RENDER_MS = 200;
export function validateDnsResolutionOrder(
order: string | undefined,
): DnsResolutionOrder {
@@ -198,147 +169,16 @@ export async function startInteractiveUI(
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
// Never enter Ink alternate buffer mode when screen reader mode is enabled
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
config.getScreenReader(),
// Dynamically import the heavy UI module so React/Ink are only parsed when needed
const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js');
await doStartUI(
config,
settings,
startupWarnings,
workspaceRoot,
resumedSessionData,
initializationResult,
);
const mouseEventsEnabled = useAlternateBuffer;
if (mouseEventsEnabled) {
enableMouseEvents();
registerCleanup(() => {
disableMouseEvents();
});
}
const { matchers, errors } = await loadKeyMatchers();
errors.forEach((error) => {
coreEvents.emitFeedback('warning', error);
});
const version = await getVersion();
setWindowTitle(basename(workspaceRoot), settings);
const consolePatcher = new ConsolePatcher({
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
useKittyKeyboardProtocol();
return (
<SettingsContext.Provider value={settings}>
<KeyMatchersProvider value={matchers}>
<KeypressProvider
config={config}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</KeyMatchersProvider>
</SettingsContext.Provider>
);
};
if (isShpool) {
// Wait a moment for shpool to stabilize terminal size and state.
// shpool is a persistence tool that restores terminal state by replaying it.
// This delay gives shpool time to finish its restoration replay and send
// the actual terminal size (often via an immediate SIGWINCH) before we
// render the first TUI frame. Without this, the first frame may be
// garbled or rendered at an incorrect size, which disabling incremental
// rendering alone cannot fix for the initial frame.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
<AppWrapper />
</React.StrictMode>
) : (
<AppWrapper />
),
{
stdout: inkStdout,
stderr: inkStderr,
stdin: process.stdin,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
}
profiler.reportFrameRendered();
},
patchConsole: false,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
!isShpool,
},
);
if (useAlternateBuffer) {
disableLineWrapping();
registerCleanup(() => {
enableLineWrapping();
});
}
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
debugLogger.warn('Update check failed:', err);
}
});
registerCleanup(() => instance.unmount());
registerCleanup(setupTtyCheck());
}
export async function main() {
@@ -845,25 +685,6 @@ export async function main() {
}
}
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.ui.hideWindowTitle) {
// Initial state before React loop starts
const windowTitle = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
isSilentWorking: false,
folderName: title,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
});
writeToStdout(`\x1b]0;${windowTitle}\x07`);
process.on('exit', () => {
writeToStdout(`\x1b]0;\x07`);
});
}
}
export function initializeOutputListenersAndFlush() {
// If there are no listeners for output, make sure we flush so output is not
// lost.
+214
View File
@@ -0,0 +1,214 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink';
import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import {
type StartupWarning,
type Config,
type ResumedSessionData,
coreEvents,
createWorkingStdio,
disableMouseEvents,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
recordSlowRender,
writeToStdout,
getVersion,
debugLogger,
} from '@google/gemini-cli-core';
import type { InitializationResult } from './core/initializer.js';
import type { LoadedSettings } from './config/settings.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { MouseProvider } from './ui/contexts/MouseContext.js';
import { StreamingState } from './ui/types.js';
import { computeTerminalTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { profiler } from './ui/components/DebugProfiler.js';
const SLOW_RENDER_MS = 200;
export async function startInteractiveUI(
config: Config,
settings: LoadedSettings,
startupWarnings: StartupWarning[],
workspaceRoot: string = process.cwd(),
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
// Never enter Ink alternate buffer mode when screen reader mode is enabled
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
if (mouseEventsEnabled) {
enableMouseEvents();
registerCleanup(() => {
disableMouseEvents();
});
}
const { matchers, errors } = await loadKeyMatchers();
errors.forEach((error) => {
coreEvents.emitFeedback('warning', error);
});
const version = await getVersion();
setWindowTitle(basename(workspaceRoot), settings);
const consolePatcher = new ConsolePatcher({
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
useKittyKeyboardProtocol();
return (
<SettingsContext.Provider value={settings}>
<KeyMatchersProvider value={matchers}>
<KeypressProvider
config={config}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</KeyMatchersProvider>
</SettingsContext.Provider>
);
};
if (isShpool) {
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
<AppWrapper />
</React.StrictMode>
) : (
<AppWrapper />
),
{
stdout: inkStdout,
stderr: inkStderr,
stdin: process.stdin,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
}
profiler.reportFrameRendered();
},
patchConsole: false,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
!isShpool,
},
);
if (useAlternateBuffer) {
disableLineWrapping();
registerCleanup(() => {
enableLineWrapping();
});
}
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
debugLogger.warn('Update check failed:', err);
}
});
registerCleanup(() => instance.unmount());
registerCleanup(setupTtyCheck());
}
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.ui.hideWindowTitle) {
// Initial state before React loop starts
const windowTitle = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
isSilentWorking: false,
folderName: title,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
});
writeToStdout(`\x1b]0;${windowTitle}\x07`);
process.on('exit', () => {
writeToStdout(`\x1b]0;\x07`);
});
}
}
+1 -1
View File
@@ -263,8 +263,8 @@ export async function runNonInteractive({
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
escapePastedAtSymbols: false,
});
if (error || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
@@ -123,7 +123,6 @@ async function downloadFiles({
downloads.push(
(async () => {
const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`;
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
+16 -3
View File
@@ -11,6 +11,7 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import {
type TextBuffer,
@@ -515,7 +516,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
stdout.write('\x1b]52;c;?\x07');
} else {
const textToInsert = await clipboardy.read();
buffer.insert(textToInsert, { paste: true });
const escapedText = settings.ui?.escapePastedAtSymbols
? escapeAtSymbols(textToInsert)
: textToInsert;
buffer.insert(escapedText, { paste: true });
if (isLargePaste(textToInsert)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -750,8 +755,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null;
}, 40);
}
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
if (settings.ui?.escapePastedAtSymbols) {
buffer.handleInput({
...key,
sequence: escapeAtSymbols(key.sequence || ''),
});
} else {
buffer.handleInput(key);
}
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -1291,6 +1303,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
forceShowShellSuggestions,
keyMatchers,
isHelpDismissKey,
settings,
],
);
@@ -13,9 +13,8 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import type { Config } from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import {
cleanMessage,
formatRelativeTime,
getSessionFiles,
} from '../../utils/sessionUtils.js';
@@ -150,124 +149,7 @@ const SessionBrowserEmpty = (): React.JSX.Element => (
</Box>
);
/**
* Sorts an array of sessions by the specified criteria.
* @param sessions - Array of sessions to sort
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
* @returns New sorted array of sessions
*/
const sortSessions = (
sessions: SessionInfo[],
sortBy: 'date' | 'messages' | 'name',
reverse: boolean,
): SessionInfo[] => {
const sorted = [...sessions].sort((a, b) => {
switch (sortBy) {
case 'date':
return (
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
);
case 'messages':
return b.messageCount - a.messageCount;
case 'name':
return a.displayName.localeCompare(b.displayName);
default:
return 0;
}
});
return reverse ? sorted.reverse() : sorted;
};
/**
* Finds all text matches for a search query within conversation messages.
* Creates TextMatch objects with context (10 chars before/after) and role information.
* @param messages - Array of messages to search through
* @param query - Search query string (case-insensitive)
* @returns Array of TextMatch objects containing match context and metadata
*/
const findTextMatches = (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
query: string,
): TextMatch[] => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
const matches: TextMatch[] = [];
for (const message of messages) {
const m = cleanMessage(message.content);
const lowerContent = m.toLowerCase();
let startIndex = 0;
while (true) {
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const contextStart = Math.max(0, matchIndex - 10);
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
const snippet = m.slice(contextStart, contextEnd);
const relativeMatchStart = matchIndex - contextStart;
const relativeMatchEnd = relativeMatchStart + query.length;
let before = snippet.slice(0, relativeMatchStart);
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
let after = snippet.slice(relativeMatchEnd);
if (contextStart > 0) before = '…' + before;
if (contextEnd < m.length) after = after + '…';
matches.push({ before, match, after, role: message.role });
startIndex = matchIndex + 1;
}
}
return matches;
};
/**
* Filters sessions based on a search query, checking titles, IDs, and full content.
* Also populates matchSnippets and matchCount for sessions with content matches.
* @param sessions - Array of sessions to filter
* @param query - Search query string (case-insensitive)
* @returns Filtered array of sessions that match the query
*/
const filterSessions = (
sessions: SessionInfo[],
query: string,
): SessionInfo[] => {
if (!query.trim()) {
return sessions.map((session) => ({
...session,
matchSnippets: undefined,
matchCount: undefined,
}));
}
const lowerQuery = query.toLowerCase();
return sessions.filter((session) => {
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
?.toLowerCase()
.includes(lowerQuery);
if (titleMatch || contentMatch) {
if (session.messages) {
session.matchSnippets = findTextMatches(session.messages, query);
session.matchCount = session.matchSnippets.length;
}
return true;
}
return false;
});
};
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* Search input display component.
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { sortSessions, findTextMatches, filterSessions } from './utils.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
describe('SessionBrowser utils', () => {
const createTestSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
id: 'test-id',
file: 'test-file',
fileName: 'test-file.json',
startTime: '2025-01-01T10:00:00Z',
lastUpdated: '2025-01-01T10:00:00Z',
messageCount: 1,
displayName: 'Test Session',
firstUserMessage: 'Hello',
isCurrentSession: false,
index: 0,
...overrides,
});
describe('sortSessions', () => {
it('sorts by date ascending/descending', () => {
const older = createTestSession({
id: '1',
lastUpdated: '2025-01-01T10:00:00Z',
});
const newer = createTestSession({
id: '2',
lastUpdated: '2025-01-02T10:00:00Z',
});
const desc = sortSessions([older, newer], 'date', false);
expect(desc[0].id).toBe('2');
const asc = sortSessions([older, newer], 'date', true);
expect(asc[0].id).toBe('1');
});
it('sorts by message count ascending/descending', () => {
const more = createTestSession({ id: '1', messageCount: 10 });
const less = createTestSession({ id: '2', messageCount: 2 });
const desc = sortSessions([more, less], 'messages', false);
expect(desc[0].id).toBe('1');
const asc = sortSessions([more, less], 'messages', true);
expect(asc[0].id).toBe('2');
});
it('sorts by name ascending/descending', () => {
const apple = createTestSession({ id: '1', displayName: 'Apple' });
const banana = createTestSession({ id: '2', displayName: 'Banana' });
const asc = sortSessions([apple, banana], 'name', true);
expect(asc[0].id).toBe('2'); // Reversed alpha
const desc = sortSessions([apple, banana], 'name', false);
expect(desc[0].id).toBe('1');
});
});
describe('findTextMatches', () => {
it('returns empty array if query is practically empty', () => {
expect(
findTextMatches([{ role: 'user', content: 'hello world' }], ' '),
).toEqual([]);
});
it('finds simple matches with surrounding context', () => {
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: 'What is the capital of France?' },
];
const matches = findTextMatches(messages, 'capital');
expect(matches.length).toBe(1);
expect(matches[0].match).toBe('capital');
expect(matches[0].before.endsWith('the ')).toBe(true);
expect(matches[0].after.startsWith(' of')).toBe(true);
expect(matches[0].role).toBe('user');
});
it('finds multiple matches in a single message', () => {
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: 'test here test there' },
];
const matches = findTextMatches(messages, 'test');
expect(matches.length).toBe(2);
});
});
describe('filterSessions', () => {
it('returns all sessions when query is blank and clears existing snippets', () => {
const sessions = [createTestSession({ id: '1', matchCount: 5 })];
const result = filterSessions(sessions, ' ');
expect(result.length).toBe(1);
expect(result[0].matchCount).toBeUndefined();
});
it('filters by displayName', () => {
const session1 = createTestSession({
id: '1',
displayName: 'Cats and Dogs',
});
const session2 = createTestSession({ id: '2', displayName: 'Fish' });
const result = filterSessions([session1, session2], 'cat');
expect(result.length).toBe(1);
expect(result[0].id).toBe('1');
});
it('populates match snippets if it matches content inside messages array', () => {
const sessionWithMessages = createTestSession({
id: '1',
displayName: 'Unrelated Title',
fullContent: 'This mentions a giraffe',
messages: [{ role: 'user', content: 'This mentions a giraffe' }],
});
const result = filterSessions([sessionWithMessages], 'giraffe');
expect(result.length).toBe(1);
expect(result[0].matchCount).toBe(1);
expect(result[0].matchSnippets?.[0].match).toBe('giraffe');
});
});
});
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
cleanMessage,
type SessionInfo,
type TextMatch,
} from '../../../utils/sessionUtils.js';
/**
* Sorts an array of sessions by the specified criteria.
* @param sessions - Array of sessions to sort
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
* @returns New sorted array of sessions
*/
export const sortSessions = (
sessions: SessionInfo[],
sortBy: 'date' | 'messages' | 'name',
reverse: boolean,
): SessionInfo[] => {
const sorted = [...sessions].sort((a, b) => {
switch (sortBy) {
case 'date':
return (
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
);
case 'messages':
return b.messageCount - a.messageCount;
case 'name':
return a.displayName.localeCompare(b.displayName);
default:
return 0;
}
});
return reverse ? sorted.reverse() : sorted;
};
/**
* Finds all text matches for a search query within conversation messages.
* Creates TextMatch objects with context (10 chars before/after) and role information.
* @param messages - Array of messages to search through
* @param query - Search query string (case-insensitive)
* @returns Array of TextMatch objects containing match context and metadata
*/
export const findTextMatches = (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
query: string,
): TextMatch[] => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
const matches: TextMatch[] = [];
for (const message of messages) {
const m = cleanMessage(message.content);
const lowerContent = m.toLowerCase();
let startIndex = 0;
while (true) {
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const contextStart = Math.max(0, matchIndex - 10);
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
const snippet = m.slice(contextStart, contextEnd);
const relativeMatchStart = matchIndex - contextStart;
const relativeMatchEnd = relativeMatchStart + query.length;
let before = snippet.slice(0, relativeMatchStart);
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
let after = snippet.slice(relativeMatchEnd);
if (contextStart > 0) before = '…' + before;
if (contextEnd < m.length) after = after + '…';
matches.push({ before, match, after, role: message.role });
startIndex = matchIndex + 1;
}
}
return matches;
};
/**
* Filters sessions based on a search query, checking titles, IDs, and full content.
* Also populates matchSnippets and matchCount for sessions with content matches.
* @param sessions - Array of sessions to filter
* @param query - Search query string (case-insensitive)
* @returns Filtered array of sessions that match the query
*/
export const filterSessions = (
sessions: SessionInfo[],
query: string,
): SessionInfo[] => {
if (!query.trim()) {
return sessions.map((session) => ({
...session,
matchSnippets: undefined,
matchCount: undefined,
}));
}
const lowerQuery = query.toLowerCase();
return sessions.filter((session) => {
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
?.toLowerCase()
.includes(lowerQuery);
if (titleMatch || contentMatch) {
if (session.messages) {
session.matchSnippets = findTextMatches(session.messages, query);
session.matchCount = session.matchSnippets.length;
}
return true;
}
return false;
});
};
@@ -27,6 +27,7 @@ import {
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
type Config,
type RetrieveUserQuotaResponse,
isActiveModel,
getDisplayString,
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
// Logic for building the unified list of table rows
const buildModelRows = (
models: Record<string, ModelMetrics>,
config: Config,
quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useCustomToolModel = false,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set(
Object.keys(models).map(getBaseModelName).map(getDisplayString),
Object.keys(models)
.map(getBaseModelName)
.map((name) => getDisplayString(name, config)),
);
// 1. Models with active usage
@@ -104,7 +108,7 @@ const buildModelRows = (
const inputTokens = metrics.tokens.input;
return {
key: name,
modelName: getDisplayString(modelName),
modelName: getDisplayString(modelName, config),
requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(),
inputTokens: inputTokens.toLocaleString(),
@@ -121,11 +125,11 @@ const buildModelRows = (
(b) =>
b.modelId &&
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(getDisplayString(b.modelId)),
!usedModelNames.has(getDisplayString(b.modelId, config)),
)
.map((bucket) => ({
key: bucket.modelId!,
modelName: getDisplayString(bucket.modelId!),
modelName: getDisplayString(bucket.modelId!, config),
requests: '-',
cachedTokens: '-',
inputTokens: '-',
@@ -139,6 +143,7 @@ const buildModelRows = (
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
config: Config;
quotas?: RetrieveUserQuotaResponse;
cacheEfficiency: number;
totalCachedTokens: number;
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
useCustomToolModel?: boolean;
}> = ({
models,
config,
quotas,
cacheEfficiency,
totalCachedTokens,
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
}) => {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
const rows = buildModelRows(
models,
config,
quotas,
useGemini3_1,
useCustomToolModel,
);
if (rows.length === 0) {
return null;
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
</Section>
<ModelUsageTable
models={models}
config={config}
quotas={quotas}
cacheEfficiency={computed.cacheEfficiency}
totalCachedTokens={computed.totalCachedTokens}
@@ -13,7 +13,11 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { handleAtCommand } from './atCommandProcessor.js';
import {
handleAtCommand,
escapeAtSymbols,
unescapeLiteralAt,
} from './atCommandProcessor.js';
import {
FileDiscoveryService,
GlobTool,
@@ -1481,3 +1485,56 @@ describe('handleAtCommand', () => {
);
});
});
describe('escapeAtSymbols', () => {
it('escapes a bare @ symbol', () => {
expect(escapeAtSymbols('test@domain.com')).toBe('test\\@domain.com');
});
it('escapes a leading @ symbol', () => {
expect(escapeAtSymbols('@scope/pkg')).toBe('\\@scope/pkg');
});
it('escapes multiple @ symbols', () => {
expect(escapeAtSymbols('a@b and c@d')).toBe('a\\@b and c\\@d');
});
it('does not double-escape an already escaped @', () => {
expect(escapeAtSymbols('test\\@domain.com')).toBe('test\\@domain.com');
});
it('returns text with no @ unchanged', () => {
expect(escapeAtSymbols('hello world')).toBe('hello world');
});
it('returns empty string unchanged', () => {
expect(escapeAtSymbols('')).toBe('');
});
});
describe('unescapeLiteralAt', () => {
it('unescapes \\@ to @', () => {
expect(unescapeLiteralAt('test\\@domain.com')).toBe('test@domain.com');
});
it('unescapes a leading \\@', () => {
expect(unescapeLiteralAt('\\@scope/pkg')).toBe('@scope/pkg');
});
it('unescapes multiple \\@ sequences', () => {
expect(unescapeLiteralAt('a\\@b and c\\@d')).toBe('a@b and c@d');
});
it('returns text with no \\@ unchanged', () => {
expect(unescapeLiteralAt('hello world')).toBe('hello world');
});
it('returns empty string unchanged', () => {
expect(unescapeLiteralAt('')).toBe('');
});
it('roundtrips correctly with escapeAtSymbols', () => {
const input = 'user@example.com and @scope/pkg';
expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input);
});
});
@@ -30,6 +30,26 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
/**
* Escapes unescaped @ symbols so they are not interpreted as @path commands.
*/
export function escapeAtSymbols(text: string): string {
return text.replace(/(?<!\\)@/g, '\\@');
}
/**
* Unescapes \@ back to @ correctly, preserving \\@ sequences.
*/
export function unescapeLiteralAt(text: string): string {
return text.replace(/\\@/g, (match, offset, full) => {
let backslashCount = 0;
for (let i = offset - 1; i >= 0 && full[i] === '\\'; i--) {
backslashCount++;
}
return backslashCount % 2 === 0 ? '@' : '\\@';
});
}
/**
* Regex source for the path/command part of an @ reference.
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
@@ -49,6 +69,7 @@ interface HandleAtCommandParams {
onDebugMessage: (message: string) => void;
messageId: number;
signal: AbortSignal;
escapePastedAtSymbols?: boolean;
}
interface HandleAtCommandResult {
@@ -65,7 +86,10 @@ interface AtCommandPart {
* Parses a query string to find all '@<path>' commands and text segments.
* Handles \ escaped spaces within paths.
*/
function parseAllAtCommands(query: string): AtCommandPart[] {
function parseAllAtCommands(
query: string,
escapePastedAtSymbols = false,
): AtCommandPart[] {
const parts: AtCommandPart[] = [];
let lastIndex = 0;
@@ -85,7 +109,9 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
if (matchIndex > lastIndex) {
parts.push({
type: 'text',
content: query.substring(lastIndex, matchIndex),
content: escapePastedAtSymbols
? unescapeLiteralAt(query.substring(lastIndex, matchIndex))
: query.substring(lastIndex, matchIndex),
});
}
@@ -98,7 +124,12 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
// Add remaining text
if (lastIndex < query.length) {
parts.push({ type: 'text', content: query.substring(lastIndex) });
parts.push({
type: 'text',
content: escapePastedAtSymbols
? unescapeLiteralAt(query.substring(lastIndex))
: query.substring(lastIndex),
});
}
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
@@ -635,8 +666,9 @@ export async function handleAtCommand({
onDebugMessage,
messageId: userMessageTimestamp,
signal,
escapePastedAtSymbols = false,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const commandParts = parseAllAtCommands(query);
const commandParts = parseAllAtCommands(query, escapePastedAtSymbols);
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
commandParts,
+2 -1
View File
@@ -837,8 +837,8 @@ export const useGeminiStream = (
onDebugMessage,
messageId: userMessageTimestamp,
signal: abortSignal,
escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
});
if (atCommandResult.error) {
onDebugMessage(atCommandResult.error);
return { queryToSend: null, shouldProceed: false };
@@ -874,6 +874,7 @@ export const useGeminiStream = (
logger,
shellModeActive,
scheduleToolCalls,
settings,
],
);
+1 -1
View File
@@ -25,7 +25,7 @@ export type HighlightToken = {
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
const HIGHLIGHT_REGEX = new RegExp(
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
`(^/[a-zA-Z0-9_-]+|(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
'g',
);
-1
View File
@@ -61,7 +61,6 @@ export const getLatestGitHubRelease = async (
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(endpoint, {
method: 'GET',
headers: {
@@ -5,11 +5,8 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
A2AClientManager,
type SendMessageResult,
} from './a2a-client-manager.js';
import type { AgentCard, Task } from '@a2a-js/sdk';
import { A2AClientManager } from './a2a-client-manager.js';
import type { AgentCard } from '@a2a-js/sdk';
import {
ClientFactory,
DefaultAgentCardResolver,
@@ -18,83 +15,99 @@ import {
type AuthenticationHandler,
type Client,
} from '@a2a-js/sdk/client';
import type { Config } from '../config/config.js';
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { debugLogger } from '../utils/debugLogger.js';
interface MockClient {
sendMessageStream: ReturnType<typeof vi.fn>;
getTask: ReturnType<typeof vi.fn>;
cancelTask: ReturnType<typeof vi.fn>;
}
vi.mock('@a2a-js/sdk/client', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as Record<string, unknown>),
createAuthenticatingFetchWithRetry: vi.fn(),
ClientFactory: vi.fn(),
DefaultAgentCardResolver: vi.fn(),
ClientFactoryOptions: {
createFrom: vi.fn(),
default: {},
},
};
});
vi.mock('../utils/debugLogger.js', () => ({
debugLogger: {
debug: vi.fn(),
},
}));
vi.mock('@a2a-js/sdk/client', () => {
const ClientFactory = vi.fn();
const DefaultAgentCardResolver = vi.fn();
const RestTransportFactory = vi.fn();
const JsonRpcTransportFactory = vi.fn();
const ClientFactoryOptions = {
default: {},
createFrom: vi.fn(),
};
const createAuthenticatingFetchWithRetry = vi.fn();
DefaultAgentCardResolver.prototype.resolve = vi.fn();
ClientFactory.prototype.createFromUrl = vi.fn();
return {
ClientFactory,
ClientFactoryOptions,
DefaultAgentCardResolver,
RestTransportFactory,
JsonRpcTransportFactory,
createAuthenticatingFetchWithRetry,
};
});
describe('A2AClientManager', () => {
let manager: A2AClientManager;
const mockAgentCard: AgentCard = {
name: 'test-agent',
description: 'A test agent',
url: 'http://test.agent',
version: '1.0.0',
protocolVersion: '0.1.0',
capabilities: {},
skills: [],
defaultInputModes: [],
defaultOutputModes: [],
};
const mockClient: MockClient = {
sendMessageStream: vi.fn(),
getTask: vi.fn(),
cancelTask: vi.fn(),
};
// Stable mocks initialized once
const sendMessageStreamMock = vi.fn();
const getTaskMock = vi.fn();
const cancelTaskMock = vi.fn();
const getAgentCardMock = vi.fn();
const authFetchMock = vi.fn();
const mockClient = {
sendMessageStream: sendMessageStreamMock,
getTask: getTaskMock,
cancelTask: cancelTaskMock,
getAgentCard: getAgentCardMock,
} as unknown as Client;
const mockAgentCard: Partial<AgentCard> = { name: 'TestAgent' };
beforeEach(() => {
vi.clearAllMocks();
A2AClientManager.resetInstanceForTesting();
manager = A2AClientManager.getInstance();
// Default mock implementations
getAgentCardMock.mockResolvedValue({
// Re-create the instances as plain objects that can be spied on
const factoryInstance = {
createFromUrl: vi.fn(),
createFromAgentCard: vi.fn(),
};
const resolverInstance = {
resolve: vi.fn(),
};
vi.mocked(ClientFactory).mockReturnValue(
factoryInstance as unknown as ClientFactory,
);
vi.mocked(DefaultAgentCardResolver).mockReturnValue(
resolverInstance as unknown as DefaultAgentCardResolver,
);
vi.spyOn(factoryInstance, 'createFromUrl').mockResolvedValue(
mockClient as unknown as Client,
);
vi.spyOn(factoryInstance, 'createFromAgentCard').mockResolvedValue(
mockClient as unknown as Client,
);
vi.spyOn(resolverInstance, 'resolve').mockResolvedValue({
...mockAgentCard,
url: 'http://test.agent/real/endpoint',
} as AgentCard);
vi.mocked(ClientFactory.prototype.createFromUrl).mockResolvedValue(
mockClient,
vi.spyOn(ClientFactoryOptions, 'createFrom').mockImplementation(
(_defaults, overrides) => overrides as unknown as ClientFactoryOptions,
);
vi.mocked(DefaultAgentCardResolver.prototype.resolve).mockResolvedValue({
...mockAgentCard,
url: 'http://test.agent/real/endpoint',
} as AgentCard);
vi.mocked(ClientFactoryOptions.createFrom).mockImplementation(
(_defaults, overrides) => overrides as ClientFactoryOptions,
);
vi.mocked(createAuthenticatingFetchWithRetry).mockReturnValue(
authFetchMock,
vi.mocked(createAuthenticatingFetchWithRetry).mockImplementation(() =>
authFetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
} as Response),
);
vi.stubGlobal(
@@ -117,21 +130,70 @@ describe('A2AClientManager', () => {
expect(instance1).toBe(instance2);
});
describe('getInstance / dispatcher initialization', () => {
it('should use UndiciAgent when no proxy is configured', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
await cardFetch('http://test.agent/card');
const fetchCall = vi
.mocked(fetch)
.mock.calls.find((call) => call[0] === 'http://test.agent/card');
expect(fetchCall).toBeDefined();
expect(
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
).toBeInstanceOf(UndiciAgent);
expect(
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
).not.toBeInstanceOf(ProxyAgent);
});
it('should use ProxyAgent when a proxy is configured via Config', async () => {
A2AClientManager.resetInstanceForTesting();
const mockConfig = {
getProxy: () => 'http://my-proxy:8080',
} as Config;
manager = A2AClientManager.getInstance(mockConfig);
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
await cardFetch('http://test.proxy.agent/card');
const fetchCall = vi
.mocked(fetch)
.mock.calls.find((call) => call[0] === 'http://test.proxy.agent/card');
expect(fetchCall).toBeDefined();
expect(
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
).toBeInstanceOf(ProxyAgent);
});
});
describe('loadAgent', () => {
it('should create and cache an A2AClient', async () => {
const agentCard = await manager.loadAgent(
'TestAgent',
'http://test.agent/card',
);
expect(agentCard).toMatchObject(mockAgentCard);
expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
expect(manager.getClient('TestAgent')).toBeDefined();
});
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
});
it('should throw an error if an agent with the same name is already loaded', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await expect(
manager.loadAgent('TestAgent', 'http://another.agent/card'),
manager.loadAgent('TestAgent', 'http://test.agent/card'),
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
});
@@ -146,20 +208,12 @@ describe('A2AClientManager', () => {
shouldRetryWithHeaders: vi.fn(),
};
await manager.loadAgent(
'CustomAuthAgent',
'http://custom.agent/card',
'TestAgent',
'http://test.agent/card',
customAuthHandler as unknown as AuthenticationHandler,
);
expect(createAuthenticatingFetchWithRetry).toHaveBeenCalledWith(
expect.anything(),
customAuthHandler,
);
// Card resolver should NOT use the authenticated fetch by default.
const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock
.instances[0];
expect(resolverInstance).toBeDefined();
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
expect(resolverOptions?.fetchImpl).not.toBe(authFetchMock);
@@ -220,106 +274,163 @@ describe('A2AClientManager', () => {
it('should log a debug message upon loading an agent', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
expect(debugLogger.debug).toHaveBeenCalledWith(
"[A2AClientManager] Loaded agent 'TestAgent' from http://test.agent/card",
expect.stringContaining("Loaded agent 'TestAgent'"),
);
});
it('should clear the cache', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
expect(manager.getAgentCard('TestAgent')).toBeDefined();
expect(manager.getClient('TestAgent')).toBeDefined();
manager.clearCache();
expect(manager.getAgentCard('TestAgent')).toBeUndefined();
expect(manager.getClient('TestAgent')).toBeUndefined();
expect(debugLogger.debug).toHaveBeenCalledWith(
'[A2AClientManager] Cache cleared.',
});
it('should throw if resolveAgentCard fails', async () => {
const resolverInstance = {
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
};
vi.mocked(DefaultAgentCardResolver).mockReturnValue(
resolverInstance as unknown as DefaultAgentCardResolver,
);
await expect(
manager.loadAgent('FailAgent', 'http://fail.agent'),
).rejects.toThrow('Resolution failed');
});
it('should throw if factory.createFromAgentCard fails', async () => {
const factoryInstance = {
createFromAgentCard: vi
.fn()
.mockRejectedValue(new Error('Factory failed')),
};
vi.mocked(ClientFactory).mockReturnValue(
factoryInstance as unknown as ClientFactory,
);
await expect(
manager.loadAgent('FailAgent', 'http://fail.agent'),
).rejects.toThrow('Factory failed');
});
});
describe('getAgentCard and getClient', () => {
it('should return undefined if agent is not found', () => {
expect(manager.getAgentCard('Unknown')).toBeUndefined();
expect(manager.getClient('Unknown')).toBeUndefined();
});
});
describe('sendMessageStream', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', 'http://test.agent');
await manager.loadAgent('TestAgent', 'http://test.agent/card');
});
it('should send a message and return a stream', async () => {
const mockResult = {
kind: 'message',
messageId: 'a',
parts: [],
role: 'agent',
} as SendMessageResult;
sendMessageStreamMock.mockReturnValue(
mockClient.sendMessageStream.mockReturnValue(
(async function* () {
yield mockResult;
yield { kind: 'message' };
})(),
);
const stream = manager.sendMessageStream('TestAgent', 'Hello');
const results = [];
for await (const res of stream) {
results.push(res);
for await (const result of stream) {
results.push(result);
}
expect(results).toEqual([mockResult]);
expect(sendMessageStreamMock).toHaveBeenCalledWith(
expect(results).toHaveLength(1);
expect(mockClient.sendMessageStream).toHaveBeenCalled();
});
it('should use contextId and taskId when provided', async () => {
mockClient.sendMessageStream.mockReturnValue(
(async function* () {
yield { kind: 'message' };
})(),
);
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
contextId: 'ctx123',
taskId: 'task456',
});
// trigger execution
for await (const _ of stream) {
break;
}
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.anything(),
message: expect.objectContaining({
contextId: 'ctx123',
taskId: 'task456',
}),
}),
expect.any(Object),
);
});
it('should use contextId and taskId when provided', async () => {
sendMessageStreamMock.mockReturnValue(
it('should correctly propagate AbortSignal to the stream', async () => {
mockClient.sendMessageStream.mockReturnValue(
(async function* () {
yield {
kind: 'message',
messageId: 'a',
parts: [],
role: 'agent',
} as SendMessageResult;
yield { kind: 'message' };
})(),
);
const expectedContextId = 'user-context-id';
const expectedTaskId = 'user-task-id';
const controller = new AbortController();
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
contextId: expectedContextId,
taskId: expectedTaskId,
signal: controller.signal,
});
// trigger execution
for await (const _ of stream) {
// consume stream
break;
}
const call = sendMessageStreamMock.mock.calls[0][0];
expect(call.message.contextId).toBe(expectedContextId);
expect(call.message.taskId).toBe(expectedTaskId);
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ signal: controller.signal }),
);
});
it('should propagate the original error on failure', async () => {
sendMessageStreamMock.mockImplementationOnce(() => {
throw new Error('Network error');
it('should handle a multi-chunk stream with different event types', async () => {
mockClient.sendMessageStream.mockReturnValue(
(async function* () {
yield { kind: 'message', messageId: 'm1' };
yield { kind: 'status-update', taskId: 't1' };
})(),
);
const stream = manager.sendMessageStream('TestAgent', 'Hello');
const results = [];
for await (const result of stream) {
results.push(result);
}
expect(results).toHaveLength(2);
expect(results[0].kind).toBe('message');
expect(results[1].kind).toBe('status-update');
});
it('should throw prefixed error on failure', async () => {
mockClient.sendMessageStream.mockImplementation(() => {
throw new Error('Network failure');
});
const stream = manager.sendMessageStream('TestAgent', 'Hello');
await expect(async () => {
for await (const _ of stream) {
// consume
// empty
}
}).rejects.toThrow('Network error');
}).rejects.toThrow(
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network failure',
);
});
it('should throw an error if the agent is not found', async () => {
const stream = manager.sendMessageStream('NonExistentAgent', 'Hello');
await expect(async () => {
for await (const _ of stream) {
// consume
// empty
}
}).rejects.toThrow("Agent 'NonExistentAgent' not found.");
});
@@ -327,28 +438,23 @@ describe('A2AClientManager', () => {
describe('getTask', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', 'http://test.agent');
await manager.loadAgent('TestAgent', 'http://test.agent/card');
});
it('should get a task from the correct agent', async () => {
getTaskMock.mockResolvedValue({
id: 'task123',
contextId: 'a',
kind: 'task',
status: { state: 'completed' },
} as Task);
const mockTask = { id: 'task123', kind: 'task' };
mockClient.getTask.mockResolvedValue(mockTask);
await manager.getTask('TestAgent', 'task123');
expect(getTaskMock).toHaveBeenCalledWith({
id: 'task123',
});
const result = await manager.getTask('TestAgent', 'task123');
expect(result).toBe(mockTask);
expect(mockClient.getTask).toHaveBeenCalledWith({ id: 'task123' });
});
it('should throw prefixed error on failure', async () => {
getTaskMock.mockRejectedValueOnce(new Error('Network error'));
mockClient.getTask.mockRejectedValue(new Error('Not found'));
await expect(manager.getTask('TestAgent', 'task123')).rejects.toThrow(
'A2AClient getTask Error [TestAgent]: Network error',
'A2AClient getTask Error [TestAgent]: Not found',
);
});
@@ -361,28 +467,23 @@ describe('A2AClientManager', () => {
describe('cancelTask', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', 'http://test.agent');
await manager.loadAgent('TestAgent', 'http://test.agent/card');
});
it('should cancel a task on the correct agent', async () => {
cancelTaskMock.mockResolvedValue({
id: 'task123',
contextId: 'a',
kind: 'task',
status: { state: 'canceled' },
} as Task);
const mockTask = { id: 'task123', kind: 'task' };
mockClient.cancelTask.mockResolvedValue(mockTask);
await manager.cancelTask('TestAgent', 'task123');
expect(cancelTaskMock).toHaveBeenCalledWith({
id: 'task123',
});
const result = await manager.cancelTask('TestAgent', 'task123');
expect(result).toBe(mockTask);
expect(mockClient.cancelTask).toHaveBeenCalledWith({ id: 'task123' });
});
it('should throw prefixed error on failure', async () => {
cancelTaskMock.mockRejectedValueOnce(new Error('Network error'));
mockClient.cancelTask.mockRejectedValue(new Error('Cannot cancel'));
await expect(manager.cancelTask('TestAgent', 'task123')).rejects.toThrow(
'A2AClient cancelTask Error [TestAgent]: Network error',
'A2AClient cancelTask Error [TestAgent]: Cannot cancel',
);
});
+80 -43
View File
@@ -12,45 +12,41 @@ import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
} from '@a2a-js/sdk';
import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client';
import {
type Client,
ClientFactory,
ClientFactoryOptions,
DefaultAgentCardResolver,
RestTransportFactory,
JsonRpcTransportFactory,
type AuthenticationHandler,
RestTransportFactory,
createAuthenticatingFetchWithRetry,
} from '@a2a-js/sdk/client';
import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc';
import * as grpc from '@grpc/grpc-js';
import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent } from 'undici';
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { normalizeAgentCard } from './a2aUtils.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
import { safeLookup } from '../utils/fetch.js';
import { classifyAgentError } from './a2a-errors.js';
// Remote agents can take 10+ minutes (e.g. Deep Research).
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
const A2A_TIMEOUT = 1800000; // 30 minutes
const a2aDispatcher = new UndiciAgent({
headersTimeout: A2A_TIMEOUT,
bodyTimeout: A2A_TIMEOUT,
connect: {
lookup: safeLookup, // SSRF protection at connection level
},
});
const a2aFetch: typeof fetch = (input, init) =>
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
fetch(input, { ...init, dispatcher: a2aDispatcher } as RequestInit);
/**
* Result of sending a message, which can be a full message, a task,
* or an incremental status/artifact update.
*/
export type SendMessageResult =
| Message
| Task
| TaskStatusUpdateEvent
| TaskArtifactUpdateEvent;
// Remote agents can take 10+ minutes (e.g. Deep Research).
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
const A2A_TIMEOUT = 1800000; // 30 minutes
/**
* Manages A2A clients and caches loaded agent information.
* Follows a singleton pattern to ensure a single client instance.
* Orchestrates communication with remote A2A agents.
* Manages protocol negotiation, authentication, and transport selection.
*/
export class A2AClientManager {
private static instance: A2AClientManager;
@@ -59,14 +55,35 @@ export class A2AClientManager {
private clients = new Map<string, Client>();
private agentCards = new Map<string, AgentCard>();
private constructor() {}
private a2aDispatcher: UndiciAgent | ProxyAgent;
private a2aFetch: typeof fetch;
private constructor(config?: Config) {
const proxyUrl = config?.getProxy();
const agentOptions = {
headersTimeout: A2A_TIMEOUT,
bodyTimeout: A2A_TIMEOUT,
};
if (proxyUrl) {
this.a2aDispatcher = new ProxyAgent({
uri: proxyUrl,
...agentOptions,
});
} else {
this.a2aDispatcher = new UndiciAgent(agentOptions);
}
this.a2aFetch = (input, init) =>
fetch(input, { ...init, dispatcher: this.a2aDispatcher } as RequestInit);
}
/**
* Gets the singleton instance of the A2AClientManager.
*/
static getInstance(): A2AClientManager {
static getInstance(config?: Config): A2AClientManager {
if (!A2AClientManager.instance) {
A2AClientManager.instance = new A2AClientManager();
A2AClientManager.instance = new A2AClientManager(config);
}
return A2AClientManager.instance;
}
@@ -97,9 +114,12 @@ export class A2AClientManager {
}
// Authenticated fetch for API calls (transports).
let authFetch: typeof fetch = a2aFetch;
let authFetch: typeof fetch = this.a2aFetch;
if (authHandler) {
authFetch = createAuthenticatingFetchWithRetry(a2aFetch, authHandler);
authFetch = createAuthenticatingFetchWithRetry(
this.a2aFetch,
authHandler,
);
}
// Use unauthenticated fetch for the agent card unless explicitly required.
@@ -109,7 +129,7 @@ export class A2AClientManager {
init?: RequestInit,
): Promise<Response> => {
// Try without auth first
const response = await a2aFetch(input, init);
const response = await this.a2aFetch(input, init);
// Retry with auth if we hit a 401/403
if ((response.status === 401 || response.status === 403) && authFetch) {
@@ -120,22 +140,35 @@ export class A2AClientManager {
};
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch });
const rawCard = await resolver.resolve(agentCardUrl, '');
// TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
// proto field name aliases (supportedInterfaces → additionalInterfaces,
// protocolBinding → transport).
const agentCard = normalizeAgentCard(rawCard);
const options = ClientFactoryOptions.createFrom(
const grpcUrl =
agentCard.additionalInterfaces?.find((i) => i.transport === 'GRPC')
?.url ?? agentCard.url;
const clientOptions = ClientFactoryOptions.createFrom(
ClientFactoryOptions.default,
{
transports: [
new RestTransportFactory({ fetchImpl: authFetch }),
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
new GrpcTransportFactory({
grpcChannelCredentials: grpcUrl.startsWith('https://')
? grpc.credentials.createSsl()
: grpc.credentials.createInsecure(),
}),
],
cardResolver: resolver,
},
);
try {
const factory = new ClientFactory(options);
const client = await factory.createFromUrl(agentCardUrl, '');
const agentCard = await client.getAgentCard();
const factory = new ClientFactory(clientOptions);
const client = await factory.createFromAgentCard(agentCard);
this.clients.set(name, client);
this.agentCards.set(name, agentCard);
@@ -173,9 +206,7 @@ export class A2AClientManager {
options?: { contextId?: string; taskId?: string; signal?: AbortSignal },
): AsyncIterable<SendMessageResult> {
const client = this.clients.get(agentName);
if (!client) {
throw new Error(`Agent '${agentName}' not found.`);
}
if (!client) throw new Error(`Agent '${agentName}' not found.`);
const messageParams: MessageSendParams = {
message: {
@@ -188,9 +219,19 @@ export class A2AClientManager {
},
};
yield* client.sendMessageStream(messageParams, {
signal: options?.signal,
});
try {
yield* client.sendMessageStream(messageParams, {
signal: options?.signal,
});
} catch (error: unknown) {
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
if (error instanceof Error) {
throw new Error(`${prefix}: ${error.message}`, { cause: error });
}
throw new Error(
`${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
);
}
}
/**
@@ -219,9 +260,7 @@ export class A2AClientManager {
*/
async getTask(agentName: string, taskId: string): Promise<Task> {
const client = this.clients.get(agentName);
if (!client) {
throw new Error(`Agent '${agentName}' not found.`);
}
if (!client) throw new Error(`Agent '${agentName}' not found.`);
try {
return await client.getTask({ id: taskId });
} catch (error: unknown) {
@@ -241,9 +280,7 @@ export class A2AClientManager {
*/
async cancelTask(agentName: string, taskId: string): Promise<Task> {
const client = this.clients.get(agentName);
if (!client) {
throw new Error(`Agent '${agentName}' not found.`);
}
if (!client) throw new Error(`Agent '${agentName}' not found.`);
try {
return await client.cancelTask({ id: taskId });
} catch (error: unknown) {
+20 -171
View File
@@ -12,9 +12,6 @@ import {
A2AResultReassembler,
AUTH_REQUIRED_MSG,
normalizeAgentCard,
getGrpcCredentials,
pinUrlToIp,
splitAgentCardUrl,
} from './a2aUtils.js';
import type { SendMessageResult } from './a2a-client-manager.js';
import type {
@@ -26,12 +23,6 @@ import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
} from '@a2a-js/sdk';
import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress } from 'node:dns';
vi.mock('node:dns/promises', () => ({
lookup: vi.fn(),
}));
describe('a2aUtils', () => {
beforeEach(() => {
@@ -42,89 +33,6 @@ describe('a2aUtils', () => {
vi.restoreAllMocks();
});
describe('getGrpcCredentials', () => {
it('should return secure credentials for https', () => {
const credentials = getGrpcCredentials('https://test.agent');
expect(credentials).toBeDefined();
});
it('should return insecure credentials for http', () => {
const credentials = getGrpcCredentials('http://test.agent');
expect(credentials).toBeDefined();
});
});
describe('pinUrlToIp', () => {
it('should resolve and pin hostname to IP', async () => {
vi.mocked(
dnsPromises.lookup as unknown as (
hostname: string,
options: { all: true },
) => Promise<LookupAddress[]>,
).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
const { pinnedUrl, hostname } = await pinUrlToIp(
'http://example.com:9000',
'test-agent',
);
expect(hostname).toBe('example.com');
expect(pinnedUrl).toBe('http://93.184.216.34:9000/');
});
it('should handle raw host:port strings (standard for gRPC)', async () => {
vi.mocked(
dnsPromises.lookup as unknown as (
hostname: string,
options: { all: true },
) => Promise<LookupAddress[]>,
).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
const { pinnedUrl, hostname } = await pinUrlToIp(
'example.com:9000',
'test-agent',
);
expect(hostname).toBe('example.com');
expect(pinnedUrl).toBe('93.184.216.34:9000');
});
it('should throw error if resolution fails (fail closed)', async () => {
vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error'));
await expect(
pinUrlToIp('http://unreachable.com', 'test-agent'),
).rejects.toThrow("Failed to resolve host for agent 'test-agent'");
});
it('should throw error if resolved to private IP', async () => {
vi.mocked(
dnsPromises.lookup as unknown as (
hostname: string,
options: { all: true },
) => Promise<LookupAddress[]>,
).mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
await expect(
pinUrlToIp('http://malicious.com', 'test-agent'),
).rejects.toThrow('resolves to private IP range');
});
it('should allow localhost/127.0.0.1/::1 exceptions', async () => {
vi.mocked(
dnsPromises.lookup as unknown as (
hostname: string,
options: { all: true },
) => Promise<LookupAddress[]>,
).mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
const { pinnedUrl, hostname } = await pinUrlToIp(
'http://localhost:9000',
'test-agent',
);
expect(hostname).toBe('localhost');
expect(pinnedUrl).toBe('http://127.0.0.1:9000/');
});
});
describe('isTerminalState', () => {
it('should return true for completed, failed, canceled, and rejected', () => {
expect(isTerminalState('completed')).toBe(true);
@@ -365,12 +273,12 @@ describe('a2aUtils', () => {
expect(normalized.name).toBe('my-agent');
// @ts-expect-error - testing dynamic preservation
expect(normalized.customField).toBe('keep-me');
expect(normalized.description).toBe('');
expect(normalized.skills).toEqual([]);
expect(normalized.defaultInputModes).toEqual([]);
expect(normalized.description).toBeUndefined();
expect(normalized.skills).toBeUndefined();
expect(normalized.defaultInputModes).toBeUndefined();
});
it('should normalize and synchronize interfaces while preserving other fields', () => {
it('should map supportedInterfaces to additionalInterfaces with protocolBinding → transport', () => {
const raw = {
name: 'test',
supportedInterfaces: [
@@ -384,13 +292,7 @@ describe('a2aUtils', () => {
const normalized = normalizeAgentCard(raw);
// Should exist in both fields
expect(normalized.additionalInterfaces).toHaveLength(1);
expect(
(normalized as unknown as Record<string, unknown>)[
'supportedInterfaces'
],
).toHaveLength(1);
const intf = normalized.additionalInterfaces?.[0] as unknown as Record<
string,
@@ -399,43 +301,18 @@ describe('a2aUtils', () => {
expect(intf['transport']).toBe('GRPC');
expect(intf['url']).toBe('grpc://test');
// Should fallback top-level url
expect(normalized.url).toBe('grpc://test');
});
it('should preserve existing top-level url if present', () => {
it('should not overwrite additionalInterfaces if already present', () => {
const raw = {
name: 'test',
url: 'http://existing',
additionalInterfaces: [{ url: 'http://grpc', transport: 'GRPC' }],
supportedInterfaces: [{ url: 'http://other', transport: 'REST' }],
};
const normalized = normalizeAgentCard(raw);
expect(normalized.url).toBe('http://existing');
});
it('should NOT prepend http:// scheme to raw IP:port strings for gRPC interfaces', () => {
const raw = {
name: 'raw-ip-grpc',
supportedInterfaces: [{ url: '127.0.0.1:9000', transport: 'GRPC' }],
};
const normalized = normalizeAgentCard(raw);
expect(normalized.additionalInterfaces?.[0].url).toBe('127.0.0.1:9000');
expect(normalized.url).toBe('127.0.0.1:9000');
});
it('should prepend http:// scheme to raw IP:port strings for REST interfaces', () => {
const raw = {
name: 'raw-ip-rest',
supportedInterfaces: [{ url: '127.0.0.1:8080', transport: 'REST' }],
};
const normalized = normalizeAgentCard(raw);
expect(normalized.additionalInterfaces?.[0].url).toBe(
'http://127.0.0.1:8080',
);
expect(normalized.additionalInterfaces).toHaveLength(1);
expect(normalized.additionalInterfaces?.[0].url).toBe('http://grpc');
});
it('should NOT override existing transport if protocolBinding is also present', () => {
@@ -448,48 +325,20 @@ describe('a2aUtils', () => {
const normalized = normalizeAgentCard(raw);
expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC');
});
});
describe('splitAgentCardUrl', () => {
const standard = '.well-known/agent-card.json';
it('should not mutate the original card object', () => {
const raw = {
name: 'test',
supportedInterfaces: [{ url: 'grpc://test', protocolBinding: 'GRPC' }],
};
it('should return baseUrl as-is if it does not end with standard path', () => {
const url = 'http://localhost:9001/custom/path';
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
});
it('should split correctly if URL ends with standard path', () => {
const url = `http://localhost:9001/${standard}`;
expect(splitAgentCardUrl(url)).toEqual({
baseUrl: 'http://localhost:9001/',
path: undefined,
});
});
it('should handle trailing slash in baseUrl when splitting', () => {
const url = `http://example.com/api/${standard}`;
expect(splitAgentCardUrl(url)).toEqual({
baseUrl: 'http://example.com/api/',
path: undefined,
});
});
it('should ignore hashes and query params when splitting', () => {
const url = `http://localhost:9001/${standard}?foo=bar#baz`;
expect(splitAgentCardUrl(url)).toEqual({
baseUrl: 'http://localhost:9001/',
path: undefined,
});
});
it('should return original URL if parsing fails', () => {
const url = 'not-a-url';
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
});
it('should handle standard path appearing earlier in the path', () => {
const url = `http://localhost:9001/${standard}/something-else`;
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
const normalized = normalizeAgentCard(raw);
expect(normalized).not.toBe(raw);
expect(normalized.additionalInterfaces).toBeDefined();
// Original should not have additionalInterfaces added
expect(
(raw as Record<string, unknown>)['additionalInterfaces'],
).toBeUndefined();
});
});
+24 -234
View File
@@ -4,9 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as grpc from '@grpc/grpc-js';
import { lookup } from 'node:dns/promises';
import { z } from 'zod';
import type {
Message,
Part,
@@ -18,37 +15,10 @@ import type {
AgentCard,
AgentInterface,
} from '@a2a-js/sdk';
import { isAddressPrivate } from '../utils/fetch.js';
import type { SendMessageResult } from './a2a-client-manager.js';
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
const AgentInterfaceSchema = z
.object({
url: z.string().default(''),
transport: z.string().optional(),
protocolBinding: z.string().optional(),
})
.passthrough();
const AgentCardSchema = z
.object({
name: z.string().default('unknown'),
description: z.string().default(''),
url: z.string().default(''),
version: z.string().default(''),
protocolVersion: z.string().default(''),
capabilities: z.record(z.unknown()).default({}),
skills: z.array(z.union([z.string(), z.record(z.unknown())])).default([]),
defaultInputModes: z.array(z.string()).default([]),
defaultOutputModes: z.array(z.string()).default([]),
additionalInterfaces: z.array(AgentInterfaceSchema).optional(),
supportedInterfaces: z.array(AgentInterfaceSchema).optional(),
preferredTransport: z.string().optional(),
})
.passthrough();
/**
* Reassembles incremental A2A streaming updates into a coherent result.
* Shows sequential status/messages followed by all reassembled artifacts.
@@ -241,166 +211,45 @@ function extractPartText(part: Part): string {
}
/**
* Normalizes an agent card by ensuring it has the required properties
* and resolving any inconsistencies between protocol versions.
* Normalizes proto field name aliases that the SDK doesn't handle yet.
* The A2A proto spec uses `supported_interfaces` and `protocol_binding`,
* while the SDK expects `additionalInterfaces` and `transport`.
* TODO: Remove once @a2a-js/sdk handles these aliases natively.
*/
export function normalizeAgentCard(card: unknown): AgentCard {
if (!isObject(card)) {
throw new Error('Agent card is missing.');
}
// Use Zod to validate and parse the card, ensuring safe defaults and narrowing types.
const parsed = AgentCardSchema.parse(card);
// Narrowing to AgentCard interface after runtime validation.
// Shallow-copy to avoid mutating the SDK's cached object.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = parsed as unknown as AgentCard;
const result = { ...card } as unknown as AgentCard;
// Normalize interfaces and synchronize both interface fields.
const normalizedInterfaces = extractNormalizedInterfaces(parsed);
result.additionalInterfaces = normalizedInterfaces;
// Sync supportedInterfaces for backward compatibility.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const legacyResult = result as unknown as Record<string, AgentInterface[]>;
legacyResult['supportedInterfaces'] = normalizedInterfaces;
// Fallback preferredTransport: If not specified, default to GRPC if available.
if (
!result.preferredTransport &&
normalizedInterfaces.some((i) => i.transport === 'GRPC')
) {
result.preferredTransport = 'GRPC';
// Map supportedInterfaces → additionalInterfaces if needed
if (!result.additionalInterfaces) {
const raw = card;
if (Array.isArray(raw['supportedInterfaces'])) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
result.additionalInterfaces = raw[
'supportedInterfaces'
] as AgentInterface[];
}
}
// Fallback: If top-level URL is missing, use the first interface's URL.
if (result.url === '' && normalizedInterfaces.length > 0) {
result.url = normalizedInterfaces[0].url;
// Map protocolBinding → transport on each interface
for (const intf of result.additionalInterfaces ?? []) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const raw = intf as unknown as Record<string, unknown>;
const binding = raw['protocolBinding'];
if (!intf.transport && typeof binding === 'string') {
intf.transport = binding;
}
}
return result;
}
/**
* Returns gRPC channel credentials based on the URL scheme.
*/
export function getGrpcCredentials(url: string): grpc.ChannelCredentials {
return url.startsWith('https://')
? grpc.credentials.createSsl()
: grpc.credentials.createInsecure();
}
/**
* Returns gRPC channel options to ensure SSL/authority matches the original hostname
* when connecting via a pinned IP address.
*/
export function getGrpcChannelOptions(
hostname: string,
): Record<string, unknown> {
return {
'grpc.default_authority': hostname,
'grpc.ssl_target_name_override': hostname,
};
}
/**
* Resolves a hostname to its IP address and validates it against SSRF.
* Returns the pinned IP-based URL and the original hostname.
*/
export async function pinUrlToIp(
url: string,
agentName: string,
): Promise<{ pinnedUrl: string; hostname: string }> {
if (!url) return { pinnedUrl: url, hostname: '' };
// gRPC URLs in A2A can be 'host:port' or 'dns:///host:port' or have schemes.
// We normalize to host:port for resolution.
const hasScheme = url.includes('://');
const normalizedUrl = hasScheme ? url : `http://${url}`;
try {
const parsed = new URL(normalizedUrl);
const hostname = parsed.hostname;
const sanitizedHost =
hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
// Resolve DNS to check the actual target IP and pin it
const addresses = await lookup(hostname, { all: true });
const publicAddresses = addresses.filter(
(addr) =>
!isAddressPrivate(addr.address) ||
sanitizedHost === 'localhost' ||
sanitizedHost === '127.0.0.1' ||
sanitizedHost === '::1',
);
if (publicAddresses.length === 0) {
if (addresses.length > 0) {
throw new Error(
`Refusing to load agent '${agentName}': transport URL '${url}' resolves to private IP range.`,
);
}
throw new Error(
`Failed to resolve any public IP addresses for host: ${hostname}`,
);
}
const pinnedIp = publicAddresses[0].address;
const pinnedHostname = pinnedIp.includes(':') ? `[${pinnedIp}]` : pinnedIp;
// Reconstruct URL with IP
parsed.hostname = pinnedHostname;
let pinnedUrl = parsed.toString();
// If original didn't have scheme, remove it (standard for gRPC targets)
if (!hasScheme) {
pinnedUrl = pinnedUrl.replace(/^http:\/\//, '');
// URL.toString() might append a trailing slash
if (pinnedUrl.endsWith('/') && !url.endsWith('/')) {
pinnedUrl = pinnedUrl.slice(0, -1);
}
}
return { pinnedUrl, hostname };
} catch (e) {
if (e instanceof Error && e.message.includes('Refusing')) throw e;
throw new Error(`Failed to resolve host for agent '${agentName}': ${url}`, {
cause: e,
});
}
}
/**
* Splts an agent card URL into a baseUrl and a standard path if it already
* contains '.well-known/agent-card.json'.
*/
export function splitAgentCardUrl(url: string): {
baseUrl: string;
path?: string;
} {
const standardPath = '.well-known/agent-card.json';
try {
const parsedUrl = new URL(url);
if (parsedUrl.pathname.endsWith(standardPath)) {
// Reconstruct baseUrl from parsed components to avoid issues with hashes or query params.
parsedUrl.pathname = parsedUrl.pathname.substring(
0,
parsedUrl.pathname.lastIndexOf(standardPath),
);
parsedUrl.search = '';
parsedUrl.hash = '';
// We return undefined for path if it's the standard one,
// because the SDK's DefaultAgentCardResolver appends it automatically.
return { baseUrl: parsedUrl.toString(), path: undefined };
}
} catch (_e) {
// Ignore URL parsing errors here, let the resolver handle them.
}
return { baseUrl: url };
}
/**
* Extracts contextId and taskId from a Message, Task, or Update response.
* Follows the pattern from the A2A CLI sample to maintain conversational continuity.
@@ -446,65 +295,6 @@ export function extractIdsFromResponse(result: SendMessageResult): {
return { contextId, taskId, clearTaskId };
}
/**
* Extracts and normalizes interfaces from the card, handling protocol version fallbacks.
* Preserves all original fields to maintain SDK compatibility.
*/
function extractNormalizedInterfaces(
card: Record<string, unknown>,
): AgentInterface[] {
const rawInterfaces =
getArray(card, 'additionalInterfaces') ||
getArray(card, 'supportedInterfaces');
if (!rawInterfaces) {
return [];
}
const mapped: AgentInterface[] = [];
for (const i of rawInterfaces) {
if (isObject(i)) {
// Use schema to validate interface object.
const parsed = AgentInterfaceSchema.parse(i);
// Narrowing to AgentInterface after runtime validation.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const normalized = parsed as unknown as AgentInterface & {
protocolBinding?: string;
};
// Normalize 'transport' from 'protocolBinding' if missing.
if (!normalized.transport && normalized.protocolBinding) {
normalized.transport = normalized.protocolBinding;
}
// Robust URL: Ensure the URL has a scheme (except for gRPC).
if (
normalized.url &&
!normalized.url.includes('://') &&
!normalized.url.startsWith('/') &&
normalized.transport !== 'GRPC'
) {
// Default to http:// for insecure REST/JSON-RPC if scheme is missing.
normalized.url = `http://${normalized.url}`;
}
mapped.push(normalized as AgentInterface);
}
}
return mapped;
}
/**
* Safely extracts an array property from an object.
*/
function getArray(
obj: Record<string, unknown>,
key: string,
): unknown[] | undefined {
const val = obj[key];
return Array.isArray(val) ? val : undefined;
}
// Type Guards
function isTextPart(part: Part): part is TextPart {
@@ -28,10 +28,10 @@ describe('agent-scheduler', () => {
mockMessageBus = {} as Mocked<MessageBus>;
mockToolRegistry = {
getTool: vi.fn(),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
} as unknown as Mocked<ToolRegistry>;
mockConfig = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
(mockConfig as unknown as { messageBus: MessageBus }).messageBus =
@@ -42,7 +42,7 @@ describe('agent-scheduler', () => {
it('should create a scheduler with agent-specific config', async () => {
const mockConfig = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
@@ -87,11 +87,11 @@ describe('agent-scheduler', () => {
const mainRegistry = { _id: 'main' } as unknown as Mocked<ToolRegistry>;
const agentRegistry = {
_id: 'agent',
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
} as unknown as Mocked<ToolRegistry>;
const config = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
} as unknown as Mocked<Config>;
Object.defineProperty(config, 'toolRegistry', {
get: () => mainRegistry,
+2 -2
View File
@@ -60,7 +60,7 @@ export async function scheduleAgentTools(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const agentConfig: Config = Object.create(config);
agentConfig.getToolRegistry = () => toolRegistry;
agentConfig.getMessageBus = () => toolRegistry.getMessageBus();
agentConfig.getMessageBus = () => toolRegistry.messageBus;
// Override toolRegistry property so AgentLoopContext reads the agent-specific registry.
Object.defineProperty(agentConfig, 'toolRegistry', {
get: () => toolRegistry,
@@ -69,7 +69,7 @@ export async function scheduleAgentTools(
const scheduler = new Scheduler({
context: agentConfig,
messageBus: toolRegistry.getMessageBus(),
messageBus: toolRegistry.messageBus,
getPreferredEditor: getPreferredEditor ?? (() => undefined),
schedulerId,
subagent,
@@ -109,7 +109,7 @@ export const BrowserAgentDefinition = (
): LocalAgentDefinition<typeof BrowserTaskResultSchema> => {
// Use Preview Flash model if the main model is any of the preview models.
// If the main model is not a preview model, use the default flash model.
const model = isPreviewModel(config.getModel())
const model = isPreviewModel(config.getModel(), config)
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
+3 -3
View File
@@ -7,8 +7,8 @@
import type { AgentDefinition } from './types.js';
import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js';
import { z } from 'zod';
import type { Config } from '../config/config.js';
import { GetInternalDocsTool } from '../tools/get-internal-docs.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
const CliHelpReportSchema = z.object({
answer: z
@@ -24,7 +24,7 @@ const CliHelpReportSchema = z.object({
* using its own documentation and runtime state.
*/
export const CliHelpAgent = (
config: Config,
context: AgentLoopContext,
): AgentDefinition<typeof CliHelpReportSchema> => ({
name: 'cli_help',
kind: 'local',
@@ -69,7 +69,7 @@ export const CliHelpAgent = (
},
toolConfig: {
tools: [new GetInternalDocsTool(config.getMessageBus())],
tools: [new GetInternalDocsTool(context.messageBus)],
},
promptConfig: {
@@ -22,9 +22,19 @@ describe('GeneralistAgent', () => {
it('should create a valid generalist agent definition', () => {
const config = makeFakeConfig();
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
const mockToolRegistry = {
getAllToolNames: () => ['tool1', 'tool2', 'agent-tool'],
} as unknown as ToolRegistry);
} as unknown as ToolRegistry;
vi.spyOn(config, 'getToolRegistry').mockReturnValue(mockToolRegistry);
Object.defineProperty(config, 'toolRegistry', {
get: () => mockToolRegistry,
});
Object.defineProperty(config, 'config', {
get() {
return this;
},
});
vi.spyOn(config, 'getAgentRegistry').mockReturnValue({
getDirectoryContext: () => 'mock directory context',
getAllAgentNames: () => ['agent-tool'],
+4 -4
View File
@@ -5,7 +5,7 @@
*/
import { z } from 'zod';
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { getCoreSystemPrompt } from '../core/prompts.js';
import type { LocalAgentDefinition } from './types.js';
@@ -18,7 +18,7 @@ const GeneralistAgentSchema = z.object({
* It uses the same core system prompt as the main agent but in a non-interactive mode.
*/
export const GeneralistAgent = (
config: Config,
context: AgentLoopContext,
): LocalAgentDefinition<typeof GeneralistAgentSchema> => ({
kind: 'local',
name: 'generalist',
@@ -46,7 +46,7 @@ export const GeneralistAgent = (
model: 'inherit',
},
get toolConfig() {
const tools = config.getToolRegistry().getAllToolNames();
const tools = context.toolRegistry.getAllToolNames();
return {
tools,
};
@@ -54,7 +54,7 @@ export const GeneralistAgent = (
get promptConfig() {
return {
systemPrompt: getCoreSystemPrompt(
config,
context.config,
/*useMemory=*/ undefined,
/*interactiveOverride=*/ false,
),
+365 -6
View File
@@ -33,6 +33,7 @@ import {
type PartListUnion,
type Tool,
type CallableTool,
type FunctionDeclaration,
} from '@google/genai';
import type { Config } from '../config/config.js';
import { MockTool } from '../test-utils/mock-tool.js';
@@ -312,12 +313,9 @@ describe('LocalAgentExecutor', () => {
get: () => 'test-prompt-id',
configurable: true,
});
parentToolRegistry = new ToolRegistry(
mockConfig,
mockConfig.getMessageBus(),
);
parentToolRegistry = new ToolRegistry(mockConfig, mockConfig.messageBus);
parentToolRegistry.registerTool(
new LSTool(mockConfig, mockConfig.getMessageBus()),
new LSTool(mockConfig, mockConfig.messageBus),
);
parentToolRegistry.registerTool(
new MockTool({ name: READ_FILE_TOOL_NAME }),
@@ -523,7 +521,7 @@ describe('LocalAgentExecutor', () => {
toolName,
'description',
{},
mockConfig.getMessageBus(),
mockConfig.messageBus,
);
// Mock getTool to return our real DiscoveredMCPTool instance
@@ -560,6 +558,34 @@ describe('LocalAgentExecutor', () => {
getToolSpy.mockRestore();
});
it('should not duplicate schemas when instantiated tools are provided in toolConfig', async () => {
// Create an instantiated mock tool
const instantiatedTool = new MockTool({ name: 'instantiated_tool' });
// Create an agent definition containing the instantiated tool
const definition = createTestDefinition([instantiatedTool]);
// Create the executor
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Extract the prepared tools list using the private method
const toolsList = (
executor as unknown as { prepareToolsList: () => FunctionDeclaration[] }
).prepareToolsList();
// Filter for the specific tool schema
const foundSchemas = (
toolsList as unknown as FunctionDeclaration[]
).filter((t: FunctionDeclaration) => t.name === 'instantiated_tool');
// Assert that there is exactly ONE schema for this tool
expect(foundSchemas).toHaveLength(1);
});
});
describe('run (Execution Loop and Logic)', () => {
@@ -2466,4 +2492,337 @@ describe('LocalAgentExecutor', () => {
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
});
});
describe('DeclarativeTool instance tools (browser agent pattern)', () => {
/**
* The browser agent passes DeclarativeTool instances (not string names) in
* toolConfig.tools. These tests ensure that prepareToolsList() and
* create() handle that pattern correctly in particular, that each tool
* appears exactly once in the function declarations sent to the model.
*/
/**
* Helper that creates a definition using MockTool *instances* in
* toolConfig.tools the same pattern the browser agent uses.
*/
const createInstanceToolDefinition = (
instanceTools: MockTool[],
outputConfigMode: 'default' | 'none' = 'default',
): LocalAgentDefinition => {
const outputConfig =
outputConfigMode === 'default'
? {
outputName: 'finalResult',
description: 'The final result.',
schema: z.string(),
}
: undefined;
return {
kind: 'local',
name: 'BrowserLikeAgent',
description: 'An agent using instance tools.',
inputConfig: {
inputSchema: {
type: 'object',
properties: {
goal: { type: 'string', description: 'goal' },
},
required: ['goal'],
},
},
modelConfig: {
model: 'gemini-test-model',
generateContentConfig: { temperature: 0, topP: 1 },
},
runConfig: { maxTimeMinutes: 5, maxTurns: 5 },
promptConfig: { systemPrompt: 'Achieve: ${goal}.' },
toolConfig: {
// Cast required because the type expects AnyDeclarativeTool |
// string | FunctionDeclaration; MockTool satisfies the first.
tools: instanceTools as unknown as AnyDeclarativeTool[],
},
outputConfig,
} as unknown as LocalAgentDefinition;
};
/**
* Helper to extract the functionDeclarations sent to GeminiChat.
*/
const getSentFunctionDeclarations = () => {
const chatCtorArgs = MockedGeminiChat.mock.calls[0];
const toolsArg = chatCtorArgs[2] as Tool[];
return toolsArg[0].functionDeclarations ?? [];
};
it('should produce NO duplicate function declarations when tools are DeclarativeTool instances', async () => {
const clickTool = new MockTool({ name: 'click' });
const fillTool = new MockTool({ name: 'fill' });
const snapshotTool = new MockTool({ name: 'take_snapshot' });
const definition = createInstanceToolDefinition([
clickTool,
fillTool,
snapshotTool,
]);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'c1',
},
]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
await executor.run({ goal: 'Test' }, signal);
const declarations = getSentFunctionDeclarations();
const names = declarations.map((d) => d.name);
// Each tool must appear exactly once
expect(names.filter((n) => n === 'click')).toHaveLength(1);
expect(names.filter((n) => n === 'fill')).toHaveLength(1);
expect(names.filter((n) => n === 'take_snapshot')).toHaveLength(1);
// Total = 3 tools + complete_task
expect(declarations).toHaveLength(4);
});
it('should register DeclarativeTool instances in the isolated tool registry', async () => {
const clickTool = new MockTool({ name: 'click' });
const navTool = new MockTool({ name: 'navigate_page' });
const definition = createInstanceToolDefinition([clickTool, navTool]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const registry = executor['toolRegistry'];
expect(registry.getTool('click')).toBeDefined();
expect(registry.getTool('navigate_page')).toBeDefined();
// Should NOT have tools that were not passed
expect(registry.getTool(LS_TOOL_NAME)).toBeUndefined();
});
it('should handle mixed string + DeclarativeTool instances without duplicates', async () => {
const instanceTool = new MockTool({ name: 'fill' });
const definition: LocalAgentDefinition = {
kind: 'local',
name: 'MixedAgent',
description: 'Uses both patterns.',
inputConfig: {
inputSchema: {
type: 'object',
properties: { goal: { type: 'string', description: 'goal' } },
},
},
modelConfig: {
model: 'gemini-test-model',
generateContentConfig: { temperature: 0, topP: 1 },
},
runConfig: { maxTimeMinutes: 5, maxTurns: 5 },
promptConfig: { systemPrompt: 'Achieve: ${goal}.' },
toolConfig: {
tools: [
LS_TOOL_NAME, // string reference
instanceTool as unknown as AnyDeclarativeTool, // instance
],
},
outputConfig: {
outputName: 'finalResult',
description: 'result',
schema: z.string(),
},
} as unknown as LocalAgentDefinition;
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'ok' },
id: 'c1',
},
]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
await executor.run({ goal: 'Mixed' }, signal);
const declarations = getSentFunctionDeclarations();
const names = declarations.map((d) => d.name);
expect(names.filter((n) => n === LS_TOOL_NAME)).toHaveLength(1);
expect(names.filter((n) => n === 'fill')).toHaveLength(1);
expect(names.filter((n) => n === TASK_COMPLETE_TOOL_NAME)).toHaveLength(
1,
);
// Total = ls + fill + complete_task
expect(declarations).toHaveLength(3);
});
it('should correctly execute tools passed as DeclarativeTool instances', async () => {
const executeFn = vi.fn().mockResolvedValue({
llmContent: 'Clicked successfully.',
returnDisplay: 'Clicked successfully.',
});
const clickTool = new MockTool({ name: 'click', execute: executeFn });
const definition = createInstanceToolDefinition([clickTool]);
// Turn 1: Model calls click
mockModelResponse([
{ name: 'click', args: { uid: '42' }, id: 'call-click' },
]);
mockScheduleAgentTools.mockResolvedValueOnce([
{
status: 'success',
request: {
callId: 'call-click',
name: 'click',
args: { uid: '42' },
isClientInitiated: false,
prompt_id: 'test',
},
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
callId: 'call-click',
resultDisplay: 'Clicked',
responseParts: [
{
functionResponse: {
name: 'click',
response: { result: 'Clicked' },
id: 'call-click',
},
},
],
error: undefined,
errorType: undefined,
contentLength: undefined,
},
},
]);
// Turn 2: Model completes
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call-done',
},
]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const output = await executor.run({ goal: 'Click test' }, signal);
// The scheduler should have received the click tool call
expect(mockScheduleAgentTools).toHaveBeenCalled();
const scheduledRequests = mockScheduleAgentTools.mock
.calls[0][1] as ToolCallRequestInfo[];
expect(scheduledRequests).toHaveLength(1);
expect(scheduledRequests[0].name).toBe('click');
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
});
it('should always include complete_task even when all tools are instances', async () => {
const definition = createInstanceToolDefinition(
[new MockTool({ name: 'take_snapshot' })],
'none',
);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { result: 'done' },
id: 'c1',
},
]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
await executor.run({ goal: 'Test' }, signal);
const declarations = getSentFunctionDeclarations();
const names = declarations.map((d) => d.name);
expect(names).toContain(TASK_COMPLETE_TOOL_NAME);
expect(names).toContain('take_snapshot');
expect(declarations).toHaveLength(2);
});
it('should produce unique declarations for many instance tools (browser agent scale)', async () => {
// Simulates the full set of tools the browser agent typically registers
const browserToolNames = [
'click',
'click_at',
'fill',
'fill_form',
'hover',
'drag',
'press_key',
'take_snapshot',
'navigate_page',
'new_page',
'close_page',
'select_page',
'evaluate_script',
'type_text',
];
const instanceTools = browserToolNames.map(
(name) => new MockTool({ name }),
);
const definition = createInstanceToolDefinition(instanceTools);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'c1',
},
]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
await executor.run({ goal: 'Scale test' }, signal);
const declarations = getSentFunctionDeclarations();
const names = declarations.map((d) => d.name);
// Every tool name must appear exactly once
for (const toolName of browserToolNames) {
const count = names.filter((n) => n === toolName).length;
expect(count).toBe(1);
}
// Plus complete_task
expect(declarations).toHaveLength(browserToolNames.length + 1);
// Verify the complete set of names has no duplicates
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(names.length);
});
});
});
+2 -7
View File
@@ -1209,17 +1209,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
if (toolConfig) {
for (const toolRef of toolConfig.tools) {
if (typeof toolRef === 'string') {
// The names were already expanded and loaded into the registry during creation.
} else if (typeof toolRef === 'object' && 'schema' in toolRef) {
// Tool instance with an explicit schema property.
toolsList.push(toolRef.schema);
} else {
if (typeof toolRef === 'object' && !('schema' in toolRef)) {
// Raw `FunctionDeclaration` object.
toolsList.push(toolRef);
}
}
// Add schemas from tools that were explicitly registered by name or wildcard.
// Add schemas from tools that were explicitly registered by name, wildcard, or instance.
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
}
+2 -2
View File
@@ -69,7 +69,7 @@ export class AgentRegistry {
* Clears the current registry and re-scans for agents.
*/
async reload(): Promise<void> {
A2AClientManager.getInstance().clearCache();
A2AClientManager.getInstance(this.config).clearCache();
await this.config.reloadAgents();
this.agents.clear();
this.allDefinitions.clear();
@@ -414,7 +414,7 @@ export class AgentRegistry {
// Load the remote A2A agent card and register.
try {
const clientManager = A2AClientManager.getInstance();
const clientManager = A2AClientManager.getInstance(this.config);
let authHandler: AuthenticationHandler | undefined;
if (definition.auth) {
const provider = await A2AAuthProviderFactory.create({
+4 -4
View File
@@ -43,12 +43,12 @@ export const DEFAULT_QUERY_STRING = 'Get Started!';
/**
* The default maximum number of conversational turns for an agent.
*/
export const DEFAULT_MAX_TURNS = 15;
export const DEFAULT_MAX_TURNS = 30;
/**
* The default maximum execution time for an agent in minutes.
*/
export const DEFAULT_MAX_TIME_MINUTES = 5;
export const DEFAULT_MAX_TIME_MINUTES = 10;
/**
* Represents the validated input parameters passed to an agent upon invocation.
@@ -223,12 +223,12 @@ export interface OutputConfig<T extends z.ZodTypeAny> {
export interface RunConfig {
/**
* The maximum execution time for the agent in minutes.
* If not specified, defaults to DEFAULT_MAX_TIME_MINUTES (5).
* If not specified, defaults to DEFAULT_MAX_TIME_MINUTES (10).
*/
maxTimeMinutes?: number;
/**
* The maximum number of conversational turns.
* If not specified, defaults to DEFAULT_MAX_TURNS (15).
* If not specified, defaults to DEFAULT_MAX_TURNS (30).
*/
maxTurns?: number;
}
@@ -54,19 +54,21 @@ export function resolvePolicyChain(
useCustomToolModel,
hasAccessToPreview,
);
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
const isAutoConfigured = isAutoModel(configuredModel);
const isAutoPreferred = preferredModel
? isAutoModel(preferredModel, config)
: false;
const isAutoConfigured = isAutoModel(configuredModel, config);
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (
isGemini3Model(resolvedModel) ||
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel) ||
isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
-1
View File
@@ -700,7 +700,6 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise<void> {
return;
}
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
@@ -208,6 +208,7 @@ describe('CodeAssistServer', () => {
traceId: 'test-trace-id',
status: ActionStatus.ACTION_STATUS_NO_ERROR,
initiationMethod: InitiationMethod.COMMAND,
trajectoryId: 'test-session',
streamingLatency: expect.objectContaining({
totalLatency: expect.stringMatching(/\d+s/),
firstMessageLatency: expect.stringMatching(/\d+s/),
@@ -277,6 +278,7 @@ describe('CodeAssistServer', () => {
conversationOffered: expect.objectContaining({
traceId: 'stream-trace-id',
initiationMethod: InitiationMethod.COMMAND,
trajectoryId: 'test-session',
}),
timestamp: expect.stringMatching(
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/,
+2
View File
@@ -153,6 +153,7 @@ export class CodeAssistServer implements ContentGenerator {
translatedResponse,
streamingLatency,
req.config?.abortSignal,
server.sessionId, // Use sessionId as trajectoryId
);
if (response.consumedCredits) {
@@ -223,6 +224,7 @@ export class CodeAssistServer implements ContentGenerator {
translatedResponse,
streamingLatency,
req.config?.abortSignal,
this.sessionId, // Use sessionId as trajectoryId
);
if (response.remainingCredits) {
@@ -92,6 +92,7 @@ describe('telemetry', () => {
traceId,
undefined,
streamingLatency,
'trajectory-id',
);
expect(result).toEqual({
@@ -102,6 +103,7 @@ describe('telemetry', () => {
streamingLatency,
isAgentic: true,
initiationMethod: InitiationMethod.COMMAND,
trajectoryId: 'trajectory-id',
});
});
@@ -124,6 +126,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
'trajectory-id',
);
expect(result).toBeUndefined();
});
@@ -140,6 +143,7 @@ describe('telemetry', () => {
'trace-id',
signal,
{},
'trajectory-id',
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_CANCELLED);
@@ -155,6 +159,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
'trajectory-id',
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_ERROR_UNKNOWN);
@@ -177,6 +182,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
'trajectory-id',
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_ERROR_UNKNOWN);
@@ -194,6 +200,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
undefined,
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_EMPTY);
@@ -214,7 +221,13 @@ describe('telemetry', () => {
true,
[{ name: 'replace', args: {} }],
);
const result = createConversationOffered(response, 'id', undefined, {});
const result = createConversationOffered(
response,
'id',
undefined,
{},
undefined,
);
expect(result?.includedCode).toBe(true);
});
@@ -231,7 +244,13 @@ describe('telemetry', () => {
true,
[{ name: 'replace', args: {} }],
);
const result = createConversationOffered(response, 'id', undefined, {});
const result = createConversationOffered(
response,
'id',
undefined,
{},
undefined,
);
expect(result?.includedCode).toBe(false);
});
});
@@ -260,6 +279,7 @@ describe('telemetry', () => {
response,
streamingLatency,
undefined,
undefined,
);
expect(serverMock.recordConversationOffered).toHaveBeenCalledWith(
@@ -283,6 +303,7 @@ describe('telemetry', () => {
response,
{},
undefined,
undefined,
);
expect(serverMock.recordConversationOffered).not.toHaveBeenCalled();
@@ -36,6 +36,7 @@ export async function recordConversationOffered(
response: GenerateContentResponse,
streamingLatency: StreamingLatency,
abortSignal: AbortSignal | undefined,
trajectoryId: string | undefined,
): Promise<void> {
try {
if (traceId) {
@@ -44,6 +45,7 @@ export async function recordConversationOffered(
traceId,
abortSignal,
streamingLatency,
trajectoryId,
);
if (offered) {
await server.recordConversationOffered(offered);
@@ -87,6 +89,7 @@ export function createConversationOffered(
traceId: string,
signal: AbortSignal | undefined,
streamingLatency: StreamingLatency,
trajectoryId: string | undefined,
): ConversationOffered | undefined {
// Only send conversation offered events for responses that contain edit
// function calls. Non-edit function calls don't represent file modifications.
@@ -107,6 +110,7 @@ export function createConversationOffered(
streamingLatency,
isAgentic: true,
initiationMethod: InitiationMethod.COMMAND,
trajectoryId,
};
}
+1
View File
@@ -315,6 +315,7 @@ export interface ConversationOffered {
streamingLatency?: StreamingLatency;
isAgentic?: boolean;
initiationMethod?: InitiationMethod;
trajectoryId?: string;
}
export interface StreamingLatency {
+11 -5
View File
@@ -67,6 +67,7 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
} from './models.js';
import { Storage } from './storage.js';
import type { AgentLoopContext } from './agent-loop-context.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -641,8 +642,9 @@ describe('Server Config (config.ts)', () => {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
const loopContext: AgentLoopContext = config;
expect(
config.getGeminiClient().stripThoughtsFromHistory,
loopContext.geminiClient.stripThoughtsFromHistory,
).toHaveBeenCalledWith();
});
@@ -660,8 +662,9 @@ describe('Server Config (config.ts)', () => {
await config.refreshAuth(AuthType.USE_VERTEX_AI);
const loopContext: AgentLoopContext = config;
expect(
config.getGeminiClient().stripThoughtsFromHistory,
loopContext.geminiClient.stripThoughtsFromHistory,
).toHaveBeenCalledWith();
});
@@ -679,8 +682,9 @@ describe('Server Config (config.ts)', () => {
await config.refreshAuth(AuthType.USE_GEMINI);
const loopContext: AgentLoopContext = config;
expect(
config.getGeminiClient().stripThoughtsFromHistory,
loopContext.geminiClient.stripThoughtsFromHistory,
).not.toHaveBeenCalledWith();
});
});
@@ -3059,7 +3063,8 @@ describe('Config JIT Initialization', () => {
await config.initialize();
const skillManager = config.getSkillManager();
const toolRegistry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const toolRegistry = loopContext.toolRegistry;
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
vi.spyOn(skillManager, 'setDisabledSkills');
@@ -3095,7 +3100,8 @@ describe('Config JIT Initialization', () => {
await config.initialize();
const skillManager = config.getSkillManager();
const toolRegistry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const toolRegistry = loopContext.toolRegistry;
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
vi.spyOn(toolRegistry, 'registerTool');
+55 -27
View File
@@ -601,6 +601,7 @@ export interface ConfigParameters {
disableYoloMode?: boolean;
rawOutput?: boolean;
acceptRawOutputRisk?: boolean;
dynamicModelConfiguration?: boolean;
modelConfigServiceConfig?: ModelConfigServiceConfig;
enableHooks?: boolean;
enableHooksUI?: boolean;
@@ -799,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly disableYoloMode: boolean;
private readonly rawOutput: boolean;
private readonly acceptRawOutputRisk: boolean;
private readonly dynamicModelConfiguration: boolean;
private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean;
private readonly enableHooksUI: boolean;
@@ -987,7 +989,7 @@ export class Config implements McpContext, AgentLoopContext {
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
this.useWriteTodos = isPreviewModel(this.model)
this.useWriteTodos = isPreviewModel(this.model, this)
? false
: (params.useWriteTodos ?? true);
this.workspacePoliciesDir = params.workspacePoliciesDir;
@@ -1036,7 +1038,7 @@ export class Config implements McpContext, AgentLoopContext {
// Register Conseca if enabled
if (this.enableConseca) {
debugLogger.log('[SAFETY] Registering Conseca Safety Checker');
ConsecaSafetyChecker.getInstance().setConfig(this);
ConsecaSafetyChecker.getInstance().setContext(this);
}
this._messageBus = new MessageBus(this.policyEngine, this.debugMode);
@@ -1062,6 +1064,7 @@ export class Config implements McpContext, AgentLoopContext {
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
if (params.hooks) {
this.hooks = params.hooks;
@@ -1111,18 +1114,23 @@ export class Config implements McpContext, AgentLoopContext {
// remove this hack.
let modelConfigServiceConfig = params.modelConfigServiceConfig;
if (modelConfigServiceConfig) {
if (!modelConfigServiceConfig.aliases) {
modelConfigServiceConfig = {
...modelConfigServiceConfig,
aliases: DEFAULT_MODEL_CONFIGS.aliases,
};
}
if (!modelConfigServiceConfig.overrides) {
modelConfigServiceConfig = {
...modelConfigServiceConfig,
overrides: DEFAULT_MODEL_CONFIGS.overrides,
};
}
// Ensure user-defined model definitions augment, not replace, the defaults.
const mergedModelDefinitions = {
...DEFAULT_MODEL_CONFIGS.modelDefinitions,
...modelConfigServiceConfig.modelDefinitions,
};
modelConfigServiceConfig = {
// Preserve other user settings like customAliases
...modelConfigServiceConfig,
// Apply defaults for aliases and overrides if they are not provided
aliases:
modelConfigServiceConfig.aliases ?? DEFAULT_MODEL_CONFIGS.aliases,
overrides:
modelConfigServiceConfig.overrides ?? DEFAULT_MODEL_CONFIGS.overrides,
// Use the merged model definitions
modelDefinitions: mergedModelDefinitions,
};
}
this.modelConfigService = new ModelConfigService(
@@ -1225,8 +1233,8 @@ export class Config implements McpContext, AgentLoopContext {
// Re-register ActivateSkillTool to update its schema with the discovered enabled skill enums
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.registerTool(
new ActivateSkillTool(this, this.messageBus),
);
}
@@ -1325,7 +1333,10 @@ export class Config implements McpContext, AgentLoopContext {
// Only reset when we have explicit "no access" (hasAccessToPreviewModel === false).
// When null (quota not fetched) or true, we preserve the saved model.
if (isPreviewModel(this.model) && this.hasAccessToPreviewModel === false) {
if (
isPreviewModel(this.model, this) &&
this.hasAccessToPreviewModel === false
) {
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
}
@@ -1397,14 +1408,26 @@ export class Config implements McpContext, AgentLoopContext {
return this._sessionId;
}
/**
* @deprecated Do not access directly on Config.
* Use the injected AgentLoopContext instead.
*/
get toolRegistry(): ToolRegistry {
return this._toolRegistry;
}
/**
* @deprecated Do not access directly on Config.
* Use the injected AgentLoopContext instead.
*/
get messageBus(): MessageBus {
return this._messageBus;
}
/**
* @deprecated Do not access directly on Config.
* Use the injected AgentLoopContext instead.
*/
get geminiClient(): GeminiClient {
return this._geminiClient;
}
@@ -1581,7 +1604,7 @@ export class Config implements McpContext, AgentLoopContext {
const isPreview =
model === PREVIEW_GEMINI_MODEL_AUTO ||
isPreviewModel(this.getActiveModel());
isPreviewModel(this.getActiveModel(), this);
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
const flashModel = isPreview
? PREVIEW_GEMINI_FLASH_MODEL
@@ -1779,8 +1802,9 @@ export class Config implements McpContext, AgentLoopContext {
}
const hasAccess =
quota.buckets?.some((b) => b.modelId && isPreviewModel(b.modelId)) ??
false;
quota.buckets?.some(
(b) => b.modelId && isPreviewModel(b.modelId, this),
) ?? false;
this.setHasAccessToPreviewModel(hasAccess);
return quota;
} catch (e) {
@@ -2172,6 +2196,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.acceptRawOutputRisk;
}
getExperimentalDynamicModelConfiguration(): boolean {
return this.dynamicModelConfiguration;
}
getPendingIncludeDirectories(): string[] {
return this.pendingIncludeDirectories;
}
@@ -2243,7 +2271,7 @@ export class Config implements McpContext, AgentLoopContext {
* Whenever the user memory (GEMINI.md files) is updated.
*/
updateSystemInstructionIfInitialized(): void {
const geminiClient = this.getGeminiClient();
const geminiClient = this.geminiClient;
if (geminiClient?.isInitialized()) {
geminiClient.updateSystemInstruction();
}
@@ -2709,16 +2737,16 @@ export class Config implements McpContext, AgentLoopContext {
// Re-register ActivateSkillTool to update its schema with the newly discovered skills
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.registerTool(
new ActivateSkillTool(this, this.messageBus),
);
} else {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
}
} else {
this.getSkillManager().clearSkills();
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
}
// Notify the client that system instructions might need updating
@@ -3054,7 +3082,7 @@ export class Config implements McpContext, AgentLoopContext {
for (const definition of definitions) {
try {
const tool = new SubagentTool(definition, this, this.getMessageBus());
const tool = new SubagentTool(definition, this, this.messageBus);
registry.registerTool(tool);
} catch (e: unknown) {
debugLogger.warn(
@@ -3159,7 +3187,7 @@ export class Config implements McpContext, AgentLoopContext {
this.registerSubAgentTools(this._toolRegistry);
}
// Propagate updates to the active chat session
const client = this.getGeminiClient();
const client = this.geminiClient;
if (client?.isInitialized()) {
await client.setTools();
client.updateSystemInstruction();
@@ -249,4 +249,94 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
},
],
modelDefinitions: {
// Concrete Models
'gemini-3.1-pro-preview': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3.1-pro-preview-customtools': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3-pro-preview': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3-flash-preview': {
tier: 'flash',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
'gemini-2.5-flash': {
tier: 'flash',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
'gemini-2.5-flash-lite': {
tier: 'flash-lite',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
// Aliases
auto: {
tier: 'auto',
isPreview: true,
features: { thinking: true, multimodalToolUse: false },
},
pro: {
tier: 'pro',
isPreview: false,
features: { thinking: true, multimodalToolUse: false },
},
flash: {
tier: 'flash',
isPreview: false,
features: { thinking: false, multimodalToolUse: false },
},
'flash-lite': {
tier: 'flash-lite',
isPreview: false,
features: { thinking: false, multimodalToolUse: false },
},
'auto-gemini-3': {
displayName: 'Auto (Gemini 3)',
tier: 'auto',
isPreview: true,
dialogLocation: 'main',
dialogDescription:
'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash',
features: { thinking: true, multimodalToolUse: false },
},
'auto-gemini-2.5': {
displayName: 'Auto (Gemini 2.5)',
tier: 'auto',
isPreview: false,
dialogLocation: 'main',
dialogDescription:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
features: { thinking: false, multimodalToolUse: false },
},
},
};
+115
View File
@@ -36,6 +36,121 @@ import {
VALID_GEMINI_MODELS,
VALID_ALIASES,
} from './models.js';
import type { Config } from './config.js';
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
const modelConfigService = new ModelConfigService(DEFAULT_MODEL_CONFIGS);
const dynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
const legacyConfig = {
getExperimentalDynamicModelConfiguration: () => false,
modelConfigService,
} as unknown as Config;
describe('Dynamic Configuration Parity', () => {
const modelsToTest = [
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
'custom-model',
];
it('getDisplayString should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = getDisplayString(model, legacyConfig);
const dynamic = getDisplayString(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isPreviewModel should match legacy behavior', () => {
const allModels = [
...modelsToTest,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
for (const model of allModels) {
const legacy = isPreviewModel(model, legacyConfig);
const dynamic = isPreviewModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isProModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isProModel(model, legacyConfig);
const dynamic = isProModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isGemini3Model should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isGemini3Model(model, legacyConfig);
const dynamic = isGemini3Model(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isGemini2Model should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isGemini2Model(model, legacyConfig);
const dynamic = isGemini2Model(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isCustomModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isCustomModel(model, legacyConfig);
const dynamic = isCustomModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('supportsModernFeatures should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = supportsModernFeatures(model, legacyConfig);
const dynamic = supportsModernFeatures(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isActiveModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isActiveModel(model, false, false, legacyConfig);
const dynamic = isActiveModel(model, false, false, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isValidModelOrAlias should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isValidModelOrAlias(model, legacyConfig);
const dynamic = isValidModelOrAlias(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('supportsMultimodalFunctionResponse should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = supportsMultimodalFunctionResponse(model, legacyConfig);
const dynamic = supportsMultimodalFunctionResponse(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
});
describe('isPreviewModel', () => {
it('should return true for preview models', () => {
+137 -13
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ModelConfigService } from '../services/modelConfigService.js';
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
@@ -46,6 +48,15 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops.
export const DEFAULT_THINKING_MODE = 8192;
/**
* Represents the minimal configuration needed to resolve models dynamically.
* This breaks circular dependencies by avoiding an import of the full Config class.
*/
export interface ModelResolutionContext {
getExperimentalDynamicModelConfiguration?: () => boolean;
modelConfigService: ModelConfigService;
}
/**
* Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite')
* to a concrete model name.
@@ -148,7 +159,17 @@ export function resolveClassifierModel(
}
return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
}
export function getDisplayString(model: string) {
export function getDisplayString(
model: string,
config?: ModelResolutionContext,
) {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const definition = config.modelConfigService.getModelDefinition(model);
if (definition?.displayName) {
return definition.displayName;
}
}
switch (model) {
case PREVIEW_GEMINI_MODEL_AUTO:
return 'Auto (Gemini 3)';
@@ -169,9 +190,19 @@ export function getDisplayString(model: string) {
* Checks if the model is a preview model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a preview model.
*/
export function isPreviewModel(model: string): boolean {
export function isPreviewModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return (
config.modelConfigService.getModelDefinition(model)?.isPreview === true
);
}
return (
model === PREVIEW_GEMINI_MODEL ||
model === PREVIEW_GEMINI_3_1_MODEL ||
@@ -186,9 +217,16 @@ export function isPreviewModel(model: string): boolean {
* Checks if the model is a Pro model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Pro model.
*/
export function isProModel(model: string): boolean {
export function isProModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.getModelDefinition(model)?.tier === 'pro';
}
return model.toLowerCase().includes('pro');
}
@@ -196,9 +234,22 @@ export function isProModel(model: string): boolean {
* Checks if the model is a Gemini 3 model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Gemini 3 model.
*/
export function isGemini3Model(model: string): boolean {
export function isGemini3Model(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior resolves the model first.
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.family ===
'gemini-3'
);
}
const resolved = resolveModel(model);
return /^gemini-3(\.|-|$)/.test(resolved);
}
@@ -207,9 +258,20 @@ export function isGemini3Model(model: string): boolean {
* Checks if the model is a Gemini 2.x model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Gemini-2.x model.
*/
export function isGemini2Model(model: string): boolean {
export function isGemini2Model(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior does NOT resolve the model first for Gemini 2 check.
return (
config.modelConfigService.getModelDefinition(model)?.family ===
'gemini-2.5'
);
}
return /^gemini-2(\.|$)/.test(model);
}
@@ -217,9 +279,20 @@ export function isGemini2Model(model: string): boolean {
* Checks if the model is a "custom" model (not Gemini branded).
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is not a Gemini branded model.
*/
export function isCustomModel(model: string): boolean {
export function isCustomModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.tier ===
'custom' || !resolved.startsWith('gemini-')
);
}
const resolved = resolveModel(model);
return !resolved.startsWith('gemini-');
}
@@ -229,20 +302,31 @@ export function isCustomModel(model: string): boolean {
* This includes Gemini 3 models and any custom models.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model supports modern features like thoughts.
*/
export function supportsModernFeatures(model: string): boolean {
if (isGemini3Model(model)) return true;
return isCustomModel(model);
export function supportsModernFeatures(
model: string,
config?: ModelResolutionContext,
): boolean {
if (isGemini3Model(model, config)) return true;
return isCustomModel(model, config);
}
/**
* Checks if the model is an auto model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is an auto model.
*/
export function isAutoModel(model: string): boolean {
export function isAutoModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.getModelDefinition(model)?.tier === 'auto';
}
return (
model === GEMINI_MODEL_ALIAS_AUTO ||
model === PREVIEW_GEMINI_MODEL_AUTO ||
@@ -255,9 +339,23 @@ export function isAutoModel(model: string): boolean {
* This is supported in Gemini 3.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model supports multimodal function responses.
*/
export function supportsMultimodalFunctionResponse(model: string): boolean {
export function supportsMultimodalFunctionResponse(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (VALID_ALIASES.has(model)) {
return false;
}
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.features
?.multimodalToolUse === true
);
}
return model.startsWith('gemini-3-');
}
@@ -266,16 +364,32 @@ export function supportsMultimodalFunctionResponse(model: string): boolean {
*
* @param model The model name to check.
* @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is active.
*/
export function isActiveModel(
model: string,
useGemini3_1: boolean = false,
useCustomToolModel: boolean = false,
config?: ModelResolutionContext,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (VALID_ALIASES.has(model)) {
return false;
}
const definition = config.modelConfigService.getModelDefinition(model);
if (!definition) {
return false;
}
// Legacy logic only considers explicit Gemini models as "active".
if (definition.tier === 'custom' && !model.startsWith('gemini-')) {
return false;
}
} else if (!VALID_GEMINI_MODELS.has(model)) {
return false;
}
if (useGemini3_1) {
if (model === PREVIEW_GEMINI_MODEL) {
return false;
@@ -297,9 +411,19 @@ export function isActiveModel(
* Checks if the model name is valid (either a valid model or a valid alias).
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is valid.
*/
export function isValidModelOrAlias(model: string): boolean {
export function isValidModelOrAlias(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (config.modelConfigService.getModelDefinition(model)) {
return true;
}
}
// Check if it's a valid alias
if (VALID_ALIASES.has(model)) {
return true;
@@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
import { Config } from './config.js';
import { TRACKER_CREATE_TASK_TOOL_NAME } from '../tools/tool-names.js';
import * as os from 'node:os';
import type { AgentLoopContext } from './agent-loop-context.js';
describe('Config Tracker Feature Flag', () => {
const baseParams = {
@@ -21,7 +22,8 @@ describe('Config Tracker Feature Flag', () => {
it('should not register tracker tools by default', async () => {
const config = new Config(baseParams);
await config.initialize();
const registry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const registry = loopContext.toolRegistry;
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
});
@@ -31,7 +33,8 @@ describe('Config Tracker Feature Flag', () => {
tracker: true,
});
await config.initialize();
const registry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const registry = loopContext.toolRegistry;
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeDefined();
});
@@ -41,7 +44,8 @@ describe('Config Tracker Feature Flag', () => {
tracker: false,
});
await config.initialize();
const registry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const registry = loopContext.toolRegistry;
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
});
});
+25 -1
View File
@@ -52,6 +52,7 @@ import * as policyCatalog from '../availability/policyCatalog.js';
import { LlmRole, LoopType } from '../telemetry/types.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents } from '../utils/events.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();
@@ -216,6 +217,7 @@ describe('Gemini Client (client.ts)', () => {
getGlobalMemory: vi.fn().mockReturnValue(''),
getEnvironmentMemory: vi.fn().mockReturnValue(''),
isJitContextEnabled: vi.fn().mockReturnValue(false),
getContextManager: vi.fn().mockReturnValue(undefined),
getToolOutputMaskingEnabled: vi.fn().mockReturnValue(false),
getDisableLoopDetection: vi.fn().mockReturnValue(false),
@@ -284,7 +286,10 @@ describe('Gemini Client (client.ts)', () => {
(
mockConfig as unknown as { toolRegistry: typeof mockToolRegistry }
).toolRegistry = mockToolRegistry;
(mockConfig as unknown as { messageBus: undefined }).messageBus = undefined;
(mockConfig as unknown as { messageBus: MessageBus }).messageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
} as unknown as MessageBus;
(mockConfig as unknown as { config: Config; promptId: string }).config =
mockConfig;
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
@@ -293,6 +298,8 @@ describe('Gemini Client (client.ts)', () => {
client = new GeminiClient(mockConfig as unknown as AgentLoopContext);
await client.initialize();
vi.mocked(mockConfig.getGeminiClient).mockReturnValue(client);
(mockConfig as unknown as { geminiClient: GeminiClient }).geminiClient =
client;
vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
});
@@ -368,6 +375,23 @@ describe('Gemini Client (client.ts)', () => {
expect(newHistory.length).toBe(initialHistory.length);
expect(JSON.stringify(newHistory)).not.toContain('some old message');
});
it('should refresh ContextManager to reset JIT loaded paths', async () => {
const mockRefresh = vi.fn().mockResolvedValue(undefined);
vi.mocked(mockConfig.getContextManager).mockReturnValue({
refresh: mockRefresh,
} as unknown as ReturnType<typeof mockConfig.getContextManager>);
await client.resetChat();
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
it('should not fail when ContextManager is undefined', async () => {
vi.mocked(mockConfig.getContextManager).mockReturnValue(undefined);
await expect(client.resetChat()).resolves.not.toThrow();
});
});
describe('startChat', () => {
+4 -1
View File
@@ -299,6 +299,9 @@ export class GeminiClient {
async resetChat(): Promise<void> {
this.chat = await this.startChat();
this.updateTelemetryTokenCount();
// Reset JIT context loaded paths so subdirectory context can be
// re-discovered in the new session.
await this.config.getContextManager()?.refresh();
}
dispose() {
@@ -866,7 +869,7 @@ export class GeminiClient {
}
const hooksEnabled = this.config.getEnableHooks();
const messageBus = this.config.getMessageBus();
const messageBus = this.context.messageBus;
if (this.lastPromptId !== prompt_id) {
this.loopDetector.reset(prompt_id, partListUnionToString(request));
@@ -318,6 +318,16 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
}) as unknown as PolicyEngine;
}
Object.defineProperty(finalConfig, 'toolRegistry', {
get: () => finalConfig.getToolRegistry?.() || defaultToolRegistry,
});
Object.defineProperty(finalConfig, 'messageBus', {
get: () => finalConfig.getMessageBus?.(),
});
Object.defineProperty(finalConfig, 'geminiClient', {
get: () => finalConfig.getGeminiClient?.(),
});
return finalConfig;
}
@@ -351,7 +361,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -431,7 +441,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -532,7 +542,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -629,7 +639,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -684,7 +694,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -750,7 +760,7 @@ describe('CoreToolScheduler with payload', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -898,7 +908,7 @@ describe('CoreToolScheduler edit cancellation', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -991,7 +1001,7 @@ describe('CoreToolScheduler YOLO mode', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1083,7 +1093,7 @@ describe('CoreToolScheduler request queueing', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1212,7 +1222,7 @@ describe('CoreToolScheduler request queueing', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1320,7 +1330,7 @@ describe('CoreToolScheduler request queueing', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1381,7 +1391,7 @@ describe('CoreToolScheduler request queueing', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1453,7 +1463,7 @@ describe('CoreToolScheduler request queueing', () => {
getAllTools: () => [],
getToolsByServer: () => [],
tools: new Map(),
config: mockConfig,
context: mockConfig,
mcpClientManager: undefined,
getToolByName: () => testTool,
getToolByDisplayName: () => testTool,
@@ -1471,7 +1481,7 @@ describe('CoreToolScheduler request queueing', () => {
> = [];
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate: (toolCalls) => {
onToolCallsUpdate(toolCalls);
@@ -1620,7 +1630,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1725,7 +1735,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1829,7 +1839,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1894,7 +1904,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
getPreferredEditor: () => 'vscode',
});
@@ -2005,7 +2015,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
getPreferredEditor: () => 'vscode',
});
@@ -2069,7 +2079,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2138,7 +2148,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2229,7 +2239,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2283,7 +2293,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2344,7 +2354,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
+14 -16
View File
@@ -13,7 +13,6 @@ import {
ToolConfirmationOutcome,
} from '../tools/tools.js';
import type { EditorType } from '../utils/editor.js';
import type { Config } from '../config/config.js';
import { PolicyDecision } from '../policy/types.js';
import { logToolCall } from '../telemetry/loggers.js';
import { ToolErrorType } from '../tools/tool-error.js';
@@ -50,6 +49,7 @@ import { ToolExecutor } from '../scheduler/tool-executor.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { getPolicyDenialError } from '../scheduler/policy.js';
import { GeminiCliOperation } from '../telemetry/constants.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export type {
ToolCall,
@@ -92,7 +92,7 @@ const createErrorResponse = (
});
interface CoreToolSchedulerOptions {
config: Config;
context: AgentLoopContext;
outputUpdateHandler?: OutputUpdateHandler;
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
onToolCallsUpdate?: ToolCallsUpdateHandler;
@@ -112,7 +112,7 @@ export class CoreToolScheduler {
private onAllToolCallsComplete?: AllToolCallsCompleteHandler;
private onToolCallsUpdate?: ToolCallsUpdateHandler;
private getPreferredEditor: () => EditorType | undefined;
private config: Config;
private context: AgentLoopContext;
private isFinalizingToolCalls = false;
private isScheduling = false;
private isCancelling = false;
@@ -128,19 +128,19 @@ export class CoreToolScheduler {
private toolModifier: ToolModificationHandler;
constructor(options: CoreToolSchedulerOptions) {
this.config = options.config;
this.context = options.context;
this.outputUpdateHandler = options.outputUpdateHandler;
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate;
this.getPreferredEditor = options.getPreferredEditor;
this.toolExecutor = new ToolExecutor(this.config);
this.toolExecutor = new ToolExecutor(this.context.config);
this.toolModifier = new ToolModificationHandler();
// Subscribe to message bus for ASK_USER policy decisions
// Use a static WeakMap to ensure we only subscribe ONCE per MessageBus instance
// This prevents memory leaks when multiple CoreToolScheduler instances are created
// (e.g., on every React render, or for each non-interactive tool call)
const messageBus = this.config.getMessageBus();
const messageBus = this.context.messageBus;
// Check if we've already subscribed a handler to this message bus
if (!CoreToolScheduler.subscribedMessageBuses.has(messageBus)) {
@@ -526,18 +526,16 @@ export class CoreToolScheduler {
);
}
const requestsToProcess = Array.isArray(request) ? request : [request];
const currentApprovalMode = this.config.getApprovalMode();
const currentApprovalMode = this.context.config.getApprovalMode();
this.completedToolCallsForBatch = [];
const newToolCalls: ToolCall[] = requestsToProcess.map(
(reqInfo): ToolCall => {
const toolInstance = this.config
.getToolRegistry()
.getTool(reqInfo.name);
const toolInstance = this.context.toolRegistry.getTool(reqInfo.name);
if (!toolInstance) {
const suggestion = getToolSuggestion(
reqInfo.name,
this.config.getToolRegistry().getAllToolNames(),
this.context.toolRegistry.getAllToolNames(),
);
const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`;
return {
@@ -647,13 +645,13 @@ export class CoreToolScheduler {
: undefined;
const toolAnnotations = toolCall.tool.toolAnnotations;
const { decision, rule } = await this.config
const { decision, rule } = await this.context.config
.getPolicyEngine()
.check(toolCallForPolicy, serverName, toolAnnotations);
if (decision === PolicyDecision.DENY) {
const { errorMessage, errorType } = getPolicyDenialError(
this.config,
this.context.config,
rule,
);
this.setStatusInternal(
@@ -694,7 +692,7 @@ export class CoreToolScheduler {
signal,
);
} else {
if (!this.config.isInteractive()) {
if (!this.context.config.isInteractive()) {
throw new Error(
`Tool execution for "${
toolCall.tool.displayName || toolCall.tool.name
@@ -703,7 +701,7 @@ export class CoreToolScheduler {
}
// Fire Notification hook before showing confirmation to user
const hookSystem = this.config.getHookSystem();
const hookSystem = this.context.config.getHookSystem();
if (hookSystem) {
await hookSystem.fireToolNotificationEvent(confirmationDetails);
}
@@ -988,7 +986,7 @@ export class CoreToolScheduler {
// The active tool is finished. Move it to the completed batch.
const completedCall = activeCall as CompletedToolCall;
this.completedToolCallsForBatch.push(completedCall);
logToolCall(this.config, new ToolCallEvent(completedCall));
logToolCall(this.context.config, new ToolCallEvent(completedCall));
// Clear the active tool slot. This is crucial for the sequential processing.
this.toolCalls = [];
@@ -137,6 +137,10 @@ describe('GeminiChat', () => {
let currentActiveModel = 'gemini-pro';
mockConfig = {
get config() {
return this;
},
promptId: 'test-session-id',
getSessionId: () => 'test-session-id',
getTelemetryLogPromptsEnabled: () => true,
getUsageStatisticsEnabled: () => true,
+37 -28
View File
@@ -25,7 +25,6 @@ import {
getRetryErrorType,
} from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
resolveModel,
isGemini2Model,
@@ -59,6 +58,7 @@ import {
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { coreEvents } from '../utils/events.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export enum StreamEventType {
/** A regular content chunk from the API. */
@@ -251,7 +251,7 @@ export class GeminiChat {
private lastPromptTokenCount: number;
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
private systemInstruction: string = '',
private tools: Tool[] = [],
private history: Content[] = [],
@@ -260,7 +260,7 @@ export class GeminiChat {
kind: 'main' | 'subagent' = 'main',
) {
validateHistory(history);
this.chatRecordingService = new ChatRecordingService(config);
this.chatRecordingService = new ChatRecordingService(context);
this.chatRecordingService.initialize(resumedSessionData, kind);
this.lastPromptTokenCount = estimateTokenCountSync(
this.history.flatMap((c) => c.parts || []),
@@ -315,7 +315,7 @@ export class GeminiChat {
const userContent = createUserContent(message);
const { model } =
this.config.modelConfigService.getResolvedConfig(modelConfigKey);
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
// Record user input - capture complete message with all parts (text, files, images, etc.)
// but skip recording function responses (tool call results) as they should be stored in tool call records
@@ -350,7 +350,7 @@ export class GeminiChat {
this: GeminiChat,
): AsyncGenerator<StreamEvent, void, void> {
try {
const maxAttempts = this.config.getMaxAttempts();
const maxAttempts = this.context.config.getMaxAttempts();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let isConnectionPhase = true;
@@ -412,7 +412,7 @@ export class GeminiChat {
// like ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC or ApiError)
const isRetryable = isRetryableError(
error,
this.config.getRetryFetchErrors(),
this.context.config.getRetryFetchErrors(),
);
const isContentError = error instanceof InvalidStreamError;
@@ -421,7 +421,7 @@ export class GeminiChat {
: getRetryErrorType(error);
if (
(isContentError && isGemini2Model(model)) ||
(isContentError && isGemini2Model(model, this.context.config)) ||
(isRetryable && !signal.aborted)
) {
// The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
@@ -437,12 +437,12 @@ export class GeminiChat {
if (isContentError) {
logContentRetry(
this.config,
this.context.config,
new ContentRetryEvent(attempt, errorType, delayMs, model),
);
} else {
logNetworkRetryAttempt(
this.config,
this.context.config,
new NetworkRetryAttemptEvent(
attempt + 1,
maxAttempts,
@@ -472,7 +472,7 @@ export class GeminiChat {
}
logContentRetryFailure(
this.config,
this.context.config,
new ContentRetryFailureEvent(attempt + 1, errorType, model),
);
@@ -502,7 +502,7 @@ export class GeminiChat {
model: availabilityFinalModel,
config: newAvailabilityConfig,
maxAttempts: availabilityMaxAttempts,
} = applyModelSelection(this.config, modelConfigKey);
} = applyModelSelection(this.context.config, modelConfigKey);
let lastModelToUse = availabilityFinalModel;
let currentGenerateContentConfig: GenerateContentConfig =
@@ -511,26 +511,30 @@ export class GeminiChat {
let lastContentsToUse: Content[] = [...requestContents];
const getAvailabilityContext = createAvailabilityContextProvider(
this.config,
this.context.config,
() => lastModelToUse,
);
// Track initial active model to detect fallback changes
const initialActiveModel = this.config.getActiveModel();
const initialActiveModel = this.context.config.getActiveModel();
const apiCall = async () => {
const useGemini3_1 = (await this.config.getGemini31Launched?.()) ?? false;
const useGemini3_1 =
(await this.context.config.getGemini31Launched?.()) ?? false;
// Default to the last used model (which respects arguments/availability selection)
let modelToUse = resolveModel(lastModelToUse, useGemini3_1);
// If the active model has changed (e.g. due to a fallback updating the config),
// we switch to the new active model.
if (this.config.getActiveModel() !== initialActiveModel) {
modelToUse = resolveModel(this.config.getActiveModel(), useGemini3_1);
if (this.context.config.getActiveModel() !== initialActiveModel) {
modelToUse = resolveModel(
this.context.config.getActiveModel(),
useGemini3_1,
);
}
if (modelToUse !== lastModelToUse) {
const { generateContentConfig: newConfig } =
this.config.modelConfigService.getResolvedConfig({
this.context.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: modelToUse,
});
@@ -547,11 +551,14 @@ export class GeminiChat {
abortSignal,
};
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
let contentsToUse: Content[] = supportsModernFeatures(
modelToUse,
this.context.config,
)
? [...contentsForPreviewModel]
: [...requestContents];
const hookSystem = this.config.getHookSystem();
const hookSystem = this.context.config.getHookSystem();
if (hookSystem) {
const beforeModelResult = await hookSystem.fireBeforeModelEvent({
model: modelToUse,
@@ -619,7 +626,7 @@ export class GeminiChat {
lastConfig = config;
lastContentsToUse = contentsToUse;
return this.config.getContentGenerator().generateContentStream(
return this.context.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
contents: contentsToUse,
@@ -633,12 +640,12 @@ export class GeminiChat {
const onPersistent429Callback = async (
authType?: string,
error?: unknown,
) => handleFallback(this.config, lastModelToUse, authType, error);
) => handleFallback(this.context.config, lastModelToUse, authType, error);
const onValidationRequiredCallback = async (
validationError: ValidationRequiredError,
) => {
const handler = this.config.getValidationHandler();
const handler = this.context.config.getValidationHandler();
if (typeof handler !== 'function') {
// No handler registered, re-throw to show default error message
throw validationError;
@@ -653,15 +660,17 @@ export class GeminiChat {
const streamResponse = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
onValidationRequired: onValidationRequiredCallback,
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
authType: this.context.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.context.config.getRetryFetchErrors(),
signal: abortSignal,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
maxAttempts:
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
getAvailabilityContext,
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
maxAttempts:
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
delayMs,
error: error instanceof Error ? error.message : String(error),
model: lastModelToUse,
@@ -814,7 +823,7 @@ export class GeminiChat {
isSchemaDepthError(error.message) ||
isInvalidArgumentError(error.message)
) {
const tools = this.config.getToolRegistry().getAllTools();
const tools = this.context.toolRegistry.getAllTools();
const cyclicSchemaTools: string[] = [];
for (const tool of tools) {
if (
@@ -881,7 +890,7 @@ export class GeminiChat {
}
}
const hookSystem = this.config.getHookSystem();
const hookSystem = this.context.config.getHookSystem();
if (originalRequest && chunk && hookSystem) {
const hookResult = await hookSystem.fireAfterModelEvent(
originalRequest,
@@ -79,7 +79,20 @@ describe('GeminiChat Network Retries', () => {
// Default mock implementation: execute the function immediately
mockRetryWithBackoff.mockImplementation(async (apiCall) => apiCall());
const mockToolRegistry = { getTool: vi.fn() };
const testMessageBus = { publish: vi.fn(), subscribe: vi.fn() };
mockConfig = {
get config() {
return this;
},
get toolRegistry() {
return mockToolRegistry;
},
get messageBus() {
return testMessageBus;
},
promptId: 'test-session-id',
getSessionId: () => 'test-session-id',
getTelemetryLogPromptsEnabled: () => true,
getUsageStatisticsEnabled: () => true,
@@ -10,6 +10,7 @@ import fs from 'node:fs';
import type { Config } from '../config/config.js';
import type { AgentDefinition } from '../agents/types.js';
import * as toolNames from '../tools/tool-names.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('node:fs');
vi.mock('../utils/gitUtils', () => ({
@@ -22,6 +23,17 @@ describe('Core System Prompt Substitution', () => {
vi.resetAllMocks();
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
mockConfig = {
get config() {
return this;
},
toolRegistry: {
getAllToolNames: vi
.fn()
.mockReturnValue([
toolNames.WRITE_FILE_TOOL_NAME,
toolNames.READ_FILE_TOOL_NAME,
]),
},
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi
.fn()
@@ -131,7 +143,10 @@ describe('Core System Prompt Substitution', () => {
});
it('should not substitute disabled tool names', () => {
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([]);
vi.mocked(
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry
.getAllToolNames,
).mockReturnValue([]);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('Use ${write_file_ToolName}.');
+25 -11
View File
@@ -82,11 +82,12 @@ describe('Core System Prompt (prompts.ts)', () => {
vi.stubEnv('SANDBOX', undefined);
vi.stubEnv('GEMINI_SYSTEM_MD', undefined);
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', undefined);
const mockRegistry = {
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
getAllTools: vi.fn().mockReturnValue([]),
};
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
getAllTools: vi.fn().mockReturnValue([]),
}),
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
@@ -114,6 +115,12 @@ describe('Core System Prompt (prompts.ts)', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTrackerEnabled: vi.fn().mockReturnValue(false),
get config() {
return this;
},
get toolRegistry() {
return mockRegistry;
},
} as unknown as Config;
});
@@ -374,7 +381,7 @@ describe('Core System Prompt (prompts.ts)', () => {
it('should redact grep and glob from the system prompt when they are disabled', () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([]);
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([]);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('`grep_search`');
@@ -390,10 +397,11 @@ describe('Core System Prompt (prompts.ts)', () => {
])(
'should handle CodebaseInvestigator with tools=%s',
(toolNames, expectCodebaseInvestigator) => {
const mockToolRegistry = {
getAllToolNames: vi.fn().mockReturnValue(toolNames),
};
const testConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue(toolNames),
}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
@@ -413,6 +421,12 @@ describe('Core System Prompt (prompts.ts)', () => {
}),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTrackerEnabled: vi.fn().mockReturnValue(false),
get config() {
return this;
},
get toolRegistry() {
return mockToolRegistry;
},
} as unknown as Config;
const prompt = getCoreSystemPrompt(testConfig);
@@ -468,7 +482,7 @@ describe('Core System Prompt (prompts.ts)', () => {
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue(
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
planModeTools,
);
};
@@ -522,7 +536,7 @@ describe('Core System Prompt (prompts.ts)', () => {
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue(
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
subsetTools,
);
@@ -667,7 +681,7 @@ describe('Core System Prompt (prompts.ts)', () => {
it('should include planning phase suggestion when enter_plan_mode tool is enabled', () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([
'enter_plan_mode',
]);
const prompt = getCoreSystemPrompt(mockConfig);
@@ -64,16 +64,22 @@ describe('HookEventHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
const mockGeminiClient = {
getChatRecordingService: vi.fn().mockReturnValue({
getConversationFilePath: vi
.fn()
.mockReturnValue('/test/project/.gemini/tmp/chats/session.json'),
}),
};
mockConfig = {
get config() {
return this;
},
geminiClient: mockGeminiClient,
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getSessionId: vi.fn().mockReturnValue('test-session'),
getWorkingDir: vi.fn().mockReturnValue('/test/project'),
getGeminiClient: vi.fn().mockReturnValue({
getChatRecordingService: vi.fn().mockReturnValue({
getConversationFilePath: vi
.fn()
.mockReturnValue('/test/project/.gemini/tmp/chats/session.json'),
}),
}),
} as unknown as Config;
mockHookPlanner = {
+8 -9
View File
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
import type { HookRunner } from './hookRunner.js';
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
@@ -40,12 +39,13 @@ import { logHookCall } from '../telemetry/loggers.js';
import { HookCallEvent } from '../telemetry/types.js';
import { debugLogger } from '../utils/debugLogger.js';
import { coreEvents } from '../utils/events.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
/**
* Hook event bus that coordinates hook execution across the system
*/
export class HookEventHandler {
private readonly config: Config;
private readonly context: AgentLoopContext;
private readonly hookPlanner: HookPlanner;
private readonly hookRunner: HookRunner;
private readonly hookAggregator: HookAggregator;
@@ -58,12 +58,12 @@ export class HookEventHandler {
private readonly reportedFailures = new WeakMap<object, Set<string>>();
constructor(
config: Config,
context: AgentLoopContext,
hookPlanner: HookPlanner,
hookRunner: HookRunner,
hookAggregator: HookAggregator,
) {
this.config = config;
this.context = context;
this.hookPlanner = hookPlanner;
this.hookRunner = hookRunner;
this.hookAggregator = hookAggregator;
@@ -370,15 +370,14 @@ export class HookEventHandler {
private createBaseInput(eventName: HookEventName): HookInput {
// Get the transcript path from the ChatRecordingService if available
const transcriptPath =
this.config
.getGeminiClient()
this.context.geminiClient
?.getChatRecordingService()
?.getConversationFilePath() ?? '';
return {
session_id: this.config.getSessionId(),
session_id: this.context.config.getSessionId(),
transcript_path: transcriptPath,
cwd: this.config.getWorkingDir(),
cwd: this.context.config.getWorkingDir(),
hook_event_name: eventName,
timestamp: new Date().toISOString(),
};
@@ -457,7 +456,7 @@ export class HookEventHandler {
result.error?.message,
);
logHookCall(this.config, hookCallEvent);
logHookCall(this.context.config, hookCallEvent);
}
// Log individual errors
+97 -6
View File
@@ -281,15 +281,105 @@ describe('AntigravityInstaller', () => {
);
});
it('returns a failure message if the alias is not set', async () => {
it('ignores an unsafe alias and falls back to safe commands', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy;malicious_command');
const { installer } = setup();
vi.mocked(child_process.execSync).mockImplementationOnce(() => 'agy');
const result = await installer.install();
expect(result.success).toBe(true);
expect(child_process.execSync).toHaveBeenCalledTimes(1);
expect(child_process.execSync).toHaveBeenCalledWith('command -v agy', {
stdio: 'ignore',
});
expect(child_process.spawnSync).toHaveBeenCalledWith(
'agy',
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: false },
);
});
it('falls back to antigravity when agy is unavailable on linux', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
const { installer } = setup();
vi.mocked(child_process.execSync)
.mockImplementationOnce(() => {
throw new Error('Command not found');
})
.mockImplementationOnce(() => 'antigravity');
const result = await installer.install();
expect(result.success).toBe(true);
expect(child_process.execSync).toHaveBeenNthCalledWith(
1,
'command -v agy',
{
stdio: 'ignore',
},
);
expect(child_process.execSync).toHaveBeenNthCalledWith(
2,
'command -v antigravity',
{ stdio: 'ignore' },
);
expect(child_process.spawnSync).toHaveBeenCalledWith(
'antigravity',
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: false },
);
});
it('falls back to antigravity.cmd when agy.cmd is unavailable on windows', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy.cmd');
const { installer } = setup({
platform: 'win32',
});
vi.mocked(child_process.execSync)
.mockImplementationOnce(() => {
throw new Error('Command not found');
})
.mockImplementationOnce(
() => 'C:\\Program Files\\Antigravity\\bin\\antigravity.cmd',
);
const result = await installer.install();
expect(result.success).toBe(true);
expect(child_process.execSync).toHaveBeenNthCalledWith(
1,
'where.exe agy.cmd',
);
expect(child_process.execSync).toHaveBeenNthCalledWith(
2,
'where.exe antigravity.cmd',
);
expect(child_process.spawnSync).toHaveBeenCalledWith(
'C:\\Program Files\\Antigravity\\bin\\antigravity.cmd',
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: true },
);
});
it('falls back to default commands if the alias is not set', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
const { installer } = setup({});
const result = await installer.install();
expect(result.success).toBe(false);
expect(result.message).toContain(
'ANTIGRAVITY_CLI_ALIAS environment variable not set',
);
expect(result.success).toBe(true);
});
it('returns a failure message if the command is not found', async () => {
@@ -302,6 +392,7 @@ describe('AntigravityInstaller', () => {
const result = await installer.install();
expect(result.success).toBe(false);
expect(result.message).toContain('not-a-command not found');
expect(result.message).toContain('Antigravity CLI not found');
expect(result.message).toContain('agy, antigravity');
});
});
+25 -8
View File
@@ -252,19 +252,36 @@ class AntigravityInstaller implements IdeInstaller {
) {}
async install(): Promise<InstallResult> {
const command = process.env['ANTIGRAVITY_CLI_ALIAS'];
if (!command) {
return {
success: false,
message: 'ANTIGRAVITY_CLI_ALIAS environment variable not set.',
};
const envCommand = process.env['ANTIGRAVITY_CLI_ALIAS'];
const safeCommandPattern = /^[a-zA-Z0-9.\-_/\\]+$/;
const sanitizedEnvCommand =
envCommand && safeCommandPattern.test(envCommand)
? envCommand
: undefined;
const fallbackCommands =
this.platform === 'win32'
? ['agy.cmd', 'antigravity.cmd']
: ['agy', 'antigravity'];
const commands = [
...(sanitizedEnvCommand ? [sanitizedEnvCommand] : []),
...fallbackCommands,
].filter(
(command, index, allCommands) => allCommands.indexOf(command) === index,
);
let commandPath: string | null = null;
for (const command of commands) {
commandPath = await findCommand(command, this.platform);
if (commandPath) {
break;
}
}
const commandPath = await findCommand(command, this.platform);
if (!commandPath) {
const supportedCommands = fallbackCommands.join(', ');
return {
success: false,
message: `${command} not found. Please ensure it is in your system's PATH.`,
message: `Antigravity CLI not found. Please ensure one of these commands is in your system's PATH: ${supportedCommands}.`,
};
}
-2
View File
@@ -111,7 +111,6 @@ export class MCPOAuthProvider {
scope: config.scopes?.join(' ') || '',
};
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(registrationUrl, {
method: 'POST',
headers: {
@@ -301,7 +300,6 @@ export class MCPOAuthProvider {
? { Accept: 'text/event-stream' }
: { Accept: 'application/json' };
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(mcpServerUrl, {
method: 'HEAD',
headers,
-2
View File
@@ -97,7 +97,6 @@ export class OAuthUtils {
resourceMetadataUrl: string,
): Promise<OAuthProtectedResourceMetadata | null> {
try {
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(resourceMetadataUrl);
if (!response.ok) {
return null;
@@ -122,7 +121,6 @@ export class OAuthUtils {
authServerMetadataUrl: string,
): Promise<OAuthAuthorizationServerMetadata | null> {
try {
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
const response = await fetch(authServerMetadataUrl);
if (!response.ok) {
return null;
+7 -4
View File
@@ -669,10 +669,13 @@ export function createPolicyUpdater(
if (message.mcpName) {
newRule.mcpName = message.mcpName;
// Extract simple tool name
newRule.toolName = toolName.startsWith(`${message.mcpName}__`)
? toolName.slice(message.mcpName.length + 2)
: toolName;
const expectedPrefix = `${MCP_TOOL_PREFIX}${message.mcpName}_`;
if (toolName.startsWith(expectedPrefix)) {
newRule.toolName = toolName.slice(expectedPrefix.length);
} else {
newRule.toolName = toolName;
}
} else {
newRule.toolName = toolName;
}
@@ -17,6 +17,7 @@ import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { MockTool } from '../test-utils/mock-tool.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('../tools/memoryTool.js', async (importOriginal) => {
const actual = await importOriginal();
@@ -38,11 +39,20 @@ describe('PromptProvider', () => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
const mockToolRegistry = {
getAllToolNames: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
};
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
}),
get config() {
return this as unknown as Config;
},
get toolRegistry() {
return (
this as { getToolRegistry: () => ToolRegistry }
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
+30 -25
View File
@@ -7,7 +7,6 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import type { Config } from '../config/config.js';
import type { HierarchicalMemory } from '../config/memory.js';
import { GEMINI_DIR } from '../utils/paths.js';
import { ApprovalMode } from '../policy/types.js';
@@ -31,6 +30,7 @@ import {
import { resolveModel, supportsModernFeatures } from '../config/models.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
/**
* Orchestrates prompt generation by gathering context and building options.
@@ -40,7 +40,7 @@ export class PromptProvider {
* Generates the core system prompt.
*/
getCoreSystemPrompt(
config: Config,
context: AgentLoopContext,
userMemory?: string | HierarchicalMemory,
interactiveOverride?: boolean,
): string {
@@ -48,27 +48,29 @@ export class PromptProvider {
process.env['GEMINI_SYSTEM_MD'],
);
const interactiveMode = interactiveOverride ?? config.isInteractive();
const approvalMode = config.getApprovalMode?.() ?? ApprovalMode.DEFAULT;
const interactiveMode =
interactiveOverride ?? context.config.isInteractive();
const approvalMode =
context.config.getApprovalMode?.() ?? ApprovalMode.DEFAULT;
const isPlanMode = approvalMode === ApprovalMode.PLAN;
const isYoloMode = approvalMode === ApprovalMode.YOLO;
const skills = config.getSkillManager().getSkills();
const toolNames = config.getToolRegistry().getAllToolNames();
const skills = context.config.getSkillManager().getSkills();
const toolNames = context.toolRegistry.getAllToolNames();
const enabledToolNames = new Set(toolNames);
const approvedPlanPath = config.getApprovedPlanPath();
const approvedPlanPath = context.config.getApprovedPlanPath();
const desiredModel = resolveModel(
config.getActiveModel(),
config.getGemini31LaunchedSync?.() ?? false,
context.config.getActiveModel(),
context.config.getGemini31LaunchedSync?.() ?? false,
);
const isModernModel = supportsModernFeatures(desiredModel);
const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = config.getToolRegistry().getAllTools();
const allTools = context.toolRegistry.getAllTools();
planModeToolsList = allTools
.map((t) => {
if (t instanceof DiscoveredMCPTool) {
@@ -100,7 +102,7 @@ export class PromptProvider {
);
basePrompt = applySubstitutions(
basePrompt,
config,
context.config,
skillsPrompt,
isModernModel,
);
@@ -124,7 +126,7 @@ export class PromptProvider {
contextFilenames,
})),
subAgents: this.withSection('agentContexts', () =>
config
context.config
.getAgentRegistry()
.getAllDefinitions()
.map((d) => ({
@@ -159,7 +161,7 @@ export class PromptProvider {
approvedPlan: approvedPlanPath
? { path: approvedPlanPath }
: undefined,
taskTracker: config.isTrackerEnabled(),
taskTracker: context.config.isTrackerEnabled(),
}),
!isPlanMode,
),
@@ -167,19 +169,20 @@ export class PromptProvider {
'planningWorkflow',
() => ({
planModeToolsList,
plansDir: config.storage.getPlansDir(),
approvedPlanPath: config.getApprovedPlanPath(),
taskTracker: config.isTrackerEnabled(),
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
taskTracker: context.config.isTrackerEnabled(),
}),
isPlanMode,
),
taskTracker: config.isTrackerEnabled(),
taskTracker: context.config.isTrackerEnabled(),
operationalGuidelines: this.withSection(
'operationalGuidelines',
() => ({
interactive: interactiveMode,
enableShellEfficiency: config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: config.isInteractiveShellEnabled(),
enableShellEfficiency:
context.config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: context.config.isInteractiveShellEnabled(),
}),
),
sandbox: this.withSection('sandbox', () => getSandboxMode()),
@@ -227,14 +230,16 @@ export class PromptProvider {
return sanitizedPrompt;
}
getCompressionPrompt(config: Config): string {
getCompressionPrompt(context: AgentLoopContext): string {
const desiredModel = resolveModel(
config.getActiveModel(),
config.getGemini31LaunchedSync?.() ?? false,
context.config.getActiveModel(),
context.config.getGemini31LaunchedSync?.() ?? false,
);
const isModernModel = supportsModernFeatures(desiredModel);
const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets;
return activeSnippets.getCompressionPrompt(config.getApprovedPlanPath());
return activeSnippets.getCompressionPrompt(
context.config.getApprovedPlanPath(),
);
}
private withSection<T>(
+12 -4
View File
@@ -11,6 +11,7 @@ import {
applySubstitutions,
} from './utils.js';
import type { Config } from '../config/config.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('../utils/paths.js', () => ({
homedir: vi.fn().mockReturnValue('/mock/home'),
@@ -208,6 +209,13 @@ describe('applySubstitutions', () => {
beforeEach(() => {
mockConfig = {
get config() {
return this;
},
toolRegistry: {
getAllToolNames: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
},
getAgentRegistry: vi.fn().mockReturnValue({
getAllDefinitions: vi.fn().mockReturnValue([]),
}),
@@ -256,10 +264,10 @@ describe('applySubstitutions', () => {
});
it('should replace ${AvailableTools} with tool names list', () => {
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry = {
getAllToolNames: vi.fn().mockReturnValue(['read_file', 'write_file']),
getAllTools: vi.fn().mockReturnValue([]),
} as unknown as ReturnType<Config['getToolRegistry']>);
} as unknown as ToolRegistry;
const result = applySubstitutions(
'Tools: ${AvailableTools}',
@@ -280,10 +288,10 @@ describe('applySubstitutions', () => {
});
it('should replace tool-specific ${toolName_ToolName} variables', () => {
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry = {
getAllToolNames: vi.fn().mockReturnValue(['read_file']),
getAllTools: vi.fn().mockReturnValue([]),
} as unknown as ReturnType<Config['getToolRegistry']>);
} as unknown as ToolRegistry;
const result = applySubstitutions(
'Use ${read_file_ToolName} to read',
+4 -4
View File
@@ -8,9 +8,9 @@ import path from 'node:path';
import process from 'node:process';
import { homedir } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { Config } from '../config/config.js';
import * as snippets from './snippets.js';
import * as legacySnippets from './snippets.legacy.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export type ResolvedPath = {
isSwitch: boolean;
@@ -63,7 +63,7 @@ export function resolvePathFromEnv(envVar?: string): ResolvedPath {
*/
export function applySubstitutions(
prompt: string,
config: Config,
context: AgentLoopContext,
skillsPrompt: string,
isGemini3: boolean = false,
): string {
@@ -73,7 +73,7 @@ export function applySubstitutions(
const activeSnippets = isGemini3 ? snippets : legacySnippets;
const subAgentsContent = activeSnippets.renderSubAgents(
config
context.config
.getAgentRegistry()
.getAllDefinitions()
.map((d) => ({
@@ -84,7 +84,7 @@ export function applySubstitutions(
result = result.replace(/\${SubAgents}/g, subAgentsContent);
const toolRegistry = config.getToolRegistry();
const toolRegistry = context.toolRegistry;
const allToolNames = toolRegistry.getAllToolNames();
const availableToolsList =
allToolNames.length > 0
@@ -36,7 +36,7 @@ export class ApprovalModeStrategy implements RoutingStrategy {
const model = context.requestedModel ?? config.getModel();
// This strategy only applies to "auto" models.
if (!isAutoModel(model)) {
if (!isAutoModel(model, config)) {
return null;
}
@@ -139,7 +139,7 @@ export class ClassifierStrategy implements RoutingStrategy {
const model = context.requestedModel ?? config.getModel();
if (
(await config.getNumericalRoutingEnabled()) &&
isGemini3Model(model)
isGemini3Model(model, config)
) {
return null;
}
@@ -109,7 +109,7 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
return null;
}
if (!isGemini3Model(model)) {
if (!isGemini3Model(model, config)) {
return null;
}
@@ -29,7 +29,7 @@ export class OverrideStrategy implements RoutingStrategy {
const overrideModel = context.requestedModel ?? config.getModel();
// If the model is 'auto' we should pass to the next strategy.
if (isAutoModel(overrideModel)) {
if (isAutoModel(overrideModel, config)) {
return null;
}
@@ -36,12 +36,15 @@ describe('ConsecaSafetyChecker', () => {
checker = ConsecaSafetyChecker.getInstance();
mockConfig = {
get config() {
return this;
},
enableConseca: true,
getToolRegistry: vi.fn().mockReturnValue({
getFunctionDeclarations: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
checker.setConfig(mockConfig);
checker.setContext(mockConfig);
vi.clearAllMocks();
// Default mock implementations
@@ -72,9 +75,12 @@ describe('ConsecaSafetyChecker', () => {
it('should return ALLOW if enableConseca is false', async () => {
const disabledConfig = {
get config() {
return this;
},
enableConseca: false,
} as unknown as Config;
checker.setConfig(disabledConfig);
checker.setContext(disabledConfig);
const input: SafetyCheckInput = {
protocolVersion: '1.0.0',
+10 -9
View File
@@ -23,12 +23,13 @@ import type { Config } from '../../config/config.js';
import { generatePolicy } from './policy-generator.js';
import { enforcePolicy } from './policy-enforcer.js';
import type { SecurityPolicy } from './types.js';
import type { AgentLoopContext } from '../../config/agent-loop-context.js';
export class ConsecaSafetyChecker implements InProcessChecker {
private static instance: ConsecaSafetyChecker | undefined;
private currentPolicy: SecurityPolicy | null = null;
private activeUserPrompt: string | null = null;
private config: Config | null = null;
private context: AgentLoopContext | null = null;
/**
* Private constructor to enforce singleton pattern.
@@ -50,8 +51,8 @@ export class ConsecaSafetyChecker implements InProcessChecker {
ConsecaSafetyChecker.instance = undefined;
}
setConfig(config: Config): void {
this.config = config;
setContext(context: AgentLoopContext): void {
this.context = context;
}
async check(input: SafetyCheckInput): Promise<SafetyCheckResult> {
@@ -59,7 +60,7 @@ export class ConsecaSafetyChecker implements InProcessChecker {
`[Conseca] check called. History is: ${JSON.stringify(input.context.history)}`,
);
if (!this.config) {
if (!this.context) {
debugLogger.debug('[Conseca] check failed: Config not initialized');
return {
decision: SafetyCheckDecision.ALLOW,
@@ -67,7 +68,7 @@ export class ConsecaSafetyChecker implements InProcessChecker {
};
}
if (!this.config.enableConseca) {
if (!this.context.config.enableConseca) {
debugLogger.debug('[Conseca] check skipped: Conseca is not enabled.');
return {
decision: SafetyCheckDecision.ALLOW,
@@ -78,14 +79,14 @@ export class ConsecaSafetyChecker implements InProcessChecker {
const userPrompt = this.extractUserPrompt(input);
let trustedContent = '';
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
if (toolRegistry) {
const tools = toolRegistry.getFunctionDeclarations();
trustedContent = JSON.stringify(tools, null, 2);
}
if (userPrompt) {
await this.getPolicy(userPrompt, trustedContent, this.config);
await this.getPolicy(userPrompt, trustedContent, this.context.config);
} else {
debugLogger.debug(
`[Conseca] Skipping policy generation because userPrompt is null`,
@@ -104,12 +105,12 @@ export class ConsecaSafetyChecker implements InProcessChecker {
result = await enforcePolicy(
this.currentPolicy,
input.toolCall,
this.config,
this.context.config,
);
}
logConsecaVerdict(
this.config,
this.context.config,
new ConsecaVerdictEvent(
userPrompt || '',
JSON.stringify(this.currentPolicy || {}),
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContextBuilder } from './context-builder.js';
import type { Config } from '../config/config.js';
import type { Content, FunctionCall } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
describe('ContextBuilder', () => {
let contextBuilder: ContextBuilder;
@@ -20,15 +21,20 @@ describe('ContextBuilder', () => {
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
mockHistory = [];
const mockGeminiClient = {
getHistory: vi.fn().mockImplementation(() => mockHistory),
};
mockConfig = {
get config() {
return this as unknown as Config;
},
geminiClient: mockGeminiClient as unknown as GeminiClient,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: vi.fn().mockReturnValue(mockWorkspaces),
}),
getQuestion: vi.fn().mockReturnValue('mock question'),
getGeminiClient: vi.fn().mockReturnValue({
getHistory: vi.fn().mockImplementation(() => mockHistory),
}),
};
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
} as Partial<Config>;
contextBuilder = new ContextBuilder(mockConfig as unknown as Config);
});
+5 -5
View File
@@ -5,21 +5,21 @@
*/
import type { SafetyCheckInput, ConversationTurn } from './protocol.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { Content, FunctionCall } from '@google/genai';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
/**
* Builds context objects for safety checkers, ensuring sensitive data is filtered.
*/
export class ContextBuilder {
constructor(private readonly config: Config) {}
constructor(private readonly context: AgentLoopContext) {}
/**
* Builds the full context object with all available data.
*/
buildFullContext(): SafetyCheckInput['context'] {
const clientHistory = this.config.getGeminiClient()?.getHistory() || [];
const clientHistory = this.context.geminiClient?.getHistory() || [];
const history = this.convertHistoryToTurns(clientHistory);
debugLogger.debug(
@@ -29,7 +29,7 @@ export class ContextBuilder {
// ContextBuilder's responsibility is to provide the *current* context.
// If the conversation hasn't started (history is empty), we check if there's a pending question.
// However, if the history is NOT empty, we trust it reflects the true state.
const currentQuestion = this.config.getQuestion();
const currentQuestion = this.context.config.getQuestion();
if (currentQuestion && history.length === 0) {
history.push({
user: {
@@ -43,7 +43,7 @@ export class ContextBuilder {
environment: {
cwd: process.cwd(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
workspaces: this.config
workspaces: this.context.config
.getWorkspaceContext()
.getDirectories() as string[],
},
+25 -6
View File
@@ -227,6 +227,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
@@ -254,6 +255,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -286,6 +288,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysAndSave,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -324,6 +327,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -362,12 +366,13 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysServer,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'my-server__*',
toolName: 'mcp_my-server_*',
mcpName: 'my-server',
persist: false,
}),
@@ -393,6 +398,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedOnce,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
@@ -418,6 +424,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.Cancel,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
@@ -442,6 +449,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ModifyWithEditor,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
@@ -474,6 +482,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysTool,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -513,6 +522,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -554,6 +564,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysAndSave,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -585,8 +596,8 @@ describe('policy.ts', () => {
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -615,8 +626,8 @@ describe('policy.ts', () => {
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -653,8 +664,8 @@ describe('policy.ts', () => {
details,
{
config: mockConfig,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -777,7 +788,11 @@ describe('Plan Mode Denial Consistency', () => {
if (enableEventDrivenScheduler) {
const scheduler = new Scheduler({
context: mockConfig,
context: {
config: mockConfig,
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as AgentLoopContext,
getPreferredEditor: () => undefined,
schedulerId: ROOT_SCHEDULER_ID,
});
@@ -793,7 +808,11 @@ describe('Plan Mode Denial Consistency', () => {
} else {
let capturedCalls: CompletedToolCall[] = [];
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: {
config: mockConfig,
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as AgentLoopContext,
getPreferredEditor: () => undefined,
onAllToolCallsComplete: async (calls) => {
capturedCalls = calls;
+11 -11
View File
@@ -25,7 +25,7 @@ import {
} from '../tools/tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { makeRelative } from '../utils/paths.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { DiscoveredMCPTool, formatMcpToolName } from '../tools/mcp-tool.js';
import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import type { ValidatingToolCall } from './types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
@@ -114,13 +114,12 @@ export async function updatePolicy(
outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined,
context: AgentLoopContext,
messageBus: MessageBus,
toolInvocation?: AnyToolInvocation,
): Promise<void> {
const deps = { ...context, toolInvocation };
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
deps.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
context.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
return;
}
@@ -129,8 +128,9 @@ export async function updatePolicy(
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
// If folder is trusted and workspace policies are enabled, we prefer workspace scope.
if (
deps.config.isTrustedFolder() &&
deps.config.getWorkspacePoliciesDir() !== undefined
context.config &&
context.config.isTrustedFolder() &&
context.config.getWorkspacePoliciesDir() !== undefined
) {
persistScope = 'workspace';
} else {
@@ -144,7 +144,7 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.messageBus,
messageBus,
persistScope,
);
return;
@@ -155,10 +155,10 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.messageBus,
messageBus,
persistScope,
deps.toolInvocation,
deps.config,
toolInvocation,
context.config,
);
}
@@ -247,7 +247,7 @@ async function handleMcpPolicyUpdate(
// If "Always allow all tools from this server", use the wildcard pattern
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
toolName = `${confirmationDetails.serverName}__*`;
toolName = formatMcpToolName(confirmationDetails.serverName, '*');
}
await messageBus.publish({
@@ -845,6 +845,7 @@ describe('Scheduler (Orchestrator)', () => {
resolution.lastDetails,
mockConfig,
expect.anything(),
expect.anything(),
);
expect(mockExecutor.execute).toHaveBeenCalled();
+1
View File
@@ -623,6 +623,7 @@ export class Scheduler {
outcome,
lastDetails,
this.context,
this.messageBus,
toolCall.invocation,
);
}
@@ -172,6 +172,9 @@ describe('ChatCompressionService', () => {
} as unknown as GenerateContentResponse);
mockConfig = {
get config() {
return this;
},
getCompressionThreshold: vi.fn(),
getBaseLlmClient: vi.fn().mockReturnValue({
generateContent: mockGenerateContent,
@@ -43,6 +43,13 @@ describe('ChatRecordingService', () => {
);
mockConfig = {
get config() {
return this;
},
toolRegistry: {
getTool: vi.fn(),
},
promptId: 'test-session-id',
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
storage: {
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config } from '../config/config.js';
import { type Status } from '../core/coreToolScheduler.js';
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
import { getProjectHash } from '../utils/paths.js';
@@ -20,6 +19,7 @@ import type {
} from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js';
import type { ToolResultDisplay } from '../tools/tools.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export const SESSION_FILE_PREFIX = 'session-';
@@ -134,12 +134,12 @@ export class ChatRecordingService {
private kind?: 'main' | 'subagent';
private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = [];
private queuedTokens: TokensSummary | null = null;
private config: Config;
private context: AgentLoopContext;
constructor(config: Config) {
this.config = config;
this.sessionId = config.getSessionId();
this.projectHash = getProjectHash(config.getProjectRoot());
constructor(context: AgentLoopContext) {
this.context = context;
this.sessionId = context.promptId;
this.projectHash = getProjectHash(context.config.getProjectRoot());
}
/**
@@ -171,9 +171,9 @@ export class ChatRecordingService {
this.cachedConversation = null;
} else {
// Create new session
this.sessionId = this.config.getSessionId();
this.sessionId = this.context.promptId;
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
this.context.config.storage.getProjectTempDir(),
'chats',
);
fs.mkdirSync(chatsDir, { recursive: true });
@@ -341,7 +341,7 @@ export class ChatRecordingService {
if (!this.conversationFile) return;
// Enrich tool calls with metadata from the ToolRegistry
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
const enrichedToolCalls = toolCalls.map((toolCall) => {
const toolInstance = toolRegistry.getTool(toolCall.name);
return {
@@ -594,7 +594,7 @@ export class ChatRecordingService {
*/
deleteSession(sessionId: string): void {
try {
const tempDir = this.config.storage.getProjectTempDir();
const tempDir = this.context.config.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const sessionPath = path.join(chatsDir, `${sessionId}.json`);
if (fs.existsSync(sessionPath)) {
@@ -36,6 +36,9 @@ describe('LoopDetectionService', () => {
beforeEach(() => {
mockConfig = {
get config() {
return this;
},
getTelemetryEnabled: () => true,
isInteractive: () => false,
getDisableLoopDetection: () => false,
@@ -806,7 +809,13 @@ describe('LoopDetectionService LLM Checks', () => {
vi.mocked(mockAvailability.snapshot).mockReturnValue({ available: true });
mockConfig = {
get config() {
return this;
},
getGeminiClient: () => mockGeminiClient,
get geminiClient() {
return mockGeminiClient;
},
getBaseLlmClient: () => mockBaseLlmClient,
getDisableLoopDetection: () => false,
getDebugMode: () => false,
@@ -19,12 +19,12 @@ import {
LlmLoopCheckEvent,
LlmRole,
} from '../telemetry/types.js';
import type { Config } from '../config/config.js';
import {
isFunctionCall,
isFunctionResponse,
} from '../utils/messageInspectors.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
const TOOL_CALL_LOOP_THRESHOLD = 5;
const CONTENT_LOOP_THRESHOLD = 10;
@@ -131,7 +131,7 @@ export interface LoopDetectionResult {
* Monitors tool call repetitions and content sentence repetitions.
*/
export class LoopDetectionService {
private readonly config: Config;
private readonly context: AgentLoopContext;
private promptId = '';
private userPrompt = '';
@@ -157,8 +157,8 @@ export class LoopDetectionService {
// Session-level disable flag
private disabledForSession = false;
constructor(config: Config) {
this.config = config;
constructor(context: AgentLoopContext) {
this.context = context;
}
/**
@@ -167,7 +167,7 @@ export class LoopDetectionService {
disableForSession(): void {
this.disabledForSession = true;
logLoopDetectionDisabled(
this.config,
this.context.config,
new LoopDetectionDisabledEvent(this.promptId),
);
}
@@ -184,7 +184,10 @@ export class LoopDetectionService {
* @returns A LoopDetectionResult
*/
addAndCheck(event: ServerGeminiStreamEvent): LoopDetectionResult {
if (this.disabledForSession || this.config.getDisableLoopDetection()) {
if (
this.disabledForSession ||
this.context.config.getDisableLoopDetection()
) {
return { count: 0 };
}
if (this.loopDetected) {
@@ -228,7 +231,7 @@ export class LoopDetectionService {
: LoopType.CONTENT_CHANTING_LOOP;
logLoopDetected(
this.config,
this.context.config,
new LoopDetectedEvent(
this.lastLoopType,
this.promptId,
@@ -256,7 +259,10 @@ export class LoopDetectionService {
* @returns A promise that resolves to a LoopDetectionResult.
*/
async turnStarted(signal: AbortSignal): Promise<LoopDetectionResult> {
if (this.disabledForSession || this.config.getDisableLoopDetection()) {
if (
this.disabledForSession ||
this.context.config.getDisableLoopDetection()
) {
return { count: 0 };
}
if (this.loopDetected) {
@@ -283,7 +289,7 @@ export class LoopDetectionService {
this.lastLoopType = LoopType.LLM_DETECTED_LOOP;
logLoopDetected(
this.config,
this.context.config,
new LoopDetectedEvent(
this.lastLoopType,
this.promptId,
@@ -536,8 +542,7 @@ export class LoopDetectionService {
analysis?: string;
confirmedByModel?: string;
}> {
const recentHistory = this.config
.getGeminiClient()
const recentHistory = this.context.geminiClient
.getHistory()
.slice(-LLM_LOOP_CHECK_HISTORY_COUNT);
@@ -590,13 +595,13 @@ export class LoopDetectionService {
: '';
const doubleCheckModelName =
this.config.modelConfigService.getResolvedConfig({
this.context.config.modelConfigService.getResolvedConfig({
model: DOUBLE_CHECK_MODEL_ALIAS,
}).model;
if (flashConfidence < LLM_CONFIDENCE_THRESHOLD) {
logLlmLoopCheck(
this.config,
this.context.config,
new LlmLoopCheckEvent(
this.promptId,
flashConfidence,
@@ -608,12 +613,13 @@ export class LoopDetectionService {
return { isLoop: false };
}
const availability = this.config.getModelAvailabilityService();
const availability = this.context.config.getModelAvailabilityService();
if (!availability.snapshot(doubleCheckModelName).available) {
const flashModelName = this.config.modelConfigService.getResolvedConfig({
model: 'loop-detection',
}).model;
const flashModelName =
this.context.config.modelConfigService.getResolvedConfig({
model: 'loop-detection',
}).model;
return {
isLoop: true,
analysis: flashAnalysis,
@@ -642,7 +648,7 @@ export class LoopDetectionService {
: undefined;
logLlmLoopCheck(
this.config,
this.context.config,
new LlmLoopCheckEvent(
this.promptId,
flashConfidence,
@@ -672,7 +678,7 @@ export class LoopDetectionService {
signal: AbortSignal,
): Promise<Record<string, unknown> | null> {
try {
const result = await this.config.getBaseLlmClient().generateJson({
const result = await this.context.config.getBaseLlmClient().generateJson({
modelConfigKey: { model },
contents,
schema: LOOP_DETECTION_SCHEMA,
@@ -692,7 +698,7 @@ export class LoopDetectionService {
}
return null;
} catch (error) {
if (this.config.getDebugMode()) {
if (this.context.config.getDebugMode()) {
debugLogger.warn(
`Error querying loop detection model (${model}): ${String(error)}`,
);
@@ -51,11 +51,34 @@ export interface ModelConfigAlias {
modelConfig: ModelConfig;
}
// A model definition is a mapping from a model name to a list of features
// that the model supports. Model names can be either direct model IDs
// (gemini-2.5-pro) or aliases (auto).
export interface ModelDefinition {
displayName?: string;
tier?: string; // 'pro' | 'flash' | 'flash-lite' | 'custom' | 'auto'
family?: string; // The gemini family, e.g. 'gemini-3' | 'gemini-2'
isPreview?: boolean;
// Specifies which view the model should appear in. If unset, the model will
// not appear in the dialog.
dialogLocation?: 'main' | 'manual';
/** A short description of the model for the dialog. */
dialogDescription?: string;
features?: {
// Whether the model supports thinking.
thinking?: boolean;
// Whether the model supports mutlimodal function responses. This is
// supported in Gemini 3.
multimodalToolUse?: boolean;
};
}
export interface ModelConfigServiceConfig {
aliases?: Record<string, ModelConfigAlias>;
customAliases?: Record<string, ModelConfigAlias>;
overrides?: ModelConfigOverride[];
customOverrides?: ModelConfigOverride[];
modelDefinitions?: Record<string, ModelDefinition>;
}
const MAX_ALIAS_CHAIN_DEPTH = 100;
@@ -76,6 +99,28 @@ export class ModelConfigService {
// TODO(12597): Process config to build a typed alias hierarchy.
constructor(private readonly config: ModelConfigServiceConfig) {}
getModelDefinition(modelId: string): ModelDefinition | undefined {
const definition = this.config.modelDefinitions?.[modelId];
if (definition) {
return definition;
}
// For unknown models, return an implicit custom definition to match legacy behavior.
if (!modelId.startsWith('gemini-')) {
return {
tier: 'custom',
family: 'custom',
features: {},
};
}
return undefined;
}
getModelDefinitions(): Record<string, ModelDefinition> {
return this.config.modelDefinitions ?? {};
}
registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void {
this.runtimeAliases[aliasName] = alias;
}

Some files were not shown because too many files have changed in this diff Show More