Compare commits

...

23 Commits

Author SHA1 Message Date
Abhi 061a986f09 addressing christian's feedback 2026-02-23 22:18:29 -05:00
Abhi 805a83de3f feat(core): enhance AgentSession with system instruction support and internal cancellation 2026-02-23 21:08:06 -05:00
Abhi b826256f3e feat(core): introduce Agent and AgentSession v1 with ReAct loop and event streaming 2026-02-23 21:08:06 -05:00
Yuki Okita 05bc0399f3 feat(cli): allow expanding full details of MCP tool on approval (#19916) 2026-02-24 01:45:05 +00:00
Jagjeevan Kashid 3409de774c feat:PR-rate-limit (#19804)
Signed-off-by: Jagjeevan Kashid <jagjeevandev97@gmail.com>
Co-authored-by: kevinjwang1 <kevinjwang@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Christian Gunderman <gundermanc@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-24 00:42:07 +00:00
Christian Gunderman 175ffc452b Add 3.1 pro preview to behavioral evals. (#20088) 2026-02-24 00:34:26 +00:00
Tommaso Sciortino 544df749af make windows tests mandatory (#20096) 2026-02-24 00:06:14 +00:00
Christian Gunderman 56c8d7e985 Stabilize tests. (#20095) 2026-02-24 00:01:39 +00:00
kevinjwang1 2ff7738b5d Add new setting to configure maxRetries (#20064)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-23 23:57:16 +00:00
Tommaso Sciortino 1ad26adb2b fix critical dep vulnerability (#20087) 2026-02-23 23:36:35 +00:00
nityam af5aec69da Fix: Handle corrupted token file gracefully when switching auth types (#19845) (#19850) 2026-02-23 23:15:54 +00:00
nityam dae67983a8 fix(a2a-server): Remove unsafe type assertions in agent (#19723) 2026-02-23 22:40:55 +00:00
Zafeer Mahmood 70856d5a6e fix(scripts): Add Windows (win32/x64) support to lint.js (#16193)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-23 22:36:23 +00:00
Abhijit Balaji cec45a1ebc fix(cli): skip workspace policy loading when in home directory (#20054) 2026-02-23 22:08:56 +00:00
Adam Weidman 767d80e768 fix(core): prevent utility calls from changing session active model (#20035) 2026-02-23 21:54:02 +00:00
Jerop Kipruto 3e5e608a22 feat(policy): Implement Tool Annotation Matching in Policy Engine (#20029) 2026-02-23 21:39:40 +00:00
Gal Zahavi 0bc2d3ab16 fix(core): allow environment variable expansion and explicit overrides for MCP servers (#18837) 2026-02-23 21:35:01 +00:00
Aviral Garg 31960c3388 fix(sandbox): harden image packaging integrity checks (#19552) 2026-02-23 21:02:42 +00:00
Sandy Tao 0cc4f09595 feat(core): replace expected_replacements with allow_multiple in replace tool (#20033) 2026-02-23 19:53:58 +00:00
Michael Bleigh 70336e73b1 feat(core): implement experimental direct web fetch (#19557) 2026-02-23 19:50:14 +00:00
Aishanee Shah 7cfbb6fb71 feat(core): optimize tool descriptions and schemas for Gemini 3 (#19643) 2026-02-23 19:27:35 +00:00
Mehmet Gok a105768de8 docs(CONTRIBUTING): update React DevTools version to 6 (#20014)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-23 19:17:04 +00:00
Jerop Kipruto 347f3fe7e4 feat(policy): Support MCP Server Wildcards in Policy Engine (#20024) 2026-02-23 19:07:06 +00:00
83 changed files with 4135 additions and 917 deletions
+8
View File
@@ -77,6 +77,14 @@ runs:
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
--output-file final_image_uri.txt
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
- name: 'verify'
shell: 'bash'
run: |-
docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
set -e
node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));"
/usr/local/share/npm-global/bin/gemini --version >/dev/null
'
- name: 'publish'
shell: 'bash'
if: "${{ inputs.dry-run != 'true' }}"
+1 -1
View File
@@ -22,7 +22,7 @@ get_issue_labels() {
# Check cache
case "${ISSUE_LABELS_CACHE_FLAT}" in
*"|${ISSUE_NUM}:"*)
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
echo "${suffix%%|*}"
return
;;
+2 -2
View File
@@ -224,8 +224,6 @@ jobs:
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
continue-on-error: true
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -315,6 +313,7 @@ jobs:
needs:
- 'e2e_linux'
- 'e2e_mac'
- 'e2e_windows'
- 'evals'
- 'merge_queue_skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -323,6 +322,7 @@ jobs:
run: |
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
${{ needs.e2e_mac.result }} != 'success' || \
${{ needs.e2e_windows.result }} != 'success' || \
${{ needs.evals.result }} != 'success' ]]; then
echo "One or more E2E jobs failed."
exit 1
+2 -1
View File
@@ -360,7 +360,6 @@ jobs:
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
@@ -458,6 +457,7 @@ jobs:
- 'link_checker'
- 'test_linux'
- 'test_mac'
- 'test_windows'
- 'codeql'
- 'bundle_size'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -468,6 +468,7 @@ jobs:
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
(${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
echo "One or more CI jobs failed."
+1
View File
@@ -27,6 +27,7 @@ jobs:
fail-fast: false
matrix:
model:
- 'gemini-3.1-pro-preview-customtools'
- 'gemini-3-pro-preview'
- 'gemini-3-flash-preview'
- 'gemini-2.5-pro'
+31
View File
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'PR rate limiter'
permissions: {}
on:
pull_request_target:
types:
- 'opened'
- 'reopened'
jobs:
limit:
runs-on: 'gemini-cli-ubuntu-16-core'
permissions:
contents: 'read'
pull-requests: 'write'
steps:
- name: 'Limit open pull requests per user'
uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
with:
except-author-associations: |
MEMBER
OWNER
comment-limit: 8
comment: >
You already have 7 pull requests open. Please work on getting
existing PRs merged before opening more.
close-limit: 8
close: true
+5 -6
View File
@@ -372,8 +372,7 @@ specific debug settings.
### React DevTools
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
used for the CLI's interface, is compatible with React DevTools version 4.x.
To debug the CLI's React-based UI, you can use React DevTools.
1. **Start the Gemini CLI in development mode:**
@@ -381,20 +380,20 @@ used for the CLI's interface, is compatible with React DevTools version 4.x.
DEV=true npm start
```
2. **Install and run React DevTools version 4.28.5 (or the latest compatible
4.x version):**
2. **Install and run React DevTools version 6 (which matches the CLI's
`react-devtools-core`):**
You can either install it globally:
```bash
npm install -g react-devtools@4.28.5
npm install -g react-devtools@6
react-devtools
```
Or run it directly using npx:
```bash
npx react-devtools@4.28.5
npx react-devtools@6
```
Your running CLI application should then connect to React DevTools.
+4 -1
View File
@@ -42,7 +42,10 @@ USER node
# install gemini-cli and clean up
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
RUN npm install -g /tmp/gemini-cli.tgz /tmp/gemini-core.tgz \
RUN npm install -g /tmp/gemini-core.tgz \
&& npm install -g /tmp/gemini-cli.tgz \
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
&& gemini --version > /dev/null \
&& npm cache clean --force \
&& rm -f /tmp/gemini-{cli,core}.tgz
+2
View File
@@ -29,6 +29,7 @@ they appear in the UI.
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
@@ -135,6 +136,7 @@ they appear in the UI.
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
### Skills
+10
View File
@@ -142,6 +142,11 @@ their corresponding top-level category object in your `settings.json` file.
request" errors.
- **Default:** `false`
- **`general.maxAttempts`** (number):
- **Description:** Maximum number of attempts for requests to the main chat
model. Cannot exceed 10.
- **Default:** `10`
- **`general.debugKeystrokeLogging`** (boolean):
- **Description:** Enable debug logging of keystrokes to the console.
- **Default:** `false`
@@ -969,6 +974,11 @@ their corresponding top-level category object in your `settings.json` file.
during tool execution.
- **Default:** `false`
- **`experimental.directWebFetch`** (boolean):
- **Description:** Enable web fetch behavior that bypasses LLM summarization.
- **Default:** `false`
- **Requires restart:** Yes
#### `skills`
- **`skills.enabled`** (boolean):
+42 -14
View File
@@ -64,9 +64,11 @@ primary conditions are the tool's name and its arguments.
The `toolName` in the rule must match the name of the tool being called.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
- **Wildcards**: You can use wildcards to match multiple tools.
- `*`: Matches **any tool** (built-in or MCP).
- `server__*`: Matches any tool from a specific MCP server.
- `*__toolName`: Matches a specific tool name across **all** MCP servers.
- `*__*`: Matches **any tool from any MCP server**.
#### Arguments pattern
@@ -144,9 +146,9 @@ A rule matches a tool call if all of its conditions are met:
1. **Tool name**: The `toolName` in the rule must match the name of the tool
being called.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
- **Wildcards**: You can use wildcards like `*`, `server__*`, or
`*__toolName` to match multiple tools. See [Tool Name](#tool-name) for
details.
2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments
are converted to a stable JSON string, which is then tested against the
provided regular expression. If the arguments don't match the pattern, the
@@ -272,13 +274,12 @@ priority = 100
### Special syntax for MCP tools
You can create rules that target tools from Model-hosting-protocol (MCP) servers
using the `mcpName` field or a wildcard pattern.
You can create rules that target tools from Model Context Protocol (MCP) servers
using the `mcpName` field or composite wildcard patterns.
**1. Using `mcpName`**
**1. Targeting a specific tool on a server**
To target a specific tool from a specific server, combine `mcpName` and
`toolName`.
Combine `mcpName` and `toolName` to target a single operation.
```toml
# Allows the `search` tool on the `my-jira-server` MCP
@@ -289,10 +290,10 @@ decision = "allow"
priority = 200
```
**2. Using a wildcard**
**2. Targeting all tools on a specific server**
To create a rule that applies to _all_ tools on a specific MCP server, specify
only the `mcpName`.
Specify only the `mcpName` to apply a rule to every tool provided by that
server.
```toml
# Denies all tools from the `untrusted-server` MCP
@@ -303,6 +304,33 @@ priority = 500
deny_message = "This server is not trusted by the admin."
```
**3. Targeting all MCP servers**
Use `mcpName = "*"` to create a rule that applies to **all** tools from **any**
registered MCP server. This is useful for setting category-wide defaults.
```toml
# Ask user for any tool call from any MCP server
[[rule]]
mcpName = "*"
decision = "ask_user"
priority = 10
```
**4. Targeting a tool name across all servers**
Use `mcpName = "*"` with a specific `toolName` to target that operation
regardless of which server provides it.
```toml
# Allow the `search` tool across all connected MCP servers
[[rule]]
mcpName = "*"
toolName = "search"
decision = "allow"
priority = 50
```
## Default policies
The Gemini CLI ships with a set of default policies to provide a safe
+7 -4
View File
@@ -105,10 +105,11 @@ lines containing matches, along with their file paths and line numbers.
## 6. `replace` (Edit)
`replace` replaces text within a file. By default, replaces a single occurrence,
but can replace multiple occurrences when `expected_replacements` is specified.
This tool is designed for precise, targeted changes and requires significant
context around the `old_string` to ensure it modifies the correct location.
`replace` replaces text within a file. By default, the tool expects to find and
replace exactly ONE occurrence of `old_string`. If you want to replace multiple
occurrences of the exact same string, set `allow_multiple` to `true`. This tool
is designed for precise, targeted changes and requires significant context
around the `old_string` to ensure it modifies the correct location.
- **Tool name:** `replace`
- **Arguments:**
@@ -116,6 +117,8 @@ context around the `old_string` to ensure it modifies the correct location.
- `instruction` (string, required): Semantic description of the change.
- `old_string` (string, required): Exact literal text to find.
- `new_string` (string, required): Exact literal text to replace with.
- `allow_multiple` (boolean, optional): If `true`, replaces all occurrences.
If `false` (default), only succeeds if exactly one occurrence is found.
- **Confirmation:** Requires manual user approval.
## Next steps
+62 -2
View File
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
- **`args`** (string[]): Command-line arguments for Stdio transport
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
- **`env`** (object): Environment variables for the server process. Values can
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
platforms), or `%VAR_NAME%` (Windows only).
- **`cwd`** (string): Working directory for Stdio transport
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
10 minutes)
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
Service Account to impersonate. Used with
`authProviderType: 'service_account_impersonation'`.
### Environment variable expansion
Gemini CLI automatically expands environment variables in the `env` block of
your MCP server configuration. This allows you to securely reference variables
defined in your shell or environment without hardcoding sensitive information
directly in your `settings.json` file.
The expansion utility supports:
- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
all platforms)
- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
If a variable is not defined in the current environment, it resolves to an empty
string.
**Example:**
```json
"env": {
"API_KEY": "$MY_EXTERNAL_TOKEN",
"LOG_LEVEL": "$LOG_LEVEL",
"TEMP_DIR": "%TEMP%"
}
```
### Security and environment sanitization
To protect your credentials, Gemini CLI performs environment sanitization when
spawning MCP server processes.
#### Automatic redaction
By default, the CLI redacts sensitive environment variables from the base
environment (inherited from the host process) to prevent unintended exposure to
third-party MCP servers. This includes:
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
`*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
- Certificates and private key patterns.
#### Explicit overrides
If an environment variable must be passed to an MCP server, you must explicitly
state it in the `env` property of the server configuration in `settings.json`.
Explicitly defined variables (including those from extensions) are trusted and
are **not** subjected to the automatic redaction process.
This follows the security principle that if a variable is explicitly configured
by the user for a specific server, it constitutes informed consent to share that
specific data with that server.
> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
> securely pull the value from your host environment at runtime.
### OAuth support for remote MCP servers
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
@@ -738,7 +796,9 @@ The MCP integration tracks several states:
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
cautiously and only for servers you completely control
- **Access tokens:** Be security-aware when configuring environment variables
containing API keys or tokens
containing API keys or tokens. See
[Security and environment sanitization](#security-and-environment-sanitization)
for details on how Gemini CLI protects your credentials.
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
available within the sandbox environment
- **Private data:** Using broadly scoped personal access tokens can lead to
+13 -12
View File
@@ -78,22 +78,23 @@ describe('Frugal reads eval', () => {
).toBe(true);
let totalLinesRead = 0;
const readRanges: { offset: number; limit: number }[] = [];
const readRanges: { start_line: number; end_line: number }[] = [];
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
args.limit,
'Agent read the entire file (missing limit) instead of using ranged read',
args.end_line,
'Agent read the entire file (missing end_line) instead of using ranged read',
).toBeDefined();
const limit = args.limit;
const offset = args.offset ?? 0;
totalLinesRead += limit;
readRanges.push({ offset, limit });
const end_line = args.end_line;
const start_line = args.start_line ?? 1;
const linesRead = end_line - start_line + 1;
totalLinesRead += linesRead;
readRanges.push({ start_line, end_line });
expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
1001,
);
}
@@ -108,7 +109,7 @@ describe('Frugal reads eval', () => {
const errorLines = [500, 510, 520];
for (const line of errorLines) {
const covered = readRanges.some(
(range) => line >= range.offset && line < range.offset + range.limit,
(range) => line >= range.start_line && line <= range.end_line,
);
expect(covered, `Agent should have read around line ${line}`).toBe(
true,
@@ -191,8 +192,8 @@ describe('Frugal reads eval', () => {
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
args.limit,
'Agent should have used ranged read (limit) to save tokens',
args.end_line,
'Agent should have used ranged read (end_line) to save tokens',
).toBeDefined();
}
},
@@ -253,7 +254,7 @@ describe('Frugal reads eval', () => {
// and just read the whole file to be efficient with tool calls.
const readEntireFile = targetFileReads.some((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.limit === undefined;
return args.end_line === undefined;
});
expect(
+2 -2
View File
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
const args = getParams(call);
return (
args.file_path === 'src/legacy_processor.ts' &&
(args.limit === undefined || args.limit === null)
(args.end_line === undefined || args.end_line === null)
);
});
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
if (
call.toolRequest.name === 'read_file' &&
args.file_path === 'src/legacy_processor.ts' &&
args.limit !== undefined
args.end_line !== undefined
) {
return true;
}
+1 -1
View File
@@ -56,7 +56,7 @@ describe('interactive_commands', () => {
const scaffoldCall = logs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
/npm (init|create)|npx create-|yarn create|pnpm create/.test(
/npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test(
l.toolRequest.args,
),
);
+382 -428
View File
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -29,6 +29,8 @@ import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
getContextIdFromMetadata,
getAgentSettingsFromMetadata,
} from '../types.js';
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
@@ -117,8 +119,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(metadata['_contextId'] as string) || sdkTask.contextId;
getContextIdFromMetadata(metadata) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
contextId,
@@ -141,8 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = agentSettingsInput || ({} as AgentSettings);
const agentSettings: AgentSettings = agentSettingsInput || {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: process.cwd(),
};
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
@@ -292,8 +295,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(sdkTask?.metadata?.['_contextId'] as string) ||
getContextIdFromMetadata(sdkTask?.metadata) ||
uuidv4();
logger.info(
@@ -388,10 +390,7 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = userMessage.metadata?.[
'coderAgent'
] as AgentSettings;
const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
try {
wrapper = await this.createTask(
taskId,
+8 -2
View File
@@ -513,7 +513,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: { onConfirm: onConfirmSpy },
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
},
},
] as unknown as ToolCall[];
@@ -533,7 +536,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: { onConfirm: onConfirmSpy },
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
},
},
] as unknown as ToolCall[];
+99 -41
View File
@@ -59,6 +59,33 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
type UnionKeys<T> = T extends T ? keyof T : never;
type ConfirmationType = ToolCallConfirmationDetails['type'];
const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
'edit',
'exec',
'mcp',
'info',
'ask_user',
'exit_plan_mode',
] as const;
function isToolCallConfirmationDetails(
value: unknown,
): value is ToolCallConfirmationDetails {
if (
typeof value !== 'object' ||
value === null ||
!('onConfirm' in value) ||
typeof value.onConfirm !== 'function' ||
!('type' in value) ||
typeof value.type !== 'string'
) {
return false;
}
return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
}
export class Task {
id: string;
contextId: string;
@@ -376,11 +403,10 @@ export class Task {
}
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
this.pendingToolConfirmationDetails.set(
tc.request.callId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
tc.confirmationDetails as ToolCallConfirmationDetails,
);
const details = tc.confirmationDetails;
if (isToolCallConfirmationDetails(details)) {
this.pendingToolConfirmationDetails.set(tc.request.callId, details);
}
}
// Only send an update if the status has actually changed.
@@ -412,11 +438,12 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
this.pendingToolConfirmationDetails.delete(tc.request.callId);
const details = tc.confirmationDetails;
if (isToolCallConfirmationDetails(details)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
this.pendingToolConfirmationDetails.delete(tc.request.callId);
}
}
});
return;
@@ -466,15 +493,13 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
>(from: T, ...fields: K[]): Partial<T> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const ret = {} as Pick<T, K>;
const ret: Partial<T> = {};
for (const field of fields) {
if (field in from) {
if (field in from && from[field] !== undefined) {
ret[field] = from[field];
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return ret as Partial<T>;
return ret;
}
private toolStatusMessage(
@@ -485,8 +510,11 @@ export class Task {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necessary
// properties/avoid methods causing circular reference errors)
const serializableToolCall: Partial<ToolCall> = this._pickFields(
// properties/avoid methods causing circular reference errors).
// Type allows tool to be Partial<AnyDeclarativeTool> for serialization.
const serializableToolCall: Partial<Omit<ToolCall, 'tool'>> & {
tool?: Partial<AnyDeclarativeTool>;
} = this._pickFields(
tc,
'request',
'status',
@@ -496,8 +524,7 @@ export class Task {
);
if (tc.tool) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
serializableToolCall.tool = this._pickFields(
const toolFields = this._pickFields(
tc.tool,
'name',
'displayName',
@@ -507,7 +534,8 @@ export class Task {
'canUpdateOutput',
'schema',
'parameterSchema',
) as AnyDeclarativeTool;
);
serializableToolCall.tool = toolFields;
}
messageParts.push({
@@ -530,8 +558,15 @@ export class Task {
old_string: string,
new_string: string,
): Promise<string> {
// Validate path to prevent path traversal vulnerabilities
const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
const pathError = this.config.validatePathAccess(resolvedPath, 'read');
if (pathError) {
throw new Error(`Path validation failed: ${pathError}`);
}
try {
const currentContent = await fs.readFile(file_path, 'utf8');
const currentContent = await fs.readFile(resolvedPath, 'utf8');
return this._applyReplacement(
currentContent,
old_string,
@@ -625,15 +660,32 @@ export class Task {
request.args['old_string'] &&
request.args['new_string']
) {
const newContent = await this.getProposedContent(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['file_path'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['old_string'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
request.args['new_string'] as string,
);
return { ...request, args: { ...request.args, newContent } };
const filePath = request.args['file_path'];
const oldString = request.args['old_string'];
const newString = request.args['new_string'];
if (
typeof filePath === 'string' &&
typeof oldString === 'string' &&
typeof newString === 'string'
) {
// Resolve and validate path to prevent path traversal (user-controlled file_path).
const resolvedPath = path.resolve(
this.config.getTargetDir(),
filePath,
);
const pathError = this.config.validatePathAccess(
resolvedPath,
'read',
);
if (!pathError) {
const newContent = await this.getProposedContent(
resolvedPath,
oldString,
newString,
);
return { ...request, args: { ...request.args, newContent } };
}
}
}
return request;
}),
@@ -725,10 +777,17 @@ export class Task {
break;
case GeminiEventType.Error:
default: {
// Block scope for lexical declaration
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
const errorMessage = errorEvent.value?.error
// Use type guard instead of unsafe type assertion
let errorEvent: ServerGeminiErrorEvent | undefined;
if (
event.type === GeminiEventType.Error &&
event.value &&
typeof event.value === 'object' &&
'error' in event.value
) {
errorEvent = event;
}
const errorMessage = errorEvent?.value?.error
? getErrorMessage(errorEvent.value.error)
: 'Unknown error from LLM stream';
logger.error(
@@ -737,7 +796,7 @@ export class Task {
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
if (errorEvent.value?.error) {
if (errorEvent?.value?.error) {
errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
@@ -814,12 +873,11 @@ export class Task {
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const payload = part.data['newContent']
? ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newContent: part.data['newContent'] as string,
} as ToolConfirmationPayload)
: undefined;
const newContent = part.data['newContent'];
const payload =
typeof newContent === 'string'
? ({ newContent } as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
+51 -2
View File
@@ -122,11 +122,60 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
export const METADATA_KEY = '__persistedState';
function isAgentSettings(value: unknown): value is AgentSettings {
return (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
'workspacePath' in value &&
typeof value.workspacePath === 'string'
);
}
function isPersistedStateMetadata(
value: unknown,
): value is PersistedStateMetadata {
return (
typeof value === 'object' &&
value !== null &&
'_agentSettings' in value &&
'_taskState' in value &&
isAgentSettings(value._agentSettings)
);
}
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
const state = metadata?.[METADATA_KEY];
if (isPersistedStateMetadata(state)) {
return state;
}
return undefined;
}
export function getContextIdFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): string | undefined {
if (!metadata) {
return undefined;
}
const contextId = metadata['_contextId'];
return typeof contextId === 'string' ? contextId : undefined;
}
export function getAgentSettingsFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): AgentSettings | undefined {
if (!metadata) {
return undefined;
}
const coderAgent = metadata['coderAgent'];
if (isAgentSettings(coderAgent)) {
return coderAgent;
}
return undefined;
}
export function setPersistedState(
@@ -71,6 +71,7 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
+34
View File
@@ -2016,6 +2016,40 @@ describe('loadCliConfig useRipgrep', () => {
});
});
describe('loadCliConfig directWebFetch', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should be false by default when directWebFetch is not set in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getDirectWebFetch()).toBe(false);
});
it('should be true when directWebFetch is set to true in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
directWebFetch: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getDirectWebFetch()).toBe(true);
});
});
describe('screenReader configuration', () => {
beforeEach(() => {
vi.resetAllMocks();
+2 -1
View File
@@ -826,7 +826,8 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
planSettings: settings.general.plan,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -132,6 +132,35 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.ASK_USER);
});
it('should handle global MCP wildcard (*) in settings', async () => {
const settings: Settings = {
mcp: {
allowed: ['*'],
},
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
);
const engine = new PolicyEngine(config);
// ANY tool with a server name should be allowed
expect(
(await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'another-server__tool' }, 'another-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
// Built-in tools should NOT be allowed by the MCP wildcard
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
const settings: Settings = {
mcp: {
@@ -323,6 +352,38 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.DENY);
});
it('should correctly match tool annotations', async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
);
// Add a manual rule with annotations to the config
config.rules = config.rules || [];
config.rules.push({
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ALLOW,
priority: 10,
});
const engine = new PolicyEngine(config);
// A tool with readOnlyHint=true should be ALLOWED
const roCall = { name: 'some_tool', args: {} };
const roMeta = { readOnlyHint: true };
expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
PolicyDecision.ALLOW,
);
// A tool without the hint (or with false) should follow default decision (ASK_USER)
const rwMeta = { readOnlyHint: false };
expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
PolicyDecision.ASK_USER,
);
});
describe.each(['write_file', 'replace'])(
'Plan Mode policy for %s',
(toolName) => {
+44
View File
@@ -142,4 +142,48 @@ describe('resolveWorkspacePolicyState', () => {
expect.stringContaining('Automatically accepting and loading'),
);
});
it('should not return workspace policies if cwd is the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
// Run from HOME directory (tempDir is mocked as HOME in beforeEach)
const result = await resolveWorkspacePolicyState({
cwd: tempDir,
trustedFolder: true,
interactive: true,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
// Create a symlink to the home directory
const symlinkDir = path.join(
os.tmpdir(),
`gemini-cli-symlink-${Date.now()}`,
);
fs.symlinkSync(tempDir, symlinkDir, 'dir');
try {
// Run from symlink to HOME directory
const result = await resolveWorkspacePolicyState({
cwd: symlinkDir,
trustedFolder: true,
interactive: true,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
} finally {
// Clean up symlink
fs.unlinkSync(symlinkDir);
}
});
});
+9 -3
View File
@@ -67,9 +67,15 @@ export async function resolveWorkspacePolicyState(options: {
| undefined;
if (trustedFolder) {
const potentialWorkspacePoliciesDir = new Storage(
cwd,
).getWorkspacePoliciesDir();
const storage = new Storage(cwd);
// If we are in the home directory (or rather, our target Gemini dir is the global one),
// don't treat it as a workspace to avoid loading global policies twice.
if (storage.isWorkspaceHomeDir()) {
return { workspacePoliciesDir: undefined };
}
const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity(
'workspace',
+41 -7
View File
@@ -79,6 +79,7 @@ import {
import {
FatalConfigError,
GEMINI_DIR,
Storage,
type MCPServerConfig,
} from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -126,6 +127,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const os = await import('node:os');
const pathMod = await import('node:path');
const fsMod = await import('node:fs');
// Helper to resolve paths using the test's mocked environment
const testResolve = (p: string | undefined) => {
if (!p) return '';
try {
// Use the mocked fs.realpathSync if available, otherwise fallback
return fsMod.realpathSync(pathMod.resolve(p));
} catch {
return pathMod.resolve(p);
}
};
// Create a smarter mock for isWorkspaceHomeDir
vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
function (this: Storage) {
const target = testResolve(pathMod.dirname(this.getGeminiDir()));
// Pick up the mocked home directory specifically from the 'os' mock
const home = testResolve(os.homedir());
return actual.normalizePath(target) === actual.normalizePath(home);
},
);
return {
...actual,
coreEvents: mockCoreEvents,
@@ -1491,20 +1516,29 @@ describe('Settings Loading and Merging', () => {
return pStr;
});
// Force the storage check to return true for this specific test
const isWorkspaceHomeDirSpy = vi
.spyOn(Storage.prototype, 'isWorkspaceHomeDir')
.mockReturnValue(true);
(mockFsExistsSync as Mock).mockImplementation(
(p: string) =>
// Only return true for workspace settings path to see if it gets loaded
p === mockWorkspaceSettingsPath,
);
const settings = loadSettings(mockSymlinkDir);
try {
const settings = loadSettings(mockSymlinkDir);
// Verify that even though the file exists, it was NOT loaded because realpath matched home
expect(fs.readFileSync).not.toHaveBeenCalledWith(
mockWorkspaceSettingsPath,
'utf-8',
);
expect(settings.workspace.settings).toEqual({});
// Verify that even though the file exists, it was NOT loaded because realpath matched home
expect(fs.readFileSync).not.toHaveBeenCalledWith(
mockWorkspaceSettingsPath,
'utf-8',
);
expect(settings.workspace.settings).toEqual({});
} finally {
isWorkspaceHomeDirSpy.mockRestore();
}
});
});
+5 -21
View File
@@ -637,24 +637,8 @@ export function loadSettings(
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const resolvedHomeDir = path.resolve(homedir());
let realWorkspaceDir = resolvedWorkspaceDir;
try {
// fs.realpathSync gets the "true" path, resolving any symlinks
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
} catch (_e) {
// This is okay. The path might not exist yet, and that's a valid state.
}
// We expect homedir to always exist and be resolvable.
const realHomeDir = fs.realpathSync(resolvedHomeDir);
const workspaceSettingsPath = new Storage(
workspaceDir,
).getWorkspaceSettingsPath();
const storage = new Storage(workspaceDir);
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
@@ -712,7 +696,7 @@ export function loadSettings(
settings: {} as Settings,
rawJson: undefined,
};
if (realWorkspaceDir !== realHomeDir) {
if (!storage.isWorkspaceHomeDir()) {
workspaceResult = load(workspaceSettingsPath);
}
@@ -800,11 +784,11 @@ export function loadSettings(
readOnly: false,
},
{
path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
settings: workspaceSettings,
originalSettings: workspaceOriginalSettings,
rawJson: workspaceResult.rawJson,
readOnly: realWorkspaceDir === realHomeDir,
readOnly: storage.isWorkspaceHomeDir(),
},
isTrusted,
settingsErrors,
+20
View File
@@ -297,6 +297,16 @@ const SETTINGS_SCHEMA = {
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
},
maxAttempts: {
type: 'number',
label: 'Max Chat Model Attempts',
category: 'General',
requiresRestart: false,
default: 10,
description:
'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
showInDialog: true,
},
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -1693,6 +1703,16 @@ const SETTINGS_SCHEMA = {
'Enable model steering (user hints) to guide the model during tool execution.',
showInDialog: true,
},
directWebFetch: {
type: 'boolean',
label: 'Direct Web Fetch',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable web fetch behavior that bypasses LLM summarization.',
showInDialog: true,
},
},
},
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ Keep chat history undefined │
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -520,4 +520,77 @@ describe('ToolConfirmationMessage', () => {
expect(output).toMatchSnapshot();
unmount();
});
it('should show MCP tool details expand hint for MCP confirmations', async () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
toolArgs: {
url: 'https://www.google.co.jp',
},
toolDescription: 'Navigates browser to a URL.',
toolParameterSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'Destination URL',
},
},
required: ['url'],
},
onConfirm: vi.fn(),
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
expect(output).not.toContain('https://www.google.co.jp');
expect(output).not.toContain('Navigates browser to a URL.');
unmount();
});
it('should omit empty MCP invocation arguments from details', async () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
toolArgs: {},
toolDescription: 'No arguments required.',
onConfirm: vi.fn(),
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MCP Tool Details:');
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
expect(output).not.toContain('Invocation Arguments:');
unmount();
});
});
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo, useCallback } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -29,6 +29,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
@@ -64,6 +65,17 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
}) => {
const { confirm, isDiffingEnabled } = useToolActions();
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
callId: string;
expanded: boolean;
}>({
callId,
expanded: false,
});
const isMcpToolDetailsExpanded =
mcpDetailsExpansionState.callId === callId
? mcpDetailsExpansionState.expanded
: false;
const settings = useSettings();
const allowPermanentApproval =
@@ -86,9 +98,81 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
const mcpToolDetailsText = useMemo(() => {
if (confirmationDetails.type !== 'mcp') {
return null;
}
const detailsLines: string[] = [];
const hasNonEmptyToolArgs =
confirmationDetails.toolArgs !== undefined &&
!(
typeof confirmationDetails.toolArgs === 'object' &&
confirmationDetails.toolArgs !== null &&
Object.keys(confirmationDetails.toolArgs).length === 0
);
if (hasNonEmptyToolArgs) {
let argsText: string;
try {
argsText = stripUnsafeCharacters(
JSON.stringify(confirmationDetails.toolArgs, null, 2),
);
} catch {
argsText = '[unserializable arguments]';
}
detailsLines.push('Invocation Arguments:');
detailsLines.push(argsText);
}
const description = confirmationDetails.toolDescription?.trim();
if (description) {
if (detailsLines.length > 0) {
detailsLines.push('');
}
detailsLines.push('Description:');
detailsLines.push(stripUnsafeCharacters(description));
}
if (confirmationDetails.toolParameterSchema !== undefined) {
let schemaText: string;
try {
schemaText = stripUnsafeCharacters(
JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
);
} catch {
schemaText = '[unserializable schema]';
}
if (detailsLines.length > 0) {
detailsLines.push('');
}
detailsLines.push('Input Schema:');
detailsLines.push(schemaText);
}
if (detailsLines.length === 0) {
return null;
}
return detailsLines.join('\n');
}, [confirmationDetails]);
const hasMcpToolDetails = !!mcpToolDetailsText;
const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
useKeypress(
(key) => {
if (!isFocused) return false;
if (
confirmationDetails.type === 'mcp' &&
hasMcpToolDetails &&
keyMatchers[Command.SHOW_MORE_LINES](key)
) {
setMcpDetailsExpansionState({
callId,
expanded: !isMcpToolDetailsExpanded,
});
return true;
}
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
@@ -100,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
}
return false;
},
{ isActive: isFocused },
{ isActive: isFocused, priority: true },
);
const handleSelect = useCallback(
@@ -504,12 +588,31 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
<Box flexDirection="column">
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
<>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</>
{hasMcpToolDetails && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>MCP Tool Details:</Text>
{isMcpToolDetailsExpanded ? (
<>
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to collapse MCP tool details)
</Text>
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
</>
) : (
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to expand MCP tool details)
</Text>
)}
</Box>
)}
</Box>
);
}
@@ -522,8 +625,17 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
isMcpToolDetailsExpanded,
hasMcpToolDetails,
mcpToolDetailsText,
expandDetailsHintKey,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
? 'bottom'
: 'top';
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -559,7 +671,7 @@ export const ToolConfirmationMessage: React.FC<
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
overflowDirection={bodyOverflowDirection}
>
{bodyContent}
</MaxSizedBox>
-1
View File
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
createKey('l', { ctrl: true }),
],
},
// Shell commands
{
command: Command.REVERSE_SEARCH,
+2
View File
@@ -53,6 +53,8 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
+84
View File
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Agent } from './agent.js';
import { AgentSession } from './session.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { type AgentConfig } from './types.js';
vi.mock('./session.js', () => ({
AgentSession: vi.fn().mockImplementation(() => ({
prompt: vi.fn().mockImplementation(async function* () {
yield { type: 'agent_start', value: { sessionId: 'test-session' } };
yield {
type: 'agent_finish',
value: { sessionId: 'test-session', totalTurns: 1 },
};
}),
})),
}));
describe('Agent', () => {
let mockConfig: ReturnType<typeof makeFakeConfig>;
const agentConfig: AgentConfig = {
name: 'TestAgent',
systemInstruction: 'You are a test agent.',
};
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getSessionId').mockReturnValue('global-session-id');
});
it('should create an AgentSession', () => {
const agent = new Agent(agentConfig, mockConfig);
const session = agent.createSession('custom-session-id');
expect(session).toBeDefined();
expect(AgentSession).toHaveBeenCalledWith(
'custom-session-id',
agentConfig,
mockConfig,
);
});
it('should use global session ID if none provided to createSession', () => {
const agent = new Agent(agentConfig, mockConfig);
agent.createSession();
expect(AgentSession).toHaveBeenCalledWith(
'global-session-id',
agentConfig,
mockConfig,
);
});
it('should pass config and runtime to the session', () => {
const agent = new Agent(agentConfig, mockConfig);
agent.createSession('test-id');
expect(AgentSession).toHaveBeenCalledWith(
'test-id',
agentConfig,
mockConfig,
);
});
it('should prompt through a new session', async () => {
const agent = new Agent(agentConfig, mockConfig);
const events = [];
for await (const event of agent.prompt('Hello')) {
events.push(event);
}
expect(events).toHaveLength(2);
expect(events[0].type).toBe('agent_start');
expect(events[1].type).toBe('agent_finish');
expect(AgentSession).toHaveBeenCalled();
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Part } from '@google/genai';
import { type Config } from '../config/config.js';
import { type AgentEvent, type AgentConfig } from './types.js';
import { AgentSession } from './session.js';
/**
* The Agent class is a factory for creating stateful AgentSessions.
* This represents a configured agent template.
*/
export class Agent {
constructor(
private readonly config: AgentConfig,
private readonly runtime: Config,
) {}
/**
* Creates a new stateful session for interacting with the agent.
*/
createSession(sessionId?: string): AgentSession {
const id = sessionId ?? this.runtime.getSessionId();
return new AgentSession(id, this.config, this.runtime);
}
/**
* Helper to quickly run a single prompt and get the results.
*/
prompt(
input: string | Part[],
sessionId?: string,
signal?: AbortSignal,
): AsyncIterable<AgentEvent> {
const session = this.createSession(sessionId);
return session.prompt(input, signal);
}
}
+18 -5
View File
@@ -546,11 +546,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// === UNIFIED RECOVERY BLOCK ===
// Only attempt recovery if it's a known recoverable reason.
// We don't recover from GOAL (already done) or ABORTED (user cancelled).
if (
terminateReason !== AgentTerminateMode.ERROR &&
terminateReason !== AgentTerminateMode.ABORTED &&
terminateReason !== AgentTerminateMode.GOAL
) {
if (this.isRecoverableReason(terminateReason)) {
const recoveryResult = await this.executeFinalWarningTurn(
chat,
turnCounter, // Use current turnCounter for the recovery attempt
@@ -1256,6 +1252,23 @@ Important Rules:
return null;
}
/**
* Returns true if the agent should attempt a recovery turn for the given reason.
*/
private isRecoverableReason(
reason: AgentTerminateMode,
): reason is
| AgentTerminateMode.TIMEOUT
| AgentTerminateMode.MAX_TURNS
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL {
return (
reason !== AgentTerminateMode.ERROR &&
reason !== AgentTerminateMode.ABORTED &&
reason !== AgentTerminateMode.GOAL &&
reason !== AgentTerminateMode.LOOP
);
}
/** Emits an activity event to the configured callback. */
private emitActivity(
type: SubagentActivityEvent['type'],
+524
View File
@@ -0,0 +1,524 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AgentSession } from './session.js';
import { makeFakeConfig } from '../test-utils/config.js';
import {
type AgentConfig,
AgentTerminateMode,
type AgentEvent,
type AgentFinishEvent,
type ToolSuiteStartEvent,
type ToolCallFinishEvent,
} from './types.js';
import { Scheduler } from '../scheduler/scheduler.js';
import { GeminiEventType, CompressionStatus } from '../core/turn.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import {
MessageBusType,
type ToolCallsUpdateMessage,
} from '../confirmation-bus/types.js';
import {
CoreToolCallStatus,
type ToolCallRequestInfo,
} from '../scheduler/types.js';
import { type ResumedSessionData } from '../services/chatRecordingService.js';
import { type GeminiClient } from '../core/client.js';
vi.mock('../core/client.js');
vi.mock('../scheduler/scheduler.js');
vi.mock('../services/chatCompressionService.js');
describe('AgentSession', () => {
let mockConfig: ReturnType<typeof makeFakeConfig>;
let mockClient: {
sendMessageStream: ReturnType<typeof vi.fn>;
isInitialized: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
getChat: ReturnType<typeof vi.fn>;
getCurrentSequenceModel: ReturnType<typeof vi.fn>;
getHistory: ReturnType<typeof vi.fn>;
resumeChat: ReturnType<typeof vi.fn>;
};
let mockScheduler: {
schedule: ReturnType<typeof vi.fn>;
};
let mockCompressionService: {
compress: ReturnType<typeof vi.fn>;
};
let session: AgentSession;
const agentConfig: AgentConfig = {
name: 'TestAgent',
systemInstruction: 'You are a test agent.',
capabilities: { compression: true },
};
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
mockClient = {
sendMessageStream: vi.fn(),
isInitialized: vi.fn().mockReturnValue(false),
initialize: vi.fn().mockResolvedValue(undefined),
getChat: vi.fn().mockReturnValue({
recordCompletedToolCalls: vi.fn(),
setHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
setSystemInstruction: vi.fn(),
}),
getCurrentSequenceModel: vi.fn().mockReturnValue('test-model'),
getHistory: vi.fn().mockReturnValue([]),
resumeChat: vi.fn(),
};
mockScheduler = {
schedule: vi.fn(),
};
mockCompressionService = {
compress: vi.fn().mockResolvedValue({
newHistory: null,
info: { compressionStatus: CompressionStatus.NOOP },
}),
};
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(
mockClient as unknown as GeminiClient,
);
vi.mocked(Scheduler).mockImplementation(
(options) =>
({
...mockScheduler,
schedulerId: (options as { schedulerId: string }).schedulerId,
}) as unknown as Scheduler,
);
vi.mocked(ChatCompressionService).mockImplementation(
() => mockCompressionService as unknown as ChatCompressionService,
);
session = new AgentSession('test-session', agentConfig, mockConfig);
});
it('should emit agent_start and agent_finish', async () => {
mockClient.sendMessageStream.mockImplementation(async function* () {
yield { type: GeminiEventType.Content, value: 'Hello' };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
const events = [];
for await (const event of session.prompt('Hi')) {
events.push(event);
}
const finishEvent = events[events.length - 1] as AgentFinishEvent;
expect(events[0].type).toBe('agent_start');
expect(finishEvent.type).toBe('agent_finish');
expect(finishEvent.value.reason).toBe(AgentTerminateMode.GOAL);
expect(mockClient.sendMessageStream).toHaveBeenCalled();
});
it('should handle tool calls and execute them via MessageBus updates', async () => {
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'call1', name: 'test_tool', args: {} },
};
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield { type: GeminiEventType.Content, value: 'Tool executed' };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
const toolResponse = {
response: {
callId: 'call1',
responseParts: [
{
functionResponse: {
name: 'test_tool',
response: { ok: true },
id: 'call1',
},
},
],
},
};
mockScheduler.schedule.mockImplementation(async () => {
const bus = mockConfig.getMessageBus();
const schedulerId = (session as unknown as { schedulerId: string })
.schedulerId;
await bus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId,
toolCalls: [
{
request: { callId: 'call1', name: 'test_tool', args: {} },
status: CoreToolCallStatus.Executing,
schedulerId,
} as unknown as ToolCallsUpdateMessage['toolCalls'][number],
],
});
await bus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId,
toolCalls: [
{
request: { callId: 'call1', name: 'test_tool', args: {} },
status: CoreToolCallStatus.Success,
response: toolResponse.response,
schedulerId,
} as unknown as ToolCallsUpdateMessage['toolCalls'][number],
],
});
return [toolResponse];
});
const events = [];
for await (const event of session.prompt('Run tool')) {
events.push(event);
}
expect(mockClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockScheduler.schedule).toHaveBeenCalledTimes(1);
const callStart = events.find((e) => e.type === 'tool_call_start');
const callFinish = events.find((e) => e.type === 'tool_call_finish');
expect(callStart).toBeDefined();
expect(callFinish).toBeDefined();
expect((callFinish as ToolCallFinishEvent).value.callId).toBe('call1');
});
it('should handle multiple consecutive ReAct turns', async () => {
// Turn 1: tool1
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'c1', name: 'tool1', args: {} },
};
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
// Turn 2: tool2
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'c2', name: 'tool2', args: {} },
};
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
// Turn 3: final content
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield { type: GeminiEventType.Content, value: 'All done' };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
mockScheduler.schedule.mockImplementation(async (calls) =>
(calls as ToolCallRequestInfo[]).map((c) => ({
response: { callId: c.callId, responseParts: [] },
})),
);
const events = [];
for await (const event of session.prompt('Start multistep')) {
events.push(event);
}
expect(mockClient.sendMessageStream).toHaveBeenCalledTimes(3);
expect(mockScheduler.schedule).toHaveBeenCalledTimes(2);
expect(events.filter((e) => e.type === 'tool_suite_start')).toHaveLength(2);
});
it('should handle parallel tool calls in a single turn', async () => {
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'p1', name: 'toolA', args: {} },
};
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'p2', name: 'toolB', args: {} },
};
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield { type: GeminiEventType.Content, value: 'Parallel done' };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
mockScheduler.schedule.mockImplementation(async (calls) =>
(calls as ToolCallRequestInfo[]).map((c) => ({
response: { callId: c.callId, responseParts: [] },
})),
);
const events = [];
for await (const event of session.prompt('Parallel')) {
events.push(event);
}
const suiteStart = events.find(
(e) => e.type === 'tool_suite_start',
) as ToolSuiteStartEvent;
expect(suiteStart.value.count).toBe(2);
expect(mockScheduler.schedule).toHaveBeenCalledTimes(1);
expect(mockScheduler.schedule).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ callId: 'p1' }),
expect.objectContaining({ callId: 'p2' }),
]),
expect.anything(),
);
});
it('should resume from session data', async () => {
const resumeData = {
conversation: {
messages: [{ type: 'user', content: 'Hello' }],
},
} as unknown as ResumedSessionData;
await session.resume(resumeData);
expect(mockClient.resumeChat).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ role: 'user', parts: [{ text: 'Hello' }] }),
]),
resumeData,
);
});
it('should handle model stream errors gracefully', async () => {
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield* []; // satisfy require-yield
throw new Error('Model connection lost');
});
const events: AgentEvent[] = [];
try {
for await (const event of session.prompt('Error test')) {
events.push(event);
}
} catch (_e) {
// Expected error
}
const finishEvent = events.find(
(e) => e.type === 'agent_finish',
) as AgentFinishEvent;
expect(finishEvent).toBeDefined();
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ERROR);
expect(finishEvent.value.message).toBe('Model connection lost');
});
it('should ignore MessageBus updates from other schedulers', async () => {
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'call1', name: 'test_tool', args: {} },
};
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield { type: GeminiEventType.Content, value: 'Done' };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
mockScheduler.schedule.mockImplementation(async () => {
const bus = mockConfig.getMessageBus();
// Update from ANOTHER scheduler
await bus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId: 'different-scheduler',
toolCalls: [
{
request: { callId: 'call1', name: 'test_tool', args: {} },
status: CoreToolCallStatus.Executing,
schedulerId: 'different-scheduler',
} as unknown as ToolCallsUpdateMessage['toolCalls'][number],
],
});
return [{ response: { callId: 'call1', responseParts: [] } }];
});
const events = [];
for await (const event of session.prompt('Isolation test')) {
events.push(event);
}
// Should NOT see tool_call_start because it was from a different schedulerId
expect(events.find((e) => e.type === 'tool_call_start')).toBeUndefined();
});
it('should terminate with LOOP when loop is detected by model', async () => {
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield { type: GeminiEventType.LoopDetected };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
const events = [];
for await (const event of session.prompt('Loop')) {
events.push(event);
}
const finishEvent = events.find(
(e) => e.type === 'agent_finish',
) as AgentFinishEvent;
expect(finishEvent.value.reason).toBe(AgentTerminateMode.LOOP);
expect(finishEvent.value.message).toContain('Loop detected');
});
it('should handle Part[] input correctly', async () => {
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
yield { type: GeminiEventType.Content, value: 'I see parts' };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
const input = [{ text: 'Hello' }, { text: 'World' }];
for await (const _ of session.prompt(input)) {
// consume
}
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
input,
expect.anything(),
expect.anything(),
undefined,
false,
input,
);
});
it('should trigger compression if enabled', async () => {
mockClient.sendMessageStream.mockImplementation(async function* () {
yield { type: GeminiEventType.Content, value: 'Done' };
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
for await (const _ of session.prompt('Compress me')) {
// consume stream to trigger compression
}
expect(mockCompressionService.compress).toHaveBeenCalled();
});
it('should respect abort signal', async () => {
const controller = new AbortController();
mockClient.sendMessageStream.mockImplementation(async function* () {
yield { type: GeminiEventType.Content, value: 'Thinking...' };
controller.abort();
yield { type: GeminiEventType.Content, value: 'Still thinking...' };
});
const events = [];
for await (const event of session.prompt('Long task', controller.signal)) {
events.push(event);
}
const finishEvent = events[events.length - 1] as AgentFinishEvent;
expect(finishEvent.type).toBe('agent_finish');
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ABORTED);
});
it('should apply systemInstruction from AgentConfig', async () => {
const customConfig: AgentConfig = {
...agentConfig,
systemInstruction: 'You are a helpful assistant.',
};
// Mock isInitialized to true so constructor can set it
mockClient.isInitialized.mockReturnValue(true);
const mockChat = { setSystemInstruction: vi.fn() };
mockClient.getChat.mockReturnValue(mockChat);
// Re-create to trigger constructor logic
new AgentSession('test-session-3', customConfig, mockConfig);
expect(mockChat.setSystemInstruction).toHaveBeenCalledWith(
'You are a helpful assistant.',
);
});
it('should abort internal operations if caller stops iterating', async () => {
let internalSignal: AbortSignal | undefined;
mockClient.sendMessageStream.mockImplementation(async function* (
_parts: unknown,
signal: AbortSignal,
) {
internalSignal = signal;
yield { type: GeminiEventType.Content, value: 'Part 1' };
yield { type: GeminiEventType.Content, value: 'Part 2' };
});
const promptStream = session.prompt('Test cancellation');
const iterator = promptStream[Symbol.asyncIterator]();
const firstEvent = await iterator.next();
expect(firstEvent.value?.type).toBe('agent_start');
const secondEvent = await iterator.next(); // content Part 1
expect(secondEvent.value?.type).toBe(GeminiEventType.Content);
// Caller stops here and closes the generator
if (iterator.return) {
await iterator.return();
}
expect(internalSignal?.aborted).toBe(true);
});
it('should respect maxTurns from config', async () => {
const customSession = new AgentSession(
'test-session-2',
{ ...agentConfig, maxTurns: 2 },
mockConfig,
);
mockClient.sendMessageStream.mockImplementation(async function* () {
yield {
type: GeminiEventType.ToolCallRequest,
value: { callId: 'call', name: 'test_tool', args: {} },
};
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
});
mockScheduler.schedule.mockResolvedValue([
{
response: {
callId: 'call',
responseParts: [
{
functionResponse: {
name: 'test_tool',
response: { ok: true },
id: 'call',
},
},
],
},
},
]);
const events = [];
for await (const event of customSession.prompt('Start loop')) {
events.push(event);
}
expect(mockScheduler.schedule).toHaveBeenCalledTimes(2);
const finishEvent = events[events.length - 1] as AgentFinishEvent;
expect(finishEvent.type).toBe('agent_finish');
expect(finishEvent.value.totalTurns).toBe(2);
expect(finishEvent.value.reason).toBe(AgentTerminateMode.MAX_TURNS);
});
});
+469
View File
@@ -0,0 +1,469 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Part } from '@google/genai';
import { type Config } from '../config/config.js';
import { type GeminiClient } from '../core/client.js';
import { type AgentEvent, type AgentConfig } from './types.js';
import { Scheduler } from '../scheduler/scheduler.js';
import {
type ToolCallRequestInfo,
type ToolCallResponseInfo,
CoreToolCallStatus,
type CompletedToolCall,
} from '../scheduler/types.js';
import { GeminiEventType, CompressionStatus } from '../core/turn.js';
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
import { debugLogger } from '../utils/debugLogger.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import { AgentTerminateMode } from './types.js';
import type { ResumedSessionData } from '../services/chatRecordingService.js';
import { convertSessionToClientHistory } from '../utils/sessionUtils.js';
import {
MessageBusType,
type ToolCallsUpdateMessage,
} from '../confirmation-bus/types.js';
/** Result of a single model turn in the ReAct loop. */
export interface ModelTurnResult {
/** The specific tool calls requested by the model. */
toolCalls: ToolCallRequestInfo[];
/** The unified event stream from this model turn. */
events: AsyncIterable<AgentEvent>;
/** Whether an infinite tool loop was detected. */
loopDetected: boolean;
}
/** Result of executing a batch of tool calls. */
export interface ToolExecutionResult {
/** The response parts from the tool execution to be sent back to the model. */
nextParts: Part[];
/** Whether execution should stop immediately (e.g. on fatal tool error). */
stopExecution: boolean;
/** Optional details if execution was stopped. */
stopExecutionInfo: ToolCallResponseInfo | undefined;
}
/**
* AgentSession manages the state of a conversation and orchestrates the agent
* loop.
*/
export class AgentSession {
readonly sessionId: string;
private readonly client: GeminiClient;
private readonly scheduler: Scheduler;
private readonly schedulerId: string;
private readonly compressionService: ChatCompressionService;
private totalTurns = 0;
private hasFailedCompressionAttempt = false;
constructor(
sessionId: string,
private readonly config: AgentConfig,
private readonly runtime: Config,
) {
this.sessionId = sessionId;
this.client = this.runtime.getGeminiClient();
this.schedulerId = `agent-scheduler-${this.sessionId}-${Math.random().toString(36).substring(2, 9)}`;
this.scheduler = new Scheduler({
config: this.runtime,
messageBus: this.runtime.getMessageBus(),
getPreferredEditor: () => undefined,
schedulerId: this.schedulerId,
});
this.compressionService = new ChatCompressionService();
// Ensure system instruction is set from AgentConfig
if (this.config.systemInstruction) {
if (this.client.isInitialized()) {
this.client
.getChat()
.setSystemInstruction(this.config.systemInstruction);
}
}
}
/**
* Resumes the agent session from persistent storage data.
* Hydrates the internal language model client with the previously saved trajectory.
*
* @param resumedSessionData The raw payload of a previously saved session.
*/
async resume(resumedSessionData: ResumedSessionData): Promise<void> {
const clientHistory = convertSessionToClientHistory(
resumedSessionData.conversation.messages,
);
await this.client.resumeChat(clientHistory, resumedSessionData);
// Re-apply system instruction after resume since resume re-creates the chat
if (this.config.systemInstruction) {
this.client.getChat().setSystemInstruction(this.config.systemInstruction);
}
}
/**
* Executes the ReAct loop for a given user input.
* Returns an AsyncIterable of events occurring during the session.
*/
async *prompt(
input: string | Part[],
signal?: AbortSignal,
): AsyncIterable<AgentEvent> {
const internalController = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, internalController.signal])
: internalController.signal;
yield {
type: 'agent_start',
value: { sessionId: this.sessionId },
};
let terminationReason = AgentTerminateMode.GOAL;
let terminationMessage: string | undefined = undefined;
let terminationError: unknown | undefined = undefined;
try {
const loop = this._runLoop(input, combinedSignal);
for await (const event of loop) {
if (event.type === GeminiEventType.LoopDetected) {
terminationReason = AgentTerminateMode.LOOP;
terminationMessage = 'Loop detected, stopping execution';
}
yield event;
}
if (combinedSignal.aborted) {
terminationReason = AgentTerminateMode.ABORTED;
} else if (
terminationReason === AgentTerminateMode.GOAL &&
this.config.maxTurns &&
this.config.maxTurns !== -1 &&
this.totalTurns >= this.config.maxTurns
) {
// Only set MAX_TURNS if we haven't already hit another reason (like LOOP)
// and we are actually at or above the turn limit.
terminationReason = AgentTerminateMode.MAX_TURNS;
terminationMessage = 'Maximum session turns exceeded.';
}
} catch (e) {
terminationReason = AgentTerminateMode.ERROR;
terminationMessage = e instanceof Error ? e.message : String(e);
terminationError = e;
throw e;
} finally {
internalController.abort();
yield {
type: 'agent_finish',
value: {
sessionId: this.sessionId,
totalTurns: this.totalTurns,
reason: terminationReason,
message: terminationMessage,
error: terminationError,
},
};
}
}
/**
* Internal generator managing the turn-by-turn ReAct loop.
*/
private async *_runLoop(
input: string | Part[],
signal: AbortSignal,
): AsyncIterable<AgentEvent> {
let currentInput = input;
let isContinuation = false;
const maxTurns = this.config.maxTurns ?? -1;
while (maxTurns === -1 || this.totalTurns < maxTurns) {
if (signal.aborted) return;
this.totalTurns++;
const promptId = `${this.sessionId}#${this.totalTurns}`;
if (this.config.capabilities?.compression) {
await this.tryCompressChat(promptId);
}
const results = await this.runModelTurn(
currentInput,
promptId,
isContinuation ? undefined : input,
signal,
);
for await (const event of results.events) {
yield event;
}
if (results.loopDetected) return;
if (signal.aborted) return;
if (results.toolCalls.length > 0) {
const toolRun = this._handleToolCalls(results.toolCalls, signal);
let toolResults: ToolExecutionResult;
while (true) {
const { value, done } = await toolRun.next();
if (done) {
toolResults = value;
break;
}
yield value;
}
if (toolResults.stopExecution || signal.aborted) {
if (toolResults.stopExecution && toolResults.stopExecutionInfo) {
throw (
toolResults.stopExecutionInfo.error ??
new Error('Tool execution stopped')
);
}
return;
}
if (maxTurns !== -1 && this.totalTurns >= maxTurns) {
return;
}
currentInput = toolResults.nextParts;
isContinuation = true;
} else {
return;
}
}
}
/**
* Orchestrates tool execution turn and yields events.
*/
private async *_handleToolCalls(
toolCalls: ToolCallRequestInfo[],
signal: AbortSignal,
): AsyncGenerator<AgentEvent, ToolExecutionResult> {
const toolRun = this.executeTools(toolCalls, signal);
while (true) {
const { value, done } = await toolRun.next();
if (done) return value;
yield value;
}
}
/**
* Calls the model and yields the event stream.
* Collects tool call requests for the next phase.
*/
private async runModelTurn(
input: string | Part[],
promptId: string,
displayContent?: string | Part[],
signal?: AbortSignal,
): Promise<ModelTurnResult> {
const parts = Array.isArray(input) ? input : [{ text: input }];
const toolCalls: ToolCallRequestInfo[] = [];
let loopDetected = false;
// Ensure client is initialized before sending message
if (!this.client.isInitialized()) {
await this.client.initialize();
// Re-apply system instruction after initialization
if (this.config.systemInstruction) {
this.client
.getChat()
.setSystemInstruction(this.config.systemInstruction);
}
}
const stream = this.client.sendMessageStream(
parts,
signal ?? new AbortController().signal,
promptId,
undefined, // maxTurns (client handles its own)
false, // isInvalidStreamRetry
displayContent,
);
const eventGenerator = async function* (): AsyncIterable<AgentEvent> {
for await (const event of stream) {
if (event.type === GeminiEventType.ToolCallRequest) {
toolCalls.push(event.value);
} else if (event.type === GeminiEventType.LoopDetected) {
loopDetected = true;
}
yield event;
}
};
const events = eventGenerator();
return {
toolCalls,
events,
get loopDetected() {
return loopDetected;
},
};
}
/**
* Executes a batch of tool calls via the Scheduler.
*/
private async *executeTools(
toolCalls: ToolCallRequestInfo[],
signal?: AbortSignal,
): AsyncGenerator<AgentEvent, ToolExecutionResult> {
yield {
type: 'tool_suite_start',
value: { count: toolCalls.length },
};
const eventQueue: AgentEvent[] = [];
let resolveNext: (() => void) | undefined;
let isFinished = false;
const onToolUpdate = this._createToolUpdateHandler(eventQueue, () =>
resolveNext?.(),
);
const messageBus = this.runtime.getMessageBus();
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, onToolUpdate);
const schedulePromise = this.scheduler.schedule(
toolCalls,
signal ?? new AbortController().signal,
);
try {
while (!isFinished || eventQueue.length > 0) {
if (eventQueue.length > 0) {
const event = eventQueue.shift();
if (event) yield event;
} else {
const waitNext = new Promise<void>((resolve) => {
resolveNext = resolve;
});
await Promise.race([
waitNext,
schedulePromise.then(() => {
isFinished = true;
resolveNext?.();
}),
]);
}
}
const completedCalls = await schedulePromise;
yield {
type: 'tool_suite_finish',
value: { responses: completedCalls.map((c) => c.response) },
};
await this._recordTelemetry(completedCalls);
const nextParts = completedCalls.flatMap((c) => c.response.responseParts);
const stopExecutionInfo = completedCalls.find(
(c) => c.response.errorType === ToolErrorType.STOP_EXECUTION,
)?.response;
return {
nextParts,
stopExecution: !!stopExecutionInfo,
stopExecutionInfo,
};
} finally {
messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, onToolUpdate);
}
}
/**
* Creates a handler for MessageBus tool update events.
*/
private _createToolUpdateHandler(
eventQueue: AgentEvent[],
onNewEvents: () => void,
) {
const seenStatuses = new Map<string, CoreToolCallStatus>();
return (message: ToolCallsUpdateMessage) => {
if (message.schedulerId !== this.schedulerId) return;
for (const call of message.toolCalls) {
const prevStatus = seenStatuses.get(call.request.callId);
if (prevStatus === call.status) continue;
if (call.status === CoreToolCallStatus.Executing) {
eventQueue.push({ type: 'tool_call_start', value: call.request });
} else if (
call.status === CoreToolCallStatus.Success ||
call.status === CoreToolCallStatus.Error ||
call.status === CoreToolCallStatus.Cancelled
) {
eventQueue.push({
type: 'tool_call_finish',
value: call.response,
});
}
seenStatuses.set(call.request.callId, call.status);
}
onNewEvents();
};
}
/**
* Records tool interaction telemetry and persistence data.
*/
private async _recordTelemetry(completedCalls: CompletedToolCall[]) {
try {
const currentModel =
this.client.getCurrentSequenceModel() ?? this.runtime.getModel();
this.client
.getChat()
.recordCompletedToolCalls(currentModel, completedCalls);
await recordToolCallInteractions(this.runtime, completedCalls);
} catch (e) {
debugLogger.warn(`Error recording tool call information: ${e}`);
}
}
/**
* Attempts to compress the chat history if thresholds are exceeded.
*/
private async tryCompressChat(promptId: string): Promise<void> {
const chat = this.client.getChat();
const model = this.config.model ?? this.runtime.getModel();
const { newHistory, info } = await this.compressionService.compress(
chat,
promptId,
false,
model,
this.runtime,
this.hasFailedCompressionAttempt,
);
if (
info.compressionStatus ===
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
) {
this.hasFailedCompressionAttempt = true;
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
if (newHistory) {
chat.setHistory(newHistory);
this.hasFailedCompressionAttempt = false;
}
}
}
/**
* Returns the current message history for this session.
*/
getHistory() {
return this.client.getHistory();
}
}
+100 -4
View File
@@ -4,16 +4,111 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Defines the core configuration interfaces and types for the agent architecture.
*/
import type { Content, FunctionDeclaration } from '@google/genai';
import type { AnyDeclarativeTool } from '../tools/tools.js';
import { type z } from 'zod';
import type { ModelConfig } from '../services/modelConfigService.js';
import type { AnySchema } from 'ajv';
import type { A2AAuthConfig } from './auth-provider/types.js';
import { type ServerGeminiStreamEvent } from '../core/turn.js';
import {
type ToolCallResponseInfo,
type ToolCallRequestInfo,
} from '../scheduler/types.js';
/** Emitted when an agent session begins execution. */
export interface AgentStartEvent {
type: 'agent_start';
value: { sessionId: string };
}
/** Emitted when an agent session completes, providing termination details. */
export interface AgentFinishEvent {
type: 'agent_finish';
value: {
sessionId: string;
totalTurns: number;
reason: AgentTerminateMode;
message?: string;
error?: unknown;
};
}
/** Emitted when a group of tool calls is about to be executed. */
export interface ToolSuiteStartEvent {
type: 'tool_suite_start';
value: { count: number };
}
/** Emitted when a group of tool calls has finished executing. */
export interface ToolSuiteFinishEvent {
type: 'tool_suite_finish';
value: { responses: ToolCallResponseInfo[] };
}
/** Emitted when an individual tool call begins execution. */
export interface ToolCallStartEvent {
type: 'tool_call_start';
value: ToolCallRequestInfo;
}
/** Emitted when an individual tool call has finished execution. */
export interface ToolCallFinishEvent {
type: 'tool_call_finish';
value: ToolCallResponseInfo;
}
/** Emitted when the model generates internal reasoning or "thought" content. */
export interface ThoughtEvent {
type: 'thought';
value: string;
}
/** Emitted when an infinite loop is detected in the model's tool calling patterns. */
export interface LoopDetectedEvent {
type: 'loop_detected';
value: { sessionId: string };
}
/**
* Unified event type for the Agent loop.
* This extends the base Gemini stream events with higher-level agent lifecycle events.
*/
export type AgentEvent =
| ServerGeminiStreamEvent
| AgentStartEvent
| AgentFinishEvent
| ToolSuiteStartEvent
| ToolSuiteFinishEvent
| ToolCallStartEvent
| ToolCallFinishEvent
| ThoughtEvent
| LoopDetectedEvent;
/**
* Configuration for an Agent.
*/
export interface AgentConfig {
/** The name of the agent. */
name: string;
/** The system instruction (personality/rules) for the agent. */
systemInstruction: string;
/** Optional override for the model to use. */
model?: string;
/**
* Optional maximum number of conversational turns.
* Set to -1 for no limit, defaults to -1 if not specified.
*/
maxTurns?: number;
/**
* Optional capabilities to enable for this agent.
*/
capabilities?: {
compression?: boolean;
loopDetection?: boolean;
ideContext?: boolean;
};
}
/**
* Describes the possible termination modes for an agent.
@@ -24,6 +119,7 @@ export enum AgentTerminateMode {
GOAL = 'GOAL',
MAX_TURNS = 'MAX_TURNS',
ABORTED = 'ABORTED',
LOOP = 'LOOP',
ERROR_NO_COMPLETE_TASK_CALL = 'ERROR_NO_COMPLETE_TASK_CALL',
}
@@ -47,7 +47,10 @@ describe('Fallback Integration', () => {
const requestedModel = PREVIEW_GEMINI_MODEL;
// 3. Apply model selection
const result = applyModelSelection(config, { model: requestedModel });
const result = applyModelSelection(config, {
model: requestedModel,
isChatModel: true,
});
// 4. Expect fallback to Flash
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
@@ -222,7 +222,10 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-pro',
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
expect(result.model).toBe('gemini-pro');
expect(result.maxAttempts).toBeUndefined();
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
@@ -243,7 +246,10 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-flash',
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
expect(result.model).toBe('gemini-flash');
expect(result.config).toEqual({
@@ -253,14 +259,33 @@ describe('policyHelpers', () => {
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-pro',
isChatModel: true,
});
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-flash',
isChatModel: true,
});
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-flash');
});
it('consumes sticky attempt if indicated', () => {
it('does not call setActiveModel if isChatModel is false', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
generateContentConfig: {},
});
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-pro',
});
applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: false,
});
expect(config.setActiveModel).not.toHaveBeenCalled();
});
it('consumes sticky attempt if indicated and isChatModel is true', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
@@ -271,10 +296,36 @@ describe('policyHelpers', () => {
attempts: 1,
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
'gemini-pro',
);
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});
it('consumes sticky attempt if indicated but does not call setActiveModel if isChatModel is false', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
generateContentConfig: {},
});
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-pro',
attempts: 1,
});
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: false,
});
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
'gemini-pro',
);
expect(config.setActiveModel).not.toHaveBeenCalled();
expect(result.maxAttempts).toBe(1);
});
@@ -291,7 +342,7 @@ describe('policyHelpers', () => {
const result = applyModelSelection(
config,
{ model: 'gemini-pro' },
{ model: 'gemini-pro', isChatModel: true },
{
consumeAttempt: false,
},
@@ -299,6 +350,7 @@ describe('policyHelpers', () => {
expect(
mockAvailabilityService.consumeStickyAttempt,
).not.toHaveBeenCalled();
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});
});
@@ -214,7 +214,9 @@ export function applyModelSelection(
generateContentConfig = fallbackResolved.generateContentConfig;
}
config.setActiveModel(finalModel);
if (modelConfigKey.isChatModel) {
config.setActiveModel(finalModel);
}
if (selection.attempts && options.consumeAttempt !== false) {
config.getModelAvailabilityService().consumeStickyAttempt(finalModel);
@@ -28,6 +28,11 @@ vi.mock('node:fs', () => ({
readFile: vi.fn(),
rm: vi.fn(),
},
createWriteStream: vi.fn(() => ({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
})),
}));
vi.mock('node:os');
vi.mock('node:path');
+24
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import type { ConfigParameters, SandboxConfig } from './config.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js';
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { ApprovalMode } from '../policy/types.js';
@@ -259,6 +260,29 @@ describe('Server Config (config.ts)', () => {
usageStatisticsEnabled: false,
};
describe('maxAttempts', () => {
it('should default to DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config(baseParams);
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
});
it('should use provided maxAttempts if <= DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config({
...baseParams,
maxAttempts: 5,
});
expect(config.getMaxAttempts()).toBe(5);
});
it('should cap maxAttempts at DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config({
...baseParams,
maxAttempts: 20,
});
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
});
});
beforeEach(() => {
// Reset mocks if necessary
vi.clearAllMocks();
+18
View File
@@ -291,6 +291,7 @@ export interface ExtensionInstallMetadata {
allowPreRelease?: boolean;
}
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import type { FileFilteringOptions } from './constants.js';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
@@ -470,11 +471,13 @@ export interface ConfigParameters {
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
retryFetchErrors?: boolean;
maxAttempts?: number;
enableShellOutputEfficiency?: boolean;
shellToolInactivityTimeout?: number;
fakeResponses?: string;
@@ -633,6 +636,7 @@ export class Config {
readonly interactive: boolean;
private readonly ptyInfo: string;
private readonly trustedFolder: boolean | undefined;
private readonly directWebFetch: boolean;
private readonly useRipgrep: boolean;
private readonly enableInteractiveShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
@@ -655,6 +659,7 @@ export class Config {
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
private readonly maxAttempts: number;
private readonly enableShellOutputEfficiency: boolean;
private readonly shellToolInactivityTimeout: number;
readonly fakeResponses?: string;
@@ -826,6 +831,7 @@ export class Config {
this.interactive = params.interactive ?? false;
this.ptyInfo = params.ptyInfo ?? 'child_process';
this.trustedFolder = params.trustedFolder;
this.directWebFetch = params.directWebFetch ?? false;
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
@@ -876,6 +882,10 @@ export class Config {
format: params.output?.format ?? OutputFormat.TEXT,
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
DEFAULT_MAX_ATTEMPTS,
);
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
@@ -2085,6 +2095,10 @@ export class Config {
return this.approvedPlanPath;
}
getDirectWebFetch(): boolean {
return this.directWebFetch;
}
setApprovedPlanPath(path: string | undefined): void {
this.approvedPlanPath = path;
}
@@ -2408,6 +2422,10 @@ export class Config {
return this.retryFetchErrors;
}
getMaxAttempts(): number {
return this.maxAttempts;
}
getEnableShellOutputEfficiency(): boolean {
return this.enableShellOutputEfficiency;
}
+12
View File
@@ -14,6 +14,7 @@ import {
GOOGLE_ACCOUNTS_FILENAME,
isSubpath,
resolveToRealPath,
normalizePath,
} from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -142,6 +143,17 @@ export class Storage {
return path.join(this.targetDir, GEMINI_DIR);
}
/**
* Checks if the current workspace storage location is the same as the global/user storage location.
* This handles symlinks and platform-specific path normalization.
*/
isWorkspaceHomeDir(): boolean {
return (
normalizePath(resolveToRealPath(this.targetDir)) ===
normalizePath(resolveToRealPath(homedir()))
);
}
getAgentsDir(): string {
return path.join(this.targetDir, AGENTS_DIR_NAME);
}
@@ -95,6 +95,9 @@ export type SerializableConfirmationDetails =
serverName: string;
toolName: string;
toolDisplayName: string;
toolArgs?: Record<string, unknown>;
toolDescription?: string;
toolParameterSchema?: unknown;
}
| {
type: 'ask_user';
+47 -25
View File
@@ -641,7 +641,7 @@ describe('BaseLlmClient', () => {
);
contentOptions = {
modelConfigKey: { model: 'test-model' },
modelConfigKey: { model: 'test-model', isChatModel: false },
contents: [{ role: 'user', parts: [{ text: 'Give me a color.' }] }],
abortSignal: abortController.signal,
promptId: 'content-prompt-id',
@@ -650,12 +650,17 @@ describe('BaseLlmClient', () => {
jsonOptions = {
...defaultOptions,
modelConfigKey: {
...defaultOptions.modelConfigKey,
isChatModel: true,
},
promptId: 'json-prompt-id',
};
});
it('should mark model as healthy on success', async () => {
const successfulModel = 'gemini-pro';
mockConfig.getActiveModel.mockReturnValue(successfulModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: successfulModel,
skipped: [],
@@ -666,7 +671,7 @@ describe('BaseLlmClient', () => {
await client.generateContent({
...contentOptions,
modelConfigKey: { model: successfulModel },
modelConfigKey: { model: successfulModel, isChatModel: false },
role: LlmRole.UTILITY_TOOL,
});
@@ -678,44 +683,55 @@ describe('BaseLlmClient', () => {
it('marks the final attempted model healthy after a retry with availability enabled', async () => {
const firstModel = 'gemini-pro';
const fallbackModel = 'gemini-flash';
let activeModel = firstModel;
mockConfig.getActiveModel.mockImplementation(() => activeModel);
mockConfig.setActiveModel.mockImplementation((m) => {
activeModel = m;
});
vi.mocked(mockAvailabilityService.selectFirstAvailable)
.mockReturnValueOnce({ selectedModel: firstModel, skipped: [] })
.mockReturnValueOnce({ selectedModel: fallbackModel, skipped: [] });
// Mock generateContent to fail once and then succeed
mockGenerateContent
.mockResolvedValueOnce(createMockResponse('retry-me'))
.mockResolvedValueOnce(createMockResponse(''))
.mockResolvedValueOnce(createMockResponse('final-response'));
// Run the real retryWithBackoff (with fake timers) to exercise the retry path
vi.useFakeTimers();
// 1. First call starts. applyModelSelection(firstModel) -> currentModel = firstModel.
// 2. apiCall() runs. getActiveModel() === firstModel. call(firstModel). returns ''.
// 3. retry triggers.
// 4. Second call starts. applyModelSelection(firstModel).
// selectFirstAvailable -> fallbackModel.
// setActiveModel(fallbackModel) -> activeModel = fallbackModel.
// returns fallbackModel.
// 5. apiCall() runs. getActiveModel() === fallbackModel. call(fallbackModel). returns 'final-response'.
const retryPromise = client.generateContent({
vi.mocked(retryWithBackoff).mockImplementation(async (fn) => {
// First call
let res = (await fn()) as GenerateContentResponse;
if (res.candidates?.[0]?.content?.parts?.[0]?.text === '') {
// Second call
activeModel = fallbackModel;
mockConfig.setActiveModel(fallbackModel);
res = (await fn()) as GenerateContentResponse;
}
mockAvailabilityService.markHealthy(activeModel);
return res;
});
const result = await client.generateContent({
...contentOptions,
modelConfigKey: { model: firstModel },
modelConfigKey: { model: firstModel, isChatModel: true },
maxAttempts: 2,
role: LlmRole.UTILITY_TOOL,
});
await vi.runAllTimersAsync();
await retryPromise;
await client.generateContent({
...contentOptions,
modelConfigKey: { model: firstModel },
maxAttempts: 2,
role: LlmRole.UTILITY_TOOL,
});
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(firstModel);
expect(result).toEqual(createMockResponse('final-response'));
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(fallbackModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
fallbackModel,
);
expect(mockGenerateContent).toHaveBeenLastCalledWith(
expect.objectContaining({ model: fallbackModel }),
expect.any(String),
LlmRole.UTILITY_TOOL,
);
});
it('should consume sticky attempt if selection has attempts', async () => {
@@ -754,6 +770,7 @@ describe('BaseLlmClient', () => {
it('should mark healthy and honor availability selection when using generateJson', async () => {
const availableModel = 'gemini-json-pro';
mockConfig.getActiveModel.mockReturnValue(availableModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: availableModel,
skipped: [],
@@ -770,10 +787,15 @@ describe('BaseLlmClient', () => {
return result;
});
const result = await client.generateJson(jsonOptions);
const result = await client.generateJson({
...jsonOptions,
modelConfigKey: {
...jsonOptions.modelConfigKey,
isChatModel: false,
},
});
expect(result).toEqual({ color: 'violet' });
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(availableModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
availableModel,
);
+6 -3
View File
@@ -280,19 +280,22 @@ export class BaseLlmClient {
() => currentModel,
);
let initialActiveModel = this.config.getActiveModel();
try {
const apiCall = () => {
// Ensure we use the current active model
// in case a fallback occurred in a previous attempt.
const activeModel = this.config.getActiveModel();
if (activeModel !== currentModel) {
currentModel = activeModel;
if (activeModel !== initialActiveModel) {
initialActiveModel = activeModel;
// Re-resolve config if model changed during retry
const { generateContentConfig } =
const { model: resolvedModel, generateContentConfig } =
this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: activeModel,
});
currentModel = resolvedModel;
currentGenerateContentConfig = generateContentConfig;
}
const finalConfig: GenerateContentConfig = {
+11 -7
View File
@@ -957,17 +957,21 @@ export class GeminiClient {
() => currentAttemptModel,
);
let initialActiveModel = this.config.getActiveModel();
const apiCall = () => {
// AvailabilityService
const active = this.config.getActiveModel();
if (active !== currentAttemptModel) {
currentAttemptModel = active;
if (active !== initialActiveModel) {
initialActiveModel = active;
// Re-resolve config if model changed
const newConfig = this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: currentAttemptModel,
});
currentAttemptGenerateContentConfig = newConfig.generateContentConfig;
const { model: resolvedModel, generateContentConfig } =
this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: active,
});
currentAttemptModel = resolvedModel;
currentAttemptGenerateContentConfig = generateContentConfig;
}
const requestConfig: GenerateContentConfig = {
@@ -153,6 +153,7 @@ describe('GeminiChat', () => {
}),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getMaxAttempts: vi.fn().mockReturnValue(10),
getUserTier: vi.fn().mockReturnValue(undefined),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
+3 -7
View File
@@ -18,11 +18,7 @@ import type {
} from '@google/genai';
import { toParts } from '../code_assist/converter.js';
import { createUserContent, FinishReason } from '@google/genai';
import {
retryWithBackoff,
isRetryableError,
DEFAULT_MAX_ATTEMPTS,
} from '../utils/retry.js';
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
@@ -635,12 +631,12 @@ export class GeminiChat {
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: abortSignal,
maxAttempts: availabilityMaxAttempts,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
getAvailabilityContext,
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts: availabilityMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
delayMs,
error: error instanceof Error ? error.message : String(error),
model: lastModelToUse,
@@ -94,6 +94,7 @@ describe('GeminiChat Network Retries', () => {
getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false), // Default false
getMaxAttempts: vi.fn().mockReturnValue(10),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => ({
model: modelConfigKey.model,
@@ -22,6 +22,11 @@ import { LlmRole } from '../telemetry/types.js';
vi.mock('node:fs', () => ({
appendFileSync: vi.fn(),
createWriteStream: vi.fn(() => ({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
})),
}));
describe('RecordingContentGenerator', () => {
@@ -17,6 +17,7 @@ vi.mock('node:fs', () => ({
writeFile: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn(),
rename: vi.fn(),
},
}));
@@ -38,6 +39,7 @@ describe('FileTokenStorage', () => {
writeFile: ReturnType<typeof vi.fn>;
unlink: ReturnType<typeof vi.fn>;
mkdir: ReturnType<typeof vi.fn>;
rename: ReturnType<typeof vi.fn>;
};
const existingCredentials: OAuthCredentials = {
serverName: 'existing-server',
@@ -105,12 +107,48 @@ describe('FileTokenStorage', () => {
expect(result).toEqual(credentials);
});
it('should throw error for corrupted files', async () => {
it('should throw error with file path when file is corrupted', async () => {
mockFs.readFile.mockResolvedValue('corrupted-data');
await expect(storage.getCredentials('test-server')).rejects.toThrow(
'Token file corrupted',
);
try {
await storage.getCredentials('test-server');
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
describe('auth type switching', () => {
it('should throw error when trying to save credentials with corrupted file', async () => {
// Simulate corrupted file on first read
mockFs.readFile.mockResolvedValue('corrupted-data');
// Try to save new credentials (simulating switch from OAuth to API key)
const newCredentials: OAuthCredentials = {
serverName: 'new-auth-server',
token: {
accessToken: 'new-api-key',
tokenType: 'ApiKey',
},
updatedAt: Date.now(),
};
// Should throw error with file path
try {
await storage.setCredentials(newCredentials);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
@@ -87,7 +87,12 @@ export class FileTokenStorage extends BaseTokenStorage {
'Unsupported state or unable to authenticate data',
)
) {
throw new Error('Token file corrupted');
// Decryption failed - this can happen when switching between auth types
// or if the file is genuinely corrupted.
throw new Error(
`Corrupted token file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
}
throw error;
}
+172 -9
View File
@@ -431,6 +431,63 @@ describe('PolicyEngine', () => {
});
describe('MCP server wildcard patterns', () => {
it('should match global wildcard (*)', async () => {
engine = new PolicyEngine({
rules: [
{ toolName: '*', decision: PolicyDecision.ALLOW, priority: 10 },
],
});
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__tool' }, 'my-server')).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should match any MCP tool when toolName is *__*', async () => {
engine = new PolicyEngine({
rules: [
{ toolName: '*__*', decision: PolicyDecision.ALLOW, priority: 10 },
],
defaultDecision: PolicyDecision.DENY,
});
expect((await engine.check({ name: 'mcp__tool' }, 'mcp')).decision).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'other__tool' }, 'other')).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should match specific tool across all servers when using *__tool', async () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__search',
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
defaultDecision: PolicyDecision.DENY,
});
expect((await engine.check({ name: 'ws__search' }, 'ws')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__search' }, 'gh')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__list' }, 'gh')).decision).toBe(
PolicyDecision.DENY,
);
});
it('should match MCP server wildcard patterns', async () => {
const rules: PolicyRule[] = [
{
@@ -449,26 +506,35 @@ describe('PolicyEngine', () => {
// Should match my-server tools
expect(
(await engine.check({ name: 'my-server__tool1' }, undefined)).decision,
(await engine.check({ name: 'my-server__tool1' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__another_tool' }, undefined))
(await engine.check({ name: 'my-server__another_tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
// Should match blocked-server tools
expect(
(await engine.check({ name: 'blocked-server__tool1' }, undefined))
.decision,
(
await engine.check(
{ name: 'blocked-server__tool1' },
'blocked-server',
)
).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__dangerous' }, undefined))
.decision,
(
await engine.check(
{ name: 'blocked-server__dangerous' },
'blocked-server',
)
).decision,
).toBe(PolicyDecision.DENY);
// Should not match other patterns
expect(
(await engine.check({ name: 'other-server__tool' }, undefined))
(await engine.check({ name: 'other-server__tool' }, 'other-server'))
.decision,
).toBe(PolicyDecision.ASK_USER);
expect(
@@ -497,11 +563,11 @@ describe('PolicyEngine', () => {
// Specific tool deny should override server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
(await engine.check({ name: 'my-server__dangerous-tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
(await engine.check({ name: 'my-server__safe-tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
});
@@ -2262,6 +2328,39 @@ describe('PolicyEngine', () => {
],
expected: [],
},
{
name: 'should handle global wildcard * in getExcludedTools',
rules: [
{
toolName: '*',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*'],
},
{
name: 'should handle MCP category wildcard *__* in getExcludedTools',
rules: [
{
toolName: '*__*',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*__*'],
},
{
name: 'should handle tool wildcard *__search in getExcludedTools',
rules: [
{
toolName: '*__search',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*__search'],
},
];
it.each(testCases)(
@@ -2458,4 +2557,68 @@ describe('PolicyEngine', () => {
expect(checkers[0].priority).toBe(2.5);
});
});
describe('Tool Annotations', () => {
it('should match tools by semantic annotations', async () => {
engine = new PolicyEngine({
rules: [
{
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
defaultDecision: PolicyDecision.DENY,
});
const readOnlyTool = { name: 'read', args: {} };
const readOnlyMeta = { readOnlyHint: true, extra: 'info' };
const writeTool = { name: 'write', args: {} };
const writeMeta = { readOnlyHint: false };
expect(
(await engine.check(readOnlyTool, undefined, readOnlyMeta)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check(writeTool, undefined, writeMeta)).decision,
).toBe(PolicyDecision.DENY);
expect((await engine.check(writeTool, undefined, {})).decision).toBe(
PolicyDecision.DENY,
);
});
it('should support scoped annotation rules', async () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__*',
toolAnnotations: { experimental: true },
decision: PolicyDecision.DENY,
priority: 20,
},
{
toolName: '*__*',
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
});
expect(
(
await engine.check({ name: 'mcp__test' }, 'mcp', {
experimental: true,
})
).decision,
).toBe(PolicyDecision.DENY);
expect(
(
await engine.check({ name: 'mcp__stable' }, 'mcp', {
experimental: false,
})
).decision,
).toBe(PolicyDecision.ALLOW);
});
});
});
+95 -20
View File
@@ -27,19 +27,73 @@ import {
import { getToolAliases } from '../tools/tool-names.js';
function isWildcardPattern(name: string): boolean {
return name.endsWith('__*');
return name === '*' || name.includes('*');
}
function getWildcardPrefix(pattern: string): string {
return pattern.slice(0, -3);
/**
* Checks if a tool call matches a wildcard pattern.
* Supports global (*) and composite (server__*, *__tool, *__*) patterns.
*/
function matchesWildcard(
pattern: string,
toolName: string,
serverName: string | undefined,
): boolean {
if (pattern === '*') {
return true;
}
if (pattern.includes('__')) {
return matchesCompositePattern(pattern, toolName, serverName);
}
return toolName === pattern;
}
function matchesWildcard(pattern: string, toolName: string): boolean {
if (!isWildcardPattern(pattern)) {
/**
* Matches composite patterns like "server__*", "*__tool", or "*__*".
*/
function matchesCompositePattern(
pattern: string,
toolName: string,
serverName: string | undefined,
): boolean {
const parts = pattern.split('__');
if (parts.length !== 2) return false;
const [patternServer, patternTool] = parts;
// 1. Identify the tool's components
const { actualServer, actualTool } = getToolMetadata(toolName, serverName);
// 2. Composite patterns require a server context
if (actualServer === undefined) {
return false;
}
const prefix = getWildcardPrefix(pattern);
return toolName.startsWith(prefix + '__');
// 3. Robustness: if serverName is provided, toolName MUST be qualified by it.
// This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious".
if (serverName !== undefined && !toolName.startsWith(serverName + '__')) {
return false;
}
// 4. Match components
const serverMatch = patternServer === '*' || patternServer === actualServer;
const toolMatch = patternTool === '*' || patternTool === actualTool;
return serverMatch && toolMatch;
}
/**
* Extracts the server and unqualified tool name from a tool call context.
*/
function getToolMetadata(toolName: string, serverName: string | undefined) {
const sepIndex = toolName.indexOf('__');
const isQualified = sepIndex !== -1;
return {
actualServer:
serverName ?? (isQualified ? toolName.substring(0, sepIndex) : undefined),
actualTool: isQualified ? toolName.substring(sepIndex + 2) : toolName,
};
}
function ruleMatches(
@@ -48,6 +102,7 @@ function ruleMatches(
stringifiedArgs: string | undefined,
serverName: string | undefined,
currentApprovalMode: ApprovalMode,
toolAnnotations?: Record<string, unknown>,
): boolean {
// Check if rule applies to current approval mode
if (rule.modes && rule.modes.length > 0) {
@@ -58,18 +113,11 @@ function ruleMatches(
// Check tool name if specified
if (rule.toolName) {
// Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
if (isWildcardPattern(rule.toolName)) {
const prefix = getWildcardPrefix(rule.toolName);
if (serverName !== undefined) {
// Robust check: if serverName is provided, it MUST match the prefix exactly.
// This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious".
if (serverName !== prefix) {
return false;
}
}
// Always verify the prefix, even if serverName matched
if (!toolCall.name || !matchesWildcard(rule.toolName, toolCall.name)) {
if (
!toolCall.name ||
!matchesWildcard(rule.toolName, toolCall.name, serverName)
) {
return false;
}
} else if (toolCall.name !== rule.toolName) {
@@ -77,6 +125,18 @@ function ruleMatches(
}
}
// Check annotations if specified
if (rule.toolAnnotations) {
if (!toolAnnotations) {
return false;
}
for (const [key, value] of Object.entries(rule.toolAnnotations)) {
if (toolAnnotations[key] !== value) {
return false;
}
}
}
// Check args pattern if specified
if (rule.argsPattern) {
// If rule has an args pattern but tool has no args, no match
@@ -157,6 +217,7 @@ export class PolicyEngine {
dir_path: string | undefined,
allowRedirection?: boolean,
rule?: PolicyRule,
toolAnnotations?: Record<string, unknown>,
): Promise<CheckResult> {
if (!command) {
return {
@@ -247,6 +308,7 @@ export class PolicyEngine {
const subResult = await this.check(
{ name: toolName, args: { command: subCmd, dir_path } },
serverName,
toolAnnotations,
);
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
@@ -304,6 +366,7 @@ export class PolicyEngine {
async check(
toolCall: FunctionCall,
serverName: string | undefined,
toolAnnotations?: Record<string, unknown>,
): Promise<CheckResult> {
let stringifiedArgs: string | undefined;
// Compute stringified args once before the loop
@@ -356,7 +419,14 @@ export class PolicyEngine {
for (const rule of this.rules) {
const match = toolCallsToTry.some((tc) =>
ruleMatches(rule, tc, stringifiedArgs, serverName, this.approvalMode),
ruleMatches(
rule,
tc,
stringifiedArgs,
serverName,
this.approvalMode,
toolAnnotations,
),
);
if (match) {
@@ -373,6 +443,7 @@ export class PolicyEngine {
shellDirPath,
rule.allowRedirection,
rule,
toolAnnotations,
);
decision = shellResult.decision;
if (shellResult.rule) {
@@ -399,6 +470,9 @@ export class PolicyEngine {
this.defaultDecision,
serverName,
shellDirPath,
undefined,
undefined,
toolAnnotations,
);
decision = shellResult.decision;
matchedRule = shellResult.rule;
@@ -417,6 +491,7 @@ export class PolicyEngine {
stringifiedArgs,
serverName,
this.approvalMode,
toolAnnotations,
)
) {
debugLogger.debug(
@@ -597,7 +672,7 @@ export class PolicyEngine {
for (const processed of processedTools) {
if (
isWildcardPattern(processed) &&
matchesWildcard(processed, toolName)
matchesWildcard(processed, toolName, undefined)
) {
// It's covered by a higher-priority wildcard rule.
// If that wildcard rule resulted in exclusion, this tool should also be excluded.
@@ -89,6 +89,52 @@ priority = 100
expect(result.errors).toHaveLength(0);
});
it('should parse toolAnnotations from TOML', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
toolName = "annotated-tool"
toolAnnotations = { readOnlyHint = true, custom = "value" }
decision = "allow"
priority = 70
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('annotated-tool');
expect(result.rules[0].toolAnnotations).toEqual({
readOnlyHint: true,
custom: 'value',
});
expect(result.errors).toHaveLength(0);
});
it('should transform mcpName = "*" to wildcard toolName', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
mcpName = "*"
decision = "ask_user"
priority = 10
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__*');
expect(result.rules[0].decision).toBe(PolicyDecision.ASK_USER);
expect(result.errors).toHaveLength(0);
});
it('should transform mcpName = "*" and specific toolName to wildcard prefix', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
mcpName = "*"
toolName = "search"
decision = "allow"
priority = 10
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__search');
expect(result.errors).toHaveLength(0);
});
it('should transform commandRegex to argsPattern', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
+4
View File
@@ -46,6 +46,7 @@ const PolicyRuleSchema = z.object({
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
}),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
toolAnnotations: z.record(z.any()).optional(),
allow_redirection: z.boolean().optional(),
deny_message: z.string().optional(),
});
@@ -61,6 +62,7 @@ const SafetyCheckerRuleSchema = z.object({
commandRegex: z.string().optional(),
priority: z.number().int().default(0),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
toolAnnotations: z.record(z.any()).optional(),
checker: z.discriminatedUnion('type', [
z.object({
type: z.literal('in-process'),
@@ -383,6 +385,7 @@ export async function loadPoliciesFromToml(
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: rule.modes,
toolAnnotations: rule.toolAnnotations,
allowRedirection: rule.allow_redirection,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
denyMessage: rule.deny_message,
@@ -467,6 +470,7 @@ export async function loadPoliciesFromToml(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
checker: checker.checker as SafetyCheckerConfig,
modes: checker.modes,
toolAnnotations: checker.toolAnnotations,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
};
+12
View File
@@ -115,6 +115,12 @@ export interface PolicyRule {
*/
argsPattern?: RegExp;
/**
* Metadata annotations provided by the tool (e.g. readOnlyHint).
* All keys and values in this record must match the tool's annotations.
*/
toolAnnotations?: Record<string, unknown>;
/**
* The decision to make when this rule matches.
*/
@@ -165,6 +171,12 @@ export interface SafetyCheckerRule {
*/
argsPattern?: RegExp;
/**
* Metadata annotations provided by the tool (e.g. readOnlyHint).
* All keys and values in this record must match the tool's annotations.
*/
toolAnnotations?: Record<string, unknown>;
/**
* Priority of this checker. Higher numbers run first.
* Default is 0.
@@ -71,6 +71,7 @@ describe('Tool Confirmation Policy Updates', () => {
isPathWithinWorkspace: () => true,
getDirectories: () => [rootDir],
}),
getDirectWebFetch: () => false,
storage: {
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
},
@@ -503,7 +503,7 @@ Use this tool when the user's query implies needing the content of several files
exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: replace 1`] = `
{
"description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the read_file tool to examine the file's current content before attempting a text replacement.
"description": "Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting. Always use the read_file tool to examine the file's current content before attempting a text replacement.
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.
@@ -512,16 +512,15 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different.
3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary.
4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement.
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail.
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the instance(s) to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations and \`allow_multiple\` is not true, the tool will fail.
5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match.
**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.",
**Multiple replacements:** Set \`allow_multiple\` to true if you want to replace ALL occurrences that match \`old_string\` exactly.",
"name": "replace",
"parametersJsonSchema": {
"properties": {
"expected_replacements": {
"description": "Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.",
"minimum": 1,
"type": "number",
"allow_multiple": {
"description": "If true, the tool will replace all occurrences of \`old_string\`. If false (default), it will only succeed if exactly one occurrence is found.",
"type": "boolean",
},
"file_path": {
"description": "The path to the file to modify.",
@@ -1023,12 +1022,12 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: google_web_search 1`] = `
{
"description": "Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.",
"description": "Performs a grounded Google Search to find information across the internet. Returns a synthesized answer with citations (e.g., [1]) and source URIs. Best for finding up-to-date documentation, troubleshooting obscure errors, or broad research. Use this when you don't have a specific URL. If a search result requires deeper analysis, follow up by using 'web_fetch' on the provided URI.",
"name": "google_web_search",
"parametersJsonSchema": {
"properties": {
"query": {
"description": "The search query to find information on the web.",
"description": "The search query. Supports natural language questions (e.g., 'Latest breaking changes in React 19') or specific technical queries.",
"type": "string",
},
},
@@ -1291,15 +1290,14 @@ Use this tool when the user's query implies needing the content of several files
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: replace 1`] = `
{
"description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting.
"description": "Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting.
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.",
"name": "replace",
"parametersJsonSchema": {
"properties": {
"expected_replacements": {
"description": "Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.",
"minimum": 1,
"type": "number",
"allow_multiple": {
"description": "If true, the tool will replace all occurrences of \`old_string\`. If false (default), it will only succeed if exactly one occurrence is found.",
"type": "boolean",
},
"file_path": {
"description": "The path to the file to modify.",
@@ -1375,20 +1373,13 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: save_memory 1`] = `
{
"description": "
Saves concise global user context (preferences, facts) for use across ALL workspaces.
### CRITICAL: GLOBAL CONTEXT ONLY
NEVER save workspace-specific context, local paths, or commands (e.g. "The entry point is src/index.js", "The test command is npm test"). These are local to the current workspace and must NOT be saved globally. EXCLUSIVELY for context relevant across ALL workspaces.
- Use for "Remember X" or clear personal facts.
- Do NOT use for session context.",
"description": "Persists global preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases. Unlike 'write_file', which is for project-specific files, this appends to a global memory file loaded in every workspace. If you are unsure whether a fact should be remembered globally, ask the user first. CRITICAL: Do not use for session-specific context or temporary data.",
"name": "save_memory",
"parametersJsonSchema": {
"additionalProperties": false,
"properties": {
"fact": {
"description": "The specific fact or piece of information to remember. Should be a clear, self-contained statement.",
"description": "A concise, global fact or preference (e.g., 'I prefer using tabs'). Do not include local paths or project-specific names.",
"type": "string",
},
},
@@ -1402,12 +1393,12 @@ NEVER save workspace-specific context, local paths, or commands (e.g. "The entry
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: web_fetch 1`] = `
{
"description": "Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
"description": "Analyzes and extracts information from up to 20 URLs. Ideal for documentation review, technical research, or reading raw code from GitHub. You can provide specific, complex instructions for the extraction (e.g., 'Summarize the breaking changes'). Provides cited answers based on the content. GitHub 'blob' URLs are automatically converted to raw versions for better processing. Supports HTTP/HTTPS only.",
"name": "web_fetch",
"parametersJsonSchema": {
"properties": {
"prompt": {
"description": "A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). All URLs to be fetched must be valid and complete, starting with "http://" or "https://", and be fully-formed with a valid hostname (e.g., a domain name like "example.com" or an IP address). For example, "https://example.com" is valid, but "example.com" is not.",
"description": "A string containing the URL(s) and your specific analysis instructions. Be clear about what information you want to find or summarize. Supports up to 20 URLs.",
"type": "string",
},
},
@@ -1421,17 +1412,16 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: write_file 1`] = `
{
"description": "Writes content to a specified file in the local filesystem.
The user has the ability to modify \`content\`. If modified, this will be stated in the response.",
"description": "Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use 'replace' for targeted edits to large files.",
"name": "write_file",
"parametersJsonSchema": {
"properties": {
"content": {
"description": "The content to write to the file. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide complete literal content.",
"description": "The complete content to write. Provide the full file; do not use placeholders like '// ... rest of code'.",
"type": "string",
},
"file_path": {
"description": "The path to the file to write to.",
"description": "Path to the file.",
"type": "string",
},
},
@@ -289,7 +289,7 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
replace: {
name: EDIT_TOOL_NAME,
description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${READ_FILE_TOOL_NAME} tool to examine the file's current content before attempting a text replacement.
description: `Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${READ_FILE_TOOL_NAME} tool to examine the file's current content before attempting a text replacement.
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.
@@ -298,9 +298,9 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different.
3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary.
4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement.
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail.
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the instance(s) to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations and \`allow_multiple\` is not true, the tool will fail.
5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match.
**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`,
**Multiple replacements:** Set \`allow_multiple\` to true if you want to replace ALL occurrences that match \`old_string\` exactly.`,
parametersJsonSchema: {
type: 'object',
properties: {
@@ -336,11 +336,10 @@ A good instruction should concisely answer:
"The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide exact literal code.",
type: 'string',
},
expected_replacements: {
type: 'number',
allow_multiple: {
type: 'boolean',
description:
'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.',
minimum: 1,
'If true, the tool will replace all occurrences of `old_string`. If false (default), it will only succeed if exactly one occurrence is found.',
},
},
required: ['file_path', 'instruction', 'old_string', 'new_string'],
@@ -63,18 +63,17 @@ export const GEMINI_3_SET: CoreToolSet = {
write_file: {
name: WRITE_FILE_TOOL_NAME,
description: `Writes content to a specified file in the local filesystem.
The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
description: `Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use '${EDIT_TOOL_NAME}' for targeted edits to large files.`,
parametersJsonSchema: {
type: 'object',
properties: {
file_path: {
description: 'The path to the file to write to.',
description: 'Path to the file.',
type: 'string',
},
content: {
description:
"The content to write to the file. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide complete literal content.",
"The complete content to write. Provide the full file; do not use placeholders like '// ... rest of code'.",
type: 'string',
},
},
@@ -291,7 +290,7 @@ The user has the ability to modify \`content\`. If modified, this will be stated
replace: {
name: EDIT_TOOL_NAME,
description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting.
description: `Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting.
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.`,
parametersJsonSchema: {
type: 'object',
@@ -314,11 +313,10 @@ The user has the ability to modify the \`new_string\` content. If modified, this
"The exact literal text to replace `old_string` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide exact literal code.",
type: 'string',
},
expected_replacements: {
type: 'number',
allow_multiple: {
type: 'boolean',
description:
'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.',
minimum: 1,
'If true, the tool will replace all occurrences of `old_string`. If false (default), it will only succeed if exactly one occurrence is found.',
},
},
required: ['file_path', 'instruction', 'old_string', 'new_string'],
@@ -327,14 +325,14 @@ The user has the ability to modify the \`new_string\` content. If modified, this
google_web_search: {
name: WEB_SEARCH_TOOL_NAME,
description:
'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.',
description: `Performs a grounded Google Search to find information across the internet. Returns a synthesized answer with citations (e.g., [1]) and source URIs. Best for finding up-to-date documentation, troubleshooting obscure errors, or broad research. Use this when you don't have a specific URL. If a search result requires deeper analysis, follow up by using '${WEB_FETCH_TOOL_NAME}' on the provided URI.`,
parametersJsonSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to find information on the web.',
description:
"The search query. Supports natural language questions (e.g., 'Latest breaking changes in React 19') or specific technical queries.",
},
},
required: ['query'],
@@ -344,13 +342,13 @@ The user has the ability to modify the \`new_string\` content. If modified, this
web_fetch: {
name: WEB_FETCH_TOOL_NAME,
description:
"Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
"Analyzes and extracts information from up to 20 URLs. Ideal for documentation review, technical research, or reading raw code from GitHub. You can provide specific, complex instructions for the extraction (e.g., 'Summarize the breaking changes'). Provides cited answers based on the content. GitHub 'blob' URLs are automatically converted to raw versions for better processing. Supports HTTP/HTTPS only.",
parametersJsonSchema: {
type: 'object',
properties: {
prompt: {
description:
'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). All URLs to be fetched must be valid and complete, starting with "http://" or "https://", and be fully-formed with a valid hostname (e.g., a domain name like "example.com" or an IP address). For example, "https://example.com" is valid, but "example.com" is not.',
'A string containing the URL(s) and your specific analysis instructions. Be clear about what information you want to find or summarize. Supports up to 20 URLs.',
type: 'string',
},
},
@@ -430,21 +428,14 @@ Use this tool when the user's query implies needing the content of several files
save_memory: {
name: MEMORY_TOOL_NAME,
description: `
Saves concise global user context (preferences, facts) for use across ALL workspaces.
### CRITICAL: GLOBAL CONTEXT ONLY
NEVER save workspace-specific context, local paths, or commands (e.g. "The entry point is src/index.js", "The test command is npm test"). These are local to the current workspace and must NOT be saved globally. EXCLUSIVELY for context relevant across ALL workspaces.
- Use for "Remember X" or clear personal facts.
- Do NOT use for session context.`,
description: `Persists global preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases. Unlike '${WRITE_FILE_TOOL_NAME}', which is for project-specific files, this appends to a global memory file loaded in every workspace. If you are unsure whether a fact should be remembered globally, ask the user first. CRITICAL: Do not use for session-specific context or temporary data.`,
parametersJsonSchema: {
type: 'object',
properties: {
fact: {
type: 'string',
description:
'The specific fact or piece of information to remember. Should be a clear, self-contained statement.',
"A concise, global fact or preference (e.g., 'I prefer using tabs'). Do not include local paths or project-specific names.",
},
},
required: ['fact'],
+49 -15
View File
@@ -934,7 +934,7 @@ function doIt() {
);
});
describe('expected_replacements', () => {
describe('allow_multiple', () => {
const testFile = 'replacements_test.txt';
let filePath: string;
@@ -944,34 +944,70 @@ function doIt() {
it.each([
{
name: 'succeed when occurrences match expected_replacements',
name: 'succeed when allow_multiple is true and there are multiple occurrences',
content: 'foo foo foo',
expected: 3,
allow_multiple: true,
shouldSucceed: true,
finalContent: 'bar bar bar',
},
{
name: 'fail when occurrences do not match expected_replacements',
content: 'foo foo foo',
expected: 2,
shouldSucceed: false,
name: 'succeed when allow_multiple is true and there is exactly 1 occurrence',
content: 'foo',
allow_multiple: true,
shouldSucceed: true,
finalContent: 'bar',
},
{
name: 'default to 1 expected replacement if not specified',
content: 'foo foo',
expected: undefined,
name: 'fail when allow_multiple is false and there are multiple occurrences',
content: 'foo foo foo',
allow_multiple: false,
shouldSucceed: false,
expectedError: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
},
{
name: 'default to 1 expected replacement if allow_multiple not specified',
content: 'foo foo',
allow_multiple: undefined,
shouldSucceed: false,
expectedError: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
},
{
name: 'succeed when allow_multiple is false and there is exactly 1 occurrence',
content: 'foo',
allow_multiple: false,
shouldSucceed: true,
finalContent: 'bar',
},
{
name: 'fail when allow_multiple is true but there are 0 occurrences',
content: 'baz',
allow_multiple: true,
shouldSucceed: false,
expectedError: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,
},
{
name: 'fail when allow_multiple is false but there are 0 occurrences',
content: 'baz',
allow_multiple: false,
shouldSucceed: false,
expectedError: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,
},
])(
'should $name',
async ({ content, expected, shouldSucceed, finalContent }) => {
async ({
content,
allow_multiple,
shouldSucceed,
finalContent,
expectedError,
}) => {
fs.writeFileSync(filePath, content, 'utf8');
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace all foo with bar',
old_string: 'foo',
new_string: 'bar',
...(expected !== undefined && { expected_replacements: expected }),
...(allow_multiple !== undefined && { allow_multiple }),
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
@@ -981,9 +1017,7 @@ function doIt() {
if (finalContent)
expect(fs.readFileSync(filePath, 'utf8')).toBe(finalContent);
} else {
expect(result.error?.type).toBe(
ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
);
expect(result.error?.type).toBe(expectedError);
}
},
);
+22 -25
View File
@@ -138,9 +138,8 @@ async function calculateExactReplacement(
const normalizedReplace = new_string.replace(/\r\n/g, '\n');
const exactOccurrences = normalizedCode.split(normalizedSearch).length - 1;
const expectedReplacements = params.expected_replacements ?? 1;
if (exactOccurrences > expectedReplacements) {
if (!params.allow_multiple && exactOccurrences > 1) {
return {
newContent: currentContent,
occurrences: exactOccurrences,
@@ -256,28 +255,33 @@ async function calculateRegexReplacement(
// The final pattern captures leading whitespace (indentation) and then matches the token pattern.
// 'm' flag enables multi-line mode, so '^' matches the start of any line.
const finalPattern = `^([ \t]*)${pattern}`;
const flexibleRegex = new RegExp(finalPattern, 'm');
const match = flexibleRegex.exec(currentContent);
// Always use a global regex to count all potential occurrences for accurate validation.
const globalRegex = new RegExp(finalPattern, 'gm');
const matches = currentContent.match(globalRegex);
if (!match) {
if (!matches) {
return null;
}
const indentation = match[1] || '';
const occurrences = matches.length;
const newLines = normalizedReplace.split('\n');
const newBlockWithIndent = applyIndentation(newLines, indentation).join('\n');
// Use replace with the regex to substitute the matched content.
// Since the regex doesn't have the 'g' flag, it will only replace the first occurrence.
// Use the appropriate regex for replacement based on allow_multiple.
const replaceRegex = new RegExp(
finalPattern,
params.allow_multiple ? 'gm' : 'm',
);
const modifiedCode = currentContent.replace(
flexibleRegex,
newBlockWithIndent,
replaceRegex,
(_match, indentation) =>
applyIndentation(newLines, indentation || '').join('\n'),
);
return {
newContent: restoreTrailingNewline(currentContent, modifiedCode),
occurrences: 1, // This method is designed to find and replace only the first occurrence.
occurrences,
finalOldString: normalizedSearch,
finalNewString: normalizedReplace,
};
@@ -341,7 +345,6 @@ export async function calculateReplacement(
export function getErrorReplaceResult(
params: EditToolParams,
occurrences: number,
expectedReplacements: number,
finalOldString: string,
finalNewString: string,
) {
@@ -353,13 +356,10 @@ export function getErrorReplaceResult(
raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${READ_FILE_TOOL_NAME} tool to verify.`,
type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,
};
} else if (occurrences !== expectedReplacements) {
const occurrenceTerm =
expectedReplacements === 1 ? 'occurrence' : 'occurrences';
} else if (!params.allow_multiple && occurrences !== 1) {
error = {
display: `Failed to edit, expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences}.`,
raw: `Failed to edit, Expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences} for old_string in file: ${params.file_path}`,
display: `Failed to edit, expected 1 occurrence but found ${occurrences}.`,
raw: `Failed to edit, Expected 1 occurrence but found ${occurrences} for old_string in file: ${params.file_path}. If you intended to replace multiple occurrences, set 'allow_multiple' to true.`,
type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
};
} else if (finalOldString === finalNewString) {
@@ -392,10 +392,10 @@ export interface EditToolParams {
new_string: string;
/**
* Number of replacements expected. Defaults to 1 if not specified.
* Use when you want to replace multiple occurrences.
* If true, the tool will replace all occurrences of `old_string` with `new_string`.
* If false (default), the tool will only succeed if exactly one occurrence is found.
*/
expected_replacements?: number;
allow_multiple?: boolean;
/**
* The instruction for what needs to be done.
@@ -517,7 +517,6 @@ class EditToolInvocation
const secondError = getErrorReplaceResult(
params,
secondAttemptResult.occurrences,
params.expected_replacements ?? 1,
secondAttemptResult.finalOldString,
secondAttemptResult.finalNewString,
);
@@ -562,7 +561,6 @@ class EditToolInvocation
params: EditToolParams,
abortSignal: AbortSignal,
): Promise<CalculatedEdit> {
const expectedReplacements = params.expected_replacements ?? 1;
let currentContent: string | null = null;
let fileExists = false;
let originalLineEnding: '\r\n' | '\n' = '\n'; // Default for new files
@@ -649,7 +647,6 @@ class EditToolInvocation
const initialError = getErrorReplaceResult(
params,
replacementResult.occurrences,
expectedReplacements,
replacementResult.finalOldString,
replacementResult.finalNewString,
);
@@ -1704,6 +1704,40 @@ describe('mcp-client', () => {
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined();
});
it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
const originalEnv = process.env;
process.env = {
...originalEnv,
GEMINI_TEST_VAR: 'expanded-value',
};
try {
await createTransport(
'test-server',
{
command: 'test-command',
env: {
TEST_EXPANDED: 'Value is $GEMINI_TEST_VAR',
SECRET_KEY: 'intentional-secret-123',
},
},
false,
EMPTY_CONFIG,
);
const callArgs = mockedTransport.mock.calls[0][0];
expect(callArgs.env).toBeDefined();
expect(callArgs.env!['TEST_EXPANDED']).toBe('Value is expanded-value');
expect(callArgs.env!['SECRET_KEY']).toBe('intentional-secret-123');
} finally {
process.env = originalEnv;
}
});
describe('useGoogleCredentialProvider', () => {
beforeEach(() => {
// Mock GoogleAuth client
+33 -7
View File
@@ -70,6 +70,7 @@ import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from '../services/environmentSanitization.js';
import { expandEnvVars } from '../utils/envExpansion.js';
import {
GEMINI_CLI_IDENTIFICATION_ENV_VAR,
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
@@ -783,9 +784,16 @@ function createTransportRequestInit(
mcpServerConfig: MCPServerConfig,
headers: Record<string, string>,
): RequestInit {
const expandedHeaders: Record<string, string> = {};
if (mcpServerConfig.headers) {
for (const [key, value] of Object.entries(mcpServerConfig.headers)) {
expandedHeaders[key] = expandEnvVars(value, process.env);
}
}
return {
headers: {
...mcpServerConfig.headers,
...expandedHeaders,
...headers,
},
};
@@ -1970,15 +1978,33 @@ export async function createTransport(
}
if (mcpServerConfig.command) {
// 1. Sanitize the base process environment to prevent unintended leaks of system-wide secrets.
const sanitizedEnv = sanitizeEnvironment(process.env, {
...sanitizationConfig,
enableEnvironmentVariableRedaction: true,
});
const finalEnv: Record<string, string> = {
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
};
for (const [key, value] of Object.entries(sanitizedEnv)) {
if (value !== undefined) {
finalEnv[key] = value;
}
}
// Expand and merge explicit environment variables from the MCP configuration.
if (mcpServerConfig.env) {
for (const [key, value] of Object.entries(mcpServerConfig.env)) {
finalEnv[key] = expandEnvVars(value, process.env);
}
}
let transport: Transport = new StdioClientTransport({
command: mcpServerConfig.command,
args: mcpServerConfig.args || [],
env: {
...sanitizeEnvironment(process.env, sanitizationConfig),
...(mcpServerConfig.env || {}),
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
} as Record<string, string>,
env: finalEnv,
cwd: mcpServerConfig.cwd,
stderr: 'pipe',
});
+7
View File
@@ -80,6 +80,8 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
readonly trust?: boolean,
params: ToolParams = {},
private readonly cliConfig?: Config,
private readonly toolDescription?: string,
private readonly toolParameterSchema?: unknown,
) {
// Use composite format for policy checks: serverName__toolName
// This enables server wildcards (e.g., "google-workspace__*")
@@ -123,6 +125,9 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
serverName: this.serverName,
toolName: this.serverToolName, // Display original tool name in confirmation
toolDisplayName: this.displayName, // Display global registry name exposed to model and user
toolArgs: this.params,
toolDescription: this.toolDescription,
toolParameterSchema: this.toolParameterSchema,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey);
@@ -317,6 +322,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.trust,
params,
this.cliConfig,
this.description,
this.parameterSchema,
);
}
}
@@ -37,6 +37,11 @@ vi.mock('node:fs/promises', async (importOriginal) => {
vi.mock('fs', () => ({
mkdirSync: vi.fn(),
createWriteStream: vi.fn(() => ({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
})),
}));
vi.mock('os');
+3
View File
@@ -757,6 +757,9 @@ export interface ToolMcpConfirmationDetails {
serverName: string;
toolName: string;
toolDisplayName: string;
toolArgs?: Record<string, unknown>;
toolDescription?: string;
toolParameterSchema?: unknown;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
}
+369 -36
View File
@@ -5,7 +5,11 @@
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { WebFetchTool, parsePrompt } from './web-fetch.js';
import {
WebFetchTool,
parsePrompt,
convertGithubUrlToRaw,
} from './web-fetch.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { ToolConfirmationOutcome } from './tools.js';
@@ -55,6 +59,72 @@ vi.mock('node:crypto', () => ({
randomUUID: vi.fn(),
}));
/**
* Helper to mock fetchWithTimeout with URL matching.
*/
const mockFetch = (url: string, response: Partial<Response> | Error) =>
vi
.spyOn(fetchUtils, 'fetchWithTimeout')
.mockImplementation(async (actualUrl) => {
if (actualUrl !== url) {
throw new Error(
`Unexpected fetch URL: expected "${url}", got "${actualUrl}"`,
);
}
if (response instanceof Error) {
throw response;
}
const headers = response.headers || new Headers();
// If we have text/arrayBuffer but no body, create a body mock
let body = response.body;
if (!body) {
let content: Uint8Array | undefined;
if (response.text) {
const text = await response.text();
content = new TextEncoder().encode(text);
} else if (response.arrayBuffer) {
const ab = await response.arrayBuffer();
content = new Uint8Array(ab);
}
if (content) {
body = {
getReader: () => {
let sent = false;
return {
read: async () => {
if (sent) return { done: true, value: undefined };
sent = true;
return { done: false, value: content };
},
releaseLock: () => {},
cancel: async () => {},
};
},
} as unknown as ReadableStream;
}
}
return {
ok: response.status ? response.status < 400 : true,
status: 200,
headers,
text: response.text || (() => Promise.resolve('')),
arrayBuffer:
response.arrayBuffer || (() => Promise.resolve(new ArrayBuffer(0))),
body: body || {
getReader: () => ({
read: async () => ({ done: true, value: undefined }),
releaseLock: () => {},
cancel: async () => {},
}),
},
...response,
} as unknown as Response;
});
describe('parsePrompt', () => {
it('should extract valid URLs separated by whitespace', () => {
const prompt = 'Go to https://example.com and http://google.com';
@@ -128,6 +198,42 @@ describe('parsePrompt', () => {
});
});
describe('convertGithubUrlToRaw', () => {
it('should convert valid github blob urls', () => {
expect(
convertGithubUrlToRaw('https://github.com/user/repo/blob/main/README.md'),
).toBe('https://raw.githubusercontent.com/user/repo/main/README.md');
});
it('should not convert non-blob github urls', () => {
expect(convertGithubUrlToRaw('https://github.com/user/repo')).toBe(
'https://github.com/user/repo',
);
});
it('should not convert urls with similar domain names', () => {
expect(
convertGithubUrlToRaw('https://mygithub.com/user/repo/blob/main'),
).toBe('https://mygithub.com/user/repo/blob/main');
});
it('should only replace the /blob/ that separates repo from branch', () => {
expect(
convertGithubUrlToRaw('https://github.com/blob/repo/blob/main/test.ts'),
).toBe('https://raw.githubusercontent.com/blob/repo/main/test.ts');
});
it('should not convert urls if blob is not in path', () => {
expect(
convertGithubUrlToRaw('https://github.com/user/repo/tree/main'),
).toBe('https://github.com/user/repo/tree/main');
});
it('should handle invalid urls gracefully', () => {
expect(convertGithubUrlToRaw('not-a-url')).toBe('not-a-url');
});
});
describe('WebFetchTool', () => {
let mockConfig: Config;
let bus: MessageBus;
@@ -142,6 +248,7 @@ describe('WebFetchTool', () => {
getProxy: vi.fn(),
getGeminiClient: mockGetGeminiClient,
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getDirectWebFetch: vi.fn().mockReturnValue(false),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation(({ model }) => ({
model,
@@ -153,32 +260,79 @@ describe('WebFetchTool', () => {
});
describe('validateToolParamValues', () => {
it.each([
{
name: 'empty prompt',
prompt: '',
expectedError: "The 'prompt' parameter cannot be empty",
},
{
name: 'prompt with no URLs',
prompt: 'hello world',
expectedError: "The 'prompt' must contain at least one valid URL",
},
{
name: 'prompt with malformed URLs',
prompt: 'fetch httpshttps://example.com',
expectedError: 'Error(s) in prompt URLs:',
},
])('should throw if $name', ({ prompt, expectedError }) => {
const tool = new WebFetchTool(mockConfig, bus);
expect(() => tool.build({ prompt })).toThrow(expectedError);
describe('standard mode', () => {
it.each([
{
name: 'empty prompt',
prompt: '',
expectedError: "The 'prompt' parameter cannot be empty",
},
{
name: 'prompt with no URLs',
prompt: 'hello world',
expectedError: "The 'prompt' must contain at least one valid URL",
},
{
name: 'prompt with malformed URLs',
prompt: 'fetch httpshttps://example.com',
expectedError: 'Error(s) in prompt URLs:',
},
])('should throw if $name', ({ prompt, expectedError }) => {
const tool = new WebFetchTool(mockConfig, bus);
expect(() => tool.build({ prompt })).toThrow(expectedError);
});
it('should pass if prompt contains at least one valid URL', () => {
const tool = new WebFetchTool(mockConfig, bus);
expect(() =>
tool.build({ prompt: 'fetch https://example.com' }),
).not.toThrow();
});
});
it('should pass if prompt contains at least one valid URL', () => {
describe('experimental mode', () => {
beforeEach(() => {
vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
});
it('should throw if url is missing', () => {
const tool = new WebFetchTool(mockConfig, bus);
expect(() => tool.build({ prompt: 'foo' })).toThrow(
"params must have required property 'url'",
);
});
it('should throw if url is invalid', () => {
const tool = new WebFetchTool(mockConfig, bus);
expect(() => tool.build({ url: 'not-a-url' })).toThrow(
'Invalid URL: "not-a-url"',
);
});
it('should pass if url is valid', () => {
const tool = new WebFetchTool(mockConfig, bus);
expect(() => tool.build({ url: 'https://example.com' })).not.toThrow();
});
});
});
describe('getSchema', () => {
it('should return standard schema by default', () => {
const tool = new WebFetchTool(mockConfig, bus);
expect(() =>
tool.build({ prompt: 'fetch https://example.com' }),
).not.toThrow();
const schema = tool.getSchema();
expect(schema.parametersJsonSchema).toHaveProperty('properties.prompt');
expect(schema.parametersJsonSchema).not.toHaveProperty('properties.url');
});
it('should return experimental schema when enabled', () => {
vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
const tool = new WebFetchTool(mockConfig, bus);
const schema = tool.getSchema();
expect(schema.parametersJsonSchema).toHaveProperty('properties.url');
expect(schema.parametersJsonSchema).not.toHaveProperty(
'properties.prompt',
);
expect(schema.parametersJsonSchema).toHaveProperty('required', ['url']);
});
});
@@ -205,9 +359,7 @@ describe('WebFetchTool', () => {
it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(
new Error('fetch failed'),
);
mockFetch('https://private.ip/', new Error('fetch failed'));
const tool = new WebFetchTool(mockConfig, bus);
const params = { prompt: 'fetch https://private.ip' };
const invocation = tool.build(params);
@@ -228,10 +380,9 @@ describe('WebFetchTool', () => {
it('should log telemetry when falling back due to private IP', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
// Mock fetchWithTimeout to succeed so fallback proceeds
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({
ok: true,
mockFetch('https://private.ip/', {
text: () => Promise.resolve('some content'),
} as Response);
});
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'fallback response' }] } }],
});
@@ -255,10 +406,9 @@ describe('WebFetchTool', () => {
candidates: [],
});
// Mock fetchWithTimeout to succeed so fallback proceeds
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({
ok: true,
mockFetch('https://public.ip/', {
text: () => Promise.resolve('some content'),
} as Response);
});
// Mock fallback LLM call
mockGenerateContent.mockResolvedValueOnce({
candidates: [{ content: { parts: [{ text: 'fallback response' }] } }],
@@ -320,11 +470,10 @@ describe('WebFetchTool', () => {
? new Headers({ 'content-type': contentType })
: new Headers();
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({
ok: true,
mockFetch('https://example.com/', {
headers,
text: () => Promise.resolve(content),
} as Response);
});
// Mock fallback LLM call to return the content passed to it
mockGenerateContent.mockImplementationOnce(async (_, req) => ({
@@ -373,6 +522,24 @@ describe('WebFetchTool', () => {
});
});
it('should handle URL param in confirmation details', async () => {
vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
const tool = new WebFetchTool(mockConfig, bus);
const params = { url: 'https://example.com' };
const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmationDetails).toEqual({
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'Fetch https://example.com',
urls: ['https://example.com'],
onConfirm: expect.any(Function),
});
});
it('should convert github urls to raw format', async () => {
const tool = new WebFetchTool(mockConfig, bus);
const params = {
@@ -601,4 +768,170 @@ describe('WebFetchTool', () => {
expect(result.llmContent).toContain('Fetched content');
});
});
describe('execute (experimental)', () => {
beforeEach(() => {
vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true);
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
});
it('should perform direct fetch and return text for plain text content', async () => {
const content = 'Plain text content';
mockFetch('https://example.com/', {
status: 200,
headers: new Headers({ 'content-type': 'text/plain' }),
text: () => Promise.resolve(content),
});
const tool = new WebFetchTool(mockConfig, bus);
const params = { url: 'https://example.com' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toBe(content);
expect(result.returnDisplay).toContain('Fetched text/plain content');
expect(fetchUtils.fetchWithTimeout).toHaveBeenCalledWith(
'https://example.com/',
expect.any(Number),
expect.objectContaining({
headers: expect.objectContaining({
Accept: expect.stringContaining('text/plain'),
}),
}),
);
});
it('should use html-to-text and preserve links for HTML content', async () => {
const content =
'<html><body><a href="https://link.com">Link</a></body></html>';
mockFetch('https://example.com/', {
status: 200,
headers: new Headers({ 'content-type': 'text/html' }),
text: () => Promise.resolve(content),
});
const tool = new WebFetchTool(mockConfig, bus);
const params = { url: 'https://example.com' };
const invocation = tool.build(params);
await invocation.execute(new AbortController().signal);
expect(convert).toHaveBeenCalledWith(
content,
expect.objectContaining({
selectors: [
expect.objectContaining({
selector: 'a',
options: { ignoreHref: false, baseUrl: 'https://example.com/' },
}),
],
}),
);
});
it('should return base64 for image content', async () => {
const buffer = Buffer.from('fake-image-data');
mockFetch('https://example.com/image.png', {
status: 200,
headers: new Headers({ 'content-type': 'image/png' }),
arrayBuffer: () =>
Promise.resolve(
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
),
),
});
const tool = new WebFetchTool(mockConfig, bus);
const params = { url: 'https://example.com/image.png' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toEqual({
inlineData: {
data: buffer.toString('base64'),
mimeType: 'image/png',
},
});
});
it('should return raw response info for 4xx/5xx errors', async () => {
const errorBody = 'Not Found';
mockFetch('https://example.com/404', {
status: 404,
headers: new Headers({ 'x-test': 'val' }),
text: () => Promise.resolve(errorBody),
});
const tool = new WebFetchTool(mockConfig, bus);
const params = { url: 'https://example.com/404' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Request failed with status 404');
expect(result.llmContent).toContain('val');
expect(result.llmContent).toContain(errorBody);
expect(result.returnDisplay).toContain('Failed to fetch');
});
it('should throw error if Content-Length exceeds limit', async () => {
mockFetch('https://example.com/large', {
headers: new Headers({
'content-length': (11 * 1024 * 1024).toString(),
}),
});
const tool = new WebFetchTool(mockConfig, bus);
const invocation = tool.build({ url: 'https://example.com/large' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Error');
expect(result.llmContent).toContain('exceeds size limit');
});
it('should throw error if stream exceeds limit', async () => {
const largeChunk = new Uint8Array(11 * 1024 * 1024);
mockFetch('https://example.com/large-stream', {
body: {
getReader: () => ({
read: vi
.fn()
.mockResolvedValueOnce({ done: false, value: largeChunk })
.mockResolvedValueOnce({ done: true }),
releaseLock: vi.fn(),
cancel: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as ReadableStream,
});
const tool = new WebFetchTool(mockConfig, bus);
const invocation = tool.build({
url: 'https://example.com/large-stream',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Error');
expect(result.llmContent).toContain('exceeds size limit');
});
it('should return error if url is missing (experimental)', async () => {
const tool = new WebFetchTool(mockConfig, bus);
// Manually bypass build() validation to test executeExperimental safety check
const invocation = tool['createInvocation']({}, bus);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Error: No URL provided.');
expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
});
it('should return error if url is invalid (experimental)', async () => {
const tool = new WebFetchTool(mockConfig, bus);
// Manually bypass build() validation to test executeExperimental safety check
const invocation = tool['createInvocation']({ url: 'not-a-url' }, bus);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Error: Invalid URL "not-a-url"');
expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
});
});
});
+278 -27
View File
@@ -18,6 +18,7 @@ import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { getResponseText } from '../utils/partUtils.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { truncateString } from '../utils/textUtils.js';
import { convert } from 'html-to-text';
import {
logWebFetchFallbackAttempt,
@@ -33,6 +34,10 @@ import { LRUCache } from 'mnemonist';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
const MAX_EXPERIMENTAL_FETCH_SIZE = 10 * 1024 * 1024; // 10MB
const USER_AGENT =
'Mozilla/5.0 (compatible; Google-Gemini-CLI/1.0; +https://github.com/google-gemini/gemini-cli)';
const TRUNCATION_WARNING = '\n\n... [Content truncated due to size limit] ...';
// Rate limiting configuration
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
@@ -107,6 +112,23 @@ export function parsePrompt(text: string): {
return { validUrls, errors };
}
/**
* Safely converts a GitHub blob URL to a raw content URL.
*/
export function convertGithubUrlToRaw(urlStr: string): string {
try {
const url = new URL(urlStr);
if (url.hostname === 'github.com' && url.pathname.includes('/blob/')) {
url.hostname = 'raw.githubusercontent.com';
url.pathname = url.pathname.replace(/^\/([^/]+\/[^/]+)\/blob\//, '/$1/');
return url.href;
}
} catch {
// Ignore invalid URLs
}
return urlStr;
}
// Interfaces for grounding metadata (similar to web-search.ts)
interface GroundingChunkWeb {
uri?: string;
@@ -135,7 +157,11 @@ export interface WebFetchToolParams {
/**
* The prompt containing URL(s) (up to 20) and instructions for processing their content.
*/
prompt: string;
prompt?: string;
/**
* Direct URL to fetch (experimental mode).
*/
url?: string;
}
interface ErrorWithStatus extends Error {
@@ -157,21 +183,22 @@ class WebFetchToolInvocation extends BaseToolInvocation<
}
private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
const { validUrls: urls } = parsePrompt(this.params.prompt);
const { validUrls: urls } = parsePrompt(this.params.prompt!);
// For now, we only support one URL for fallback
let url = urls[0];
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
url = url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
url = convertGithubUrlToRaw(url);
try {
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS);
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
'User-Agent': USER_AGENT,
},
});
if (!res.ok) {
const error = new Error(
`Request failed with status code ${res.status} ${res.statusText}`,
@@ -186,7 +213,11 @@ class WebFetchToolInvocation extends BaseToolInvocation<
},
);
const rawContent = await response.text();
const bodyBuffer = await this.readResponseWithLimit(
response,
MAX_EXPERIMENTAL_FETCH_SIZE,
);
const rawContent = bodyBuffer.toString('utf8');
const contentType = response.headers.get('content-type') || '';
let textContent: string;
@@ -207,7 +238,11 @@ class WebFetchToolInvocation extends BaseToolInvocation<
textContent = rawContent;
}
textContent = textContent.substring(0, MAX_CONTENT_LENGTH);
textContent = truncateString(
textContent,
MAX_CONTENT_LENGTH,
TRUNCATION_WARNING,
);
const geminiClient = this.config.getGeminiClient();
const fallbackPrompt = `The user requested the following: "${this.params.prompt}".
@@ -245,10 +280,12 @@ ${textContent}
}
getDescription(): string {
if (this.params.url) {
return `Fetching content from: ${this.params.url}`;
}
const prompt = this.params.prompt || '';
const displayPrompt =
this.params.prompt.length > 100
? this.params.prompt.substring(0, 97) + '...'
: this.params.prompt;
prompt.length > 100 ? prompt.substring(0, 97) + '...' : prompt;
return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
}
@@ -261,22 +298,24 @@ ${textContent}
return false;
}
// Perform GitHub URL conversion here to differentiate between user-provided
// URL and the actual URL to be fetched.
const { validUrls } = parsePrompt(this.params.prompt);
const urls = validUrls.map((url) => {
if (url.includes('github.com') && url.includes('/blob/')) {
return url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
return url;
});
let urls: string[] = [];
let prompt = this.params.prompt || '';
if (this.params.url) {
urls = [this.params.url];
prompt = `Fetch ${this.params.url}`;
} else if (this.params.prompt) {
const { validUrls } = parsePrompt(this.params.prompt);
urls = validUrls;
}
// Perform GitHub URL conversion here
urls = urls.map((url) => convertGithubUrlToRaw(url));
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: this.params.prompt,
prompt,
urls,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Mode transitions (e.g. AUTO_EDIT) and policy updates are now
@@ -286,8 +325,189 @@ ${textContent}
return confirmationDetails;
}
private async readResponseWithLimit(
response: Response,
limit: number,
): Promise<Buffer> {
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > limit) {
throw new Error(`Content exceeds size limit of ${limit} bytes`);
}
if (!response.body) {
return Buffer.alloc(0);
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalLength = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalLength += value.length;
if (totalLength > limit) {
// Attempt to cancel the reader to stop the stream
await reader.cancel().catch(() => {});
throw new Error(`Content exceeds size limit of ${limit} bytes`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks);
}
private async executeExperimental(signal: AbortSignal): Promise<ToolResult> {
if (!this.params.url) {
return {
llmContent: 'Error: No URL provided.',
returnDisplay: 'Error: No URL provided.',
error: {
message: 'No URL provided.',
type: ToolErrorType.INVALID_TOOL_PARAMS,
},
};
}
let url: string;
try {
url = new URL(this.params.url).href;
} catch {
return {
llmContent: `Error: Invalid URL "${this.params.url}"`,
returnDisplay: `Error: Invalid URL "${this.params.url}"`,
error: {
message: `Invalid URL "${this.params.url}"`,
type: ToolErrorType.INVALID_TOOL_PARAMS,
},
};
}
// Convert GitHub blob URL to raw URL
url = convertGithubUrlToRaw(url);
try {
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
Accept:
'text/markdown, text/plain;q=0.9, application/json;q=0.9, text/html;q=0.8, application/pdf;q=0.7, video/*;q=0.7, */*;q=0.5',
'User-Agent': USER_AGENT,
},
});
return res;
},
{
retryFetchErrors: this.config.getRetryFetchErrors(),
},
);
const contentType = response.headers.get('content-type') || '';
const status = response.status;
const bodyBuffer = await this.readResponseWithLimit(
response,
MAX_EXPERIMENTAL_FETCH_SIZE,
);
if (status >= 400) {
const rawResponseText = bodyBuffer.toString('utf8');
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const errorContent = `Request failed with status ${status}
Headers: ${JSON.stringify(headers, null, 2)}
Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response truncated] ...')}`;
return {
llmContent: errorContent,
returnDisplay: `Failed to fetch ${url} (Status: ${status})`,
};
}
const lowContentType = contentType.toLowerCase();
if (
lowContentType.includes('text/markdown') ||
lowContentType.includes('text/plain') ||
lowContentType.includes('application/json')
) {
const text = truncateString(
bodyBuffer.toString('utf8'),
MAX_CONTENT_LENGTH,
TRUNCATION_WARNING,
);
return {
llmContent: text,
returnDisplay: `Fetched ${contentType} content from ${url}`,
};
}
if (lowContentType.includes('text/html')) {
const html = bodyBuffer.toString('utf8');
const textContent = truncateString(
convert(html, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: false, baseUrl: url } },
],
}),
MAX_CONTENT_LENGTH,
TRUNCATION_WARNING,
);
return {
llmContent: textContent,
returnDisplay: `Fetched and converted HTML content from ${url}`,
};
}
if (
lowContentType.startsWith('image/') ||
lowContentType.startsWith('video/') ||
lowContentType === 'application/pdf'
) {
const base64Data = bodyBuffer.toString('base64');
return {
llmContent: {
inlineData: {
data: base64Data,
mimeType: contentType.split(';')[0],
},
},
returnDisplay: `Fetched ${contentType} from ${url}`,
};
}
// Fallback for unknown types - try as text
const text = truncateString(
bodyBuffer.toString('utf8'),
MAX_CONTENT_LENGTH,
TRUNCATION_WARNING,
);
return {
llmContent: text,
returnDisplay: `Fetched ${contentType || 'unknown'} content from ${url}`,
};
} catch (e) {
const errorMessage = `Error during experimental fetch for ${url}: ${getErrorMessage(e)}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_FALLBACK_FAILED,
},
};
}
}
async execute(signal: AbortSignal): Promise<ToolResult> {
const userPrompt = this.params.prompt;
if (this.config.getDirectWebFetch()) {
return this.executeExperimental(signal);
}
const userPrompt = this.params.prompt!;
const { validUrls: urls } = parsePrompt(userPrompt);
const url = urls[0];
@@ -475,6 +695,18 @@ export class WebFetchTool extends BaseDeclarativeTool<
protected override validateToolParamValues(
params: WebFetchToolParams,
): string | null {
if (this.config.getDirectWebFetch()) {
if (!params.url) {
return "The 'url' parameter is required.";
}
try {
new URL(params.url);
} catch {
return `Invalid URL: "${params.url}"`;
}
return null;
}
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions.";
}
@@ -508,6 +740,25 @@ export class WebFetchTool extends BaseDeclarativeTool<
}
override getSchema(modelId?: string) {
return resolveToolDeclaration(WEB_FETCH_DEFINITION, modelId);
const schema = resolveToolDeclaration(WEB_FETCH_DEFINITION, modelId);
if (this.config.getDirectWebFetch()) {
return {
...schema,
description:
'Fetch content from a URL directly. Send multiple requests for this tool if multiple URL fetches are needed.',
parametersJsonSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description:
'The URL to fetch. Must be a valid http or https URL.',
},
},
required: ['url'],
},
};
}
return schema;
}
}
+26 -32
View File
@@ -185,12 +185,16 @@ export async function ensureCorrectEdit(
unescapeStringForGeminiBug(originalParams.new_string) !==
originalParams.new_string;
const expectedReplacements = originalParams.expected_replacements ?? 1;
const allowMultiple = originalParams.allow_multiple ?? false;
let finalOldString = originalParams.old_string;
let occurrences = countOccurrences(currentContent, finalOldString);
if (occurrences === expectedReplacements) {
const isOccurrencesMatch = allowMultiple
? occurrences > 0
: occurrences === 1;
if (isOccurrencesMatch) {
if (newStringPotentiallyEscaped && !disableLLMCorrection) {
finalNewString = await correctNewStringEscaping(
baseLlmClient,
@@ -199,30 +203,8 @@ export async function ensureCorrectEdit(
abortSignal,
);
}
} else if (occurrences > expectedReplacements) {
const expectedReplacements = originalParams.expected_replacements ?? 1;
// If user expects multiple replacements, return as-is
if (occurrences === expectedReplacements) {
const result: CorrectedEditResult = {
params: { ...originalParams },
occurrences,
};
editCorrectionCache.set(cacheKey, result);
return result;
}
// If user expects 1 but found multiple, try to correct (existing behavior)
if (expectedReplacements === 1) {
const result: CorrectedEditResult = {
params: { ...originalParams },
occurrences,
};
editCorrectionCache.set(cacheKey, result);
return result;
}
// If occurrences don't match expected, return as-is (will fail validation later)
} else if (occurrences > 1 && !allowMultiple) {
// If user doesn't allow multiple but found multiple, return as-is (will fail validation later)
const result: CorrectedEditResult = {
params: { ...originalParams },
occurrences,
@@ -236,7 +218,11 @@ export async function ensureCorrectEdit(
);
occurrences = countOccurrences(currentContent, unescapedOldStringAttempt);
if (occurrences === expectedReplacements) {
const isUnescapedOccurrencesMatch = allowMultiple
? occurrences > 0
: occurrences === 1;
if (isUnescapedOccurrencesMatch) {
finalOldString = unescapedOldStringAttempt;
if (newStringPotentiallyEscaped && !disableLLMCorrection) {
finalNewString = await correctNewString(
@@ -296,7 +282,11 @@ export async function ensureCorrectEdit(
llmCorrectedOldString,
);
if (llmOldOccurrences === expectedReplacements) {
const isLlmOccurrencesMatch = allowMultiple
? llmOldOccurrences > 0
: llmOldOccurrences === 1;
if (isLlmOccurrencesMatch) {
finalOldString = llmCorrectedOldString;
occurrences = llmOldOccurrences;
@@ -322,7 +312,7 @@ export async function ensureCorrectEdit(
return result;
}
} else {
// Unescaping old_string resulted in > 1 occurrence
// Unescaping old_string resulted in > 1 occurrence but not allowMultiple
const result: CorrectedEditResult = {
params: { ...originalParams },
occurrences, // This will be > 1
@@ -336,7 +326,7 @@ export async function ensureCorrectEdit(
finalOldString,
finalNewString,
currentContent,
expectedReplacements,
allowMultiple,
);
finalOldString = targetString;
finalNewString = pair;
@@ -705,7 +695,7 @@ function trimPairIfPossible(
target: string,
trimIfTargetTrims: string,
currentContent: string,
expectedReplacements: number,
allowMultiple: boolean,
) {
const trimmedTargetString = trimPreservingTrailingNewline(target);
if (target.length !== trimmedTargetString.length) {
@@ -714,7 +704,11 @@ function trimPairIfPossible(
trimmedTargetString,
);
if (trimmedTargetOccurrences === expectedReplacements) {
const isMatch = allowMultiple
? trimmedTargetOccurrences > 0
: trimmedTargetOccurrences === 1;
if (isMatch) {
const trimmedReactiveString =
trimPreservingTrailingNewline(trimIfTargetTrims);
return {
@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { expandEnvVars } from './envExpansion.js';
describe('expandEnvVars', () => {
const defaultEnv = {
USER: 'morty',
HOME: '/home/morty',
TEMP: 'C:\\Temp',
EMPTY: '',
};
describe('POSIX behavior (non-Windows)', () => {
beforeEach(() => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'],
[
'${VAR} (POSIX)',
'Welcome to ${HOME}',
defaultEnv,
'Welcome to /home/morty',
],
[
'should NOT expand %VAR% on non-Windows',
'Data in %TEMP%',
defaultEnv,
'Data in %TEMP%',
],
[
'mixed formats (only POSIX expanded)',
'$USER lives in ${HOME} on %TEMP%',
defaultEnv,
'morty lives in /home/morty on %TEMP%',
],
[
'missing variables (POSIX only)',
'Missing $UNDEFINED and ${NONE} and %MISSING%',
defaultEnv,
'Missing and and %MISSING%',
],
[
'empty or undefined values',
'Value is "$EMPTY"',
defaultEnv,
'Value is ""',
],
[
'original string if no variables',
'No vars here',
defaultEnv,
'No vars here',
],
['literal values like "1234"', '1234', defaultEnv, '1234'],
['empty input string', '', defaultEnv, ''],
[
'complex paths',
'${HOME}/bin:$PATH',
{ ...defaultEnv, PATH: '/usr/bin' },
'/home/morty/bin:/usr/bin',
],
])('should handle %s', (_, input, env, expected) => {
expect(expandEnvVars(input, env)).toBe(expected);
});
});
describe('Windows behavior', () => {
beforeEach(() => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'],
[
'${VAR} (POSIX)',
'Welcome to ${HOME}',
defaultEnv,
'Welcome to /home/morty',
],
[
'should expand %VAR% on Windows',
'Data in %TEMP%',
defaultEnv,
'Data in C:\\Temp',
],
[
'mixed formats (both expanded)',
'$USER lives in ${HOME} on %TEMP%',
defaultEnv,
'morty lives in /home/morty on C:\\Temp',
],
[
'missing variables (all expanded to empty)',
'Missing $UNDEFINED and ${NONE} and %MISSING%',
defaultEnv,
'Missing and and ',
],
])('should handle %s', (_, input, env, expected) => {
expect(expandEnvVars(input, env)).toBe(expected);
});
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expand } from 'dotenv-expand';
/**
* Expands environment variables in a string using the provided environment record.
* Uses the standard `dotenv-expand` library to handle expansion consistently with
* other tools.
*
* Supports POSIX/Bash syntax ($VAR, ${VAR}).
* Note: Windows syntax (%VAR%) is not natively supported by dotenv-expand.
*
* @param str - The string containing environment variable placeholders.
* @param env - A record of environment variable names and their values.
* @returns The string with environment variables expanded. Missing variables resolve to an empty string.
*/
export function expandEnvVars(
str: string,
env: Record<string, string | undefined>,
): string {
if (!str) return str;
// 1. Pre-process Windows-style variables (%VAR%) since dotenv-expand only handles POSIX ($VAR).
// We only do this on Windows to limit the blast radius and avoid conflicts with other
// systems where % might be a literal character (e.g. in URLs or shell commands).
const isWindows = process.platform === 'win32';
const processedStr = isWindows
? str.replace(/%(\w+)%/g, (_, name) => env[name] ?? '')
: str;
// 2. Use dotenv-expand for POSIX/Bash syntax ($VAR, ${VAR}).
// dotenv-expand is designed to process an object of key-value pairs (like a .env file).
// To expand a single string, we wrap it in an object with a temporary key.
const dummyKey = '__GCLI_EXPAND_TARGET__';
// Filter out undefined values to satisfy the Record<string, string> requirement safely
const processEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
processEnv[key] = value;
}
}
const result = expand({
parsed: { [dummyKey]: processedStr },
processEnv,
});
return result.parsed?.[dummyKey] ?? '';
}
+15 -1
View File
@@ -41,12 +41,26 @@ export function isPrivateIp(url: string): boolean {
export async function fetchWithTimeout(
url: string,
timeout: number,
options?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
if (options?.signal) {
if (options.signal.aborted) {
controller.abort();
} else {
options.signal.addEventListener('abort', () => controller.abort(), {
once: true,
});
}
}
try {
const response = await fetch(url, { signal: controller.signal });
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (isNodeError(error) && error.code === 'ABORT_ERR') {
+2 -2
View File
@@ -34,7 +34,7 @@ SOFTWARE.
License text not found.
============================================================
ajv@6.12.6
ajv@6.14.0
(https://github.com/ajv-validator/ajv.git)
The MIT License (MIT)
@@ -2241,7 +2241,7 @@ THE SOFTWARE.
============================================================
hono@4.11.9
hono@4.12.2
(git+https://github.com/honojs/hono.git)
MIT License
+14
View File
@@ -128,6 +128,13 @@
"default": false,
"type": "boolean"
},
"maxAttempts": {
"title": "Max Chat Model Attempts",
"description": "Maximum number of attempts for requests to the main chat model. Cannot exceed 10.",
"markdownDescription": "Maximum number of attempts for requests to the main chat model. Cannot exceed 10.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `10`",
"default": 10,
"type": "number"
},
"debugKeystrokeLogging": {
"title": "Debug Keystroke Logging",
"description": "Enable debug logging of keystrokes to the console.",
@@ -1629,6 +1636,13 @@
"markdownDescription": "Enable model steering (user hints) to guide the model during tool execution.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"directWebFetch": {
"title": "Direct Web Fetch",
"description": "Enable web fetch behavior that bypasses LLM summarization.",
"markdownDescription": "Enable web fetch behavior that bypasses LLM summarization.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
+72 -23
View File
@@ -45,6 +45,13 @@ function getPlatformArch() {
shellcheck: 'darwin.aarch64',
};
}
if (platform === 'win32' && arch === 'x64') {
return {
actionlint: 'windows_amd64',
// shellcheck is not used for Windows since it uses the .zip release
// which has a consistent name across architectures
};
}
throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`);
}
@@ -58,10 +65,52 @@ const pythonVenvPythonPath = join(
process.platform === 'win32' ? 'python.exe' : 'python',
);
const yamllintCheck =
process.platform === 'win32'
? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
: `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
const isWindows = process.platform === 'win32';
const actionlintCheck = isWindows
? `where actionlint 2>nul`
: 'command -v actionlint';
const actionlintInstaller = isWindows
? `powershell -Command "` +
`New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` +
`Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` +
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"`
: `
mkdir -p "${TEMP_DIR}/actionlint"
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
`;
const shellcheckCheck = isWindows
? `where shellcheck 2>nul`
: 'command -v shellcheck';
const shellcheckInstaller = isWindows
? `powershell -Command "` +
`Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` +
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"`
: `
mkdir -p "${TEMP_DIR}/shellcheck"
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
`;
const yamllintCheck = isWindows
? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
: `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
const yamllintInstaller = isWindows
? `python -m venv "${PYTHON_VENV_PATH}" && ` +
`"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` +
`"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple`
: `
python3 -m venv "${PYTHON_VENV_PATH}" && \
"${pythonVenvPythonPath}" -m pip install --upgrade pip && \
"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
`;
/**
* @typedef {{
@@ -76,12 +125,8 @@ const yamllintCheck =
*/
const LINTERS = {
actionlint: {
check: 'command -v actionlint',
installer: `
mkdir -p "${TEMP_DIR}/actionlint"
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
`,
check: actionlintCheck,
installer: actionlintInstaller,
run: `
actionlint \
-color \
@@ -92,12 +137,8 @@ const LINTERS = {
`,
},
shellcheck: {
check: 'command -v shellcheck',
installer: `
mkdir -p "${TEMP_DIR}/shellcheck"
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
`,
check: shellcheckCheck,
installer: shellcheckInstaller,
run: `
git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
@@ -112,11 +153,7 @@ const LINTERS = {
},
yamllint: {
check: yamllintCheck,
installer: `
python3 -m venv "${PYTHON_VENV_PATH}" && \
"${pythonVenvPythonPath}" -m pip install --upgrade pip && \
"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
`,
installer: yamllintInstaller,
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
},
};
@@ -125,8 +162,20 @@ function runCommand(command, stdio = 'inherit') {
try {
const env = { ...process.env };
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${PYTHON_VENV_PATH}/bin:${env.PATH}`;
execSync(command, { stdio, env });
const sep = isWindows ? ';' : ':';
const pythonBin = isWindows
? join(PYTHON_VENV_PATH, 'Scripts')
: join(PYTHON_VENV_PATH, 'bin');
// Windows sometimes uses 'Path' instead of 'PATH'
const pathKey = 'Path' in env ? 'Path' : 'PATH';
env[pathKey] = [
nodeBin,
join(TEMP_DIR, 'actionlint'),
join(TEMP_DIR, 'shellcheck'),
pythonBin,
env[pathKey],
].join(sep);
execSync(command, { stdio, env, shell: true });
return true;
} catch (_e) {
return false;