mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-31 12:11:05 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94362d67f0 | |||
| af4e850aae | |||
| aa5eefa193 | |||
| 7666c07e40 | |||
| 96936a3030 | |||
| 64070914bb | |||
| be9360ee08 | |||
| 3daaec2621 | |||
| 6ce878416c | |||
| 6b81eb2964 | |||
| 7d23784631 | |||
| 11964a1b77 | |||
| 14a7be90d4 | |||
| 24bbeb11c7 | |||
| a1db5f9faf | |||
| 1d3896491f | |||
| 6dd2d219d9 | |||
| 8d041e2acd | |||
| 9ccb6ff329 | |||
| f7cba3f3a4 | |||
| 38917d8ef1 | |||
| b64bfd55e8 | |||
| 8ce3de1187 | |||
| bc8acdc309 | |||
| d61eed2880 | |||
| 2e1b556764 | |||
| 50f71a8df5 |
@@ -2,10 +2,7 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"watcher": true,
|
||||
"watcherInterval": 10
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: experimentation
|
||||
description: Guide developers through the process of adding new remote experiments (feature flags) to the Gemini CLI codebase.
|
||||
---
|
||||
|
||||
# Experimentation Skill
|
||||
|
||||
This skill assists developers in adding net-new remote experiments (feature flags) to the Gemini CLI codebase. You MUST follow these steps sequentially to ensure consistency, type safety, and proper validation.
|
||||
|
||||
## Key Files
|
||||
- `packages/core/src/code_assist/experiments/flagNames.ts`: Definitions and metadata for all experiment flags.
|
||||
- `packages/core/src/config/config.ts`: Unified configuration object where strongly typed wrappers for experiments are added.
|
||||
- `packages/cli/src/config/settingsSchema.ts`: Schema definitions for `settings.json`.
|
||||
- `packages/core/src/config/config.experiment.test.ts`: Regression tests for experiment resolution.
|
||||
|
||||
## Core Pattern: Unified Configuration
|
||||
|
||||
Gemini CLI uses a unified `Config` object as the source of truth. Every experiment should be accessed via the `config.getExperimentValue(flagId)` method, which internally handles the priority of overrides:
|
||||
**Command Line Argument (--experiment-*) > Local Setting (experimental.*) > Remote Experiment > Default Value.**
|
||||
|
||||
---
|
||||
|
||||
## Workflow: Adding a New Experiment
|
||||
|
||||
### 1. List Current Experiments
|
||||
- **File:** `packages/core/src/code_assist/experiments/flagNames.ts`
|
||||
- **MANDATORY:** Read this file and explicitly list EVERY existing experiment name and its numeric ID to the user in your response. This is a critical step to ensure a unique ID and name are chosen. Do NOT just read the file silently.
|
||||
- **Analyze Usage:** Search for `getExperimentValue` in `packages/core/src/config/config.ts` to see how similar experiments are wrapped and used.
|
||||
|
||||
### 2. Ask for Details (Behavior & Intent)
|
||||
- **CRITICAL:** You MUST ask the user:
|
||||
1. "What is the name of the new flag?" (e.g., `enable-new-thing`)
|
||||
2. "What is the data type?" (boolean, number, or string)
|
||||
3. "What should the behavior be when the flag is enabled vs. disabled?"
|
||||
4. "What are the architectural impacts (routing, tools, prompt construction, etc.)?"
|
||||
- Do not proceed until the behavior is clearly defined and documented.
|
||||
|
||||
### 3. Add Experiment ID and Metadata
|
||||
- **File:** `packages/core/src/code_assist/experiments/flagNames.ts`
|
||||
- Add a new constant to the `ExperimentFlags` object with a unique numeric ID.
|
||||
- Add a corresponding entry to `ExperimentMetadata`, including `description`, `type`, and `defaultValue`.
|
||||
|
||||
### 4. Add Config Entry and Schema
|
||||
- **Step A (Wrapper):** In `packages/core/src/config/config.ts`, add a strongly typed wrapper method:
|
||||
```typescript
|
||||
isNewThingEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.ENABLE_NEW_THING) ?? false;
|
||||
}
|
||||
```
|
||||
- **Step B (Schema):** In `packages/cli/src/config/settingsSchema.ts`, add the kebab-case name to the `experimental.properties` section to enable IDE autocompletion.
|
||||
- **Step C (Regenerate):** Run `npm run schema:settings` to update `schemas/settings.schema.json`.
|
||||
|
||||
### 5. Implement Code Change with Debug Logging
|
||||
- **Usage:** Implement the logic defined in Step 2.
|
||||
- **Logging:** At the point of usage, add a debug log to confirm the flag's state:
|
||||
```typescript
|
||||
debugLogger.debug('New thing enabled:', config.isNewThingEnabled());
|
||||
```
|
||||
- **Telemetry (Crucial):** Prompt the user to think about success metrics. Ask: "How will we know if this feature is working as intended?" and help them add telemetry calls to capture these insights.
|
||||
|
||||
### 6. Update Tests
|
||||
- **File:** `packages/core/src/config/config.experiment.test.ts`
|
||||
- Add a test case to verify that the flag resolves correctly across all priority levels (CLI, Settings, Remote, Default).
|
||||
- Example: `expect(config.isNewThingEnabled()).toBe(true)` when passed via `experimentalCliArgs`.
|
||||
|
||||
### 7. Final Validation and PR Preparation
|
||||
- **Validation Action:** Run the CLI with debugging enabled and the new flag set via the command line:
|
||||
```bash
|
||||
npm run start -- --debug --experiment your-flag-name=value --prompt "test"
|
||||
```
|
||||
- **Log Verification:** Check the console for the "Experimental CLI args" normalization log and your custom debug log.
|
||||
- **Branching:** If not already on a dedicated branch, help the user create one (e.g., `git checkout -b exp/feature-name`).
|
||||
- **Pre-flight:** Remind the user to run local tests and linting (`npm run lint:all`, `npm test` or `npm run preflight`) before preparing a Pull Request.
|
||||
Binary file not shown.
Generated
+3
-31
@@ -447,8 +447,7 @@
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -1472,7 +1471,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -2179,7 +2177,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2360,7 +2357,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2410,7 +2406,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2785,7 +2780,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2819,7 +2813,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2874,7 +2867,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -4111,7 +4103,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4386,7 +4377,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5260,7 +5250,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7401,8 +7390,7 @@
|
||||
"version": "0.0.1581282",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
|
||||
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7987,7 +7975,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8505,7 +8492,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9818,7 +9804,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10097,7 +10082,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
|
||||
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
@@ -13854,7 +13838,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13865,7 +13848,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16006,7 +15988,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16229,8 +16210,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16238,7 +16218,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16404,7 +16383,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16627,7 +16605,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17198,7 +17175,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17211,7 +17187,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17862,7 +17837,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -18306,7 +18280,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -18410,7 +18383,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,13 +444,22 @@ export async function parseArguments(
|
||||
.option('accept-raw-output-risk', {
|
||||
type: 'boolean',
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
})
|
||||
.option('experiment', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
description:
|
||||
'Override experiment flags locally (format: flag=value, comma-separated or multiple --experiment)',
|
||||
coerce: (exps: string[]) =>
|
||||
// Handle comma-separated values
|
||||
exps.flatMap((e) => e.split(',').map((s) => s.trim())),
|
||||
}),
|
||||
)
|
||||
.version(await getVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
.alias('h', 'help')
|
||||
.strict()
|
||||
.demandCommand(0, 0) // Allow base command to run with no subcommands
|
||||
.exitProcess(false);
|
||||
|
||||
@@ -873,27 +883,46 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
let clientName: string | undefined = undefined;
|
||||
if (isAcpMode) {
|
||||
const ide = detectIdeFromEnv();
|
||||
if (
|
||||
ide &&
|
||||
(ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode')
|
||||
) {
|
||||
clientName = `acp-${ide.name}`;
|
||||
const experimentalCliArgs: Record<string, unknown> = Object.create(null);
|
||||
if (argv['experiment'] && Array.isArray(argv['experiment'])) {
|
||||
for (const entry of argv['experiment']) {
|
||||
const [key, ...valueParts] = entry.split('=');
|
||||
const value = valueParts.join('=');
|
||||
if (key && value !== undefined) {
|
||||
// Simple type inference for CLI args
|
||||
if (value === 'true') experimentalCliArgs[key] = true;
|
||||
else if (value === 'false') experimentalCliArgs[key] = false;
|
||||
else if (!isNaN(Number(value)))
|
||||
experimentalCliArgs[key] = Number(value);
|
||||
else experimentalCliArgs[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
const contextManagement = {
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
};
|
||||
let clientName: string | undefined = undefined;
|
||||
if (isAcpMode) {
|
||||
const ide = detectIdeFromEnv();
|
||||
if (
|
||||
ide &&
|
||||
(ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode')
|
||||
) {
|
||||
clientName = `acp-${ide.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
const contextManagement = {
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
};
|
||||
|
||||
if (debugMode && Object.keys(experimentalCliArgs).length > 0) {
|
||||
debugLogger.debug('Experimental CLI args:', experimentalCliArgs);
|
||||
}
|
||||
return new Config({
|
||||
acpMode: isAcpMode,
|
||||
clientName,
|
||||
@@ -985,13 +1014,13 @@ export async function loadCliConfig(
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
experimentalSettings: settings.experimental,
|
||||
experimentalCliArgs,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalWatcher: settings.experimental?.watcher,
|
||||
experimentalWatcherInterval: settings.experimental?.watcherInterval,
|
||||
contextManagement,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
|
||||
@@ -34,8 +34,8 @@ export const ALL_ITEMS = [
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
header: 'quota',
|
||||
description: 'Percentage of daily limit used (not shown when unavailable)',
|
||||
header: '/stats',
|
||||
description: 'Remaining usage on daily limit (not shown when unavailable)',
|
||||
},
|
||||
{
|
||||
id: 'memory-usage',
|
||||
|
||||
@@ -2217,24 +2217,13 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.',
|
||||
showInDialog: true,
|
||||
},
|
||||
watcher: {
|
||||
'enable-awesome': {
|
||||
type: 'boolean',
|
||||
label: 'Watcher Subagent',
|
||||
label: 'Enable Awesome',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the specialized Watcher subagent for periodic progress monitoring and strategic feedback.',
|
||||
showInDialog: true,
|
||||
},
|
||||
watcherInterval: {
|
||||
type: 'number',
|
||||
label: 'Watcher Interval',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 20,
|
||||
description:
|
||||
'The number of turns between each Watcher subagent progress review.',
|
||||
description: "When enabled, the ASCII art says 'matt'.",
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { experimentCommand } from '../ui/commands/experimentCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { footerCommand } from '../ui/commands/footerCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
@@ -133,6 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
docsCommand,
|
||||
directoryCommand,
|
||||
editorCommand,
|
||||
experimentCommand,
|
||||
...(this.config?.getExtensionsEnabled() === false
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -121,6 +121,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getUserCaching: vi.fn().mockResolvedValue(false),
|
||||
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
|
||||
isAwesomeEnabled: vi.fn().mockReturnValue(false),
|
||||
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Trigger CI run for latest changes.
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { experimentCommand } from './experimentCommand.js';
|
||||
import { CommandKind, type CommandContext } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
describe('experimentCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
services: {
|
||||
config: {
|
||||
getExperimentValue: vi.fn(),
|
||||
updateExperimentalSettings: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
user: {
|
||||
settings: {
|
||||
experimental: {},
|
||||
},
|
||||
},
|
||||
merged: {
|
||||
experimental: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
stats: {},
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(experimentCommand.name).toBe('experiment');
|
||||
expect(experimentCommand.description).toBe('Manage experimental features');
|
||||
expect(experimentCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
describe('list sub-command', () => {
|
||||
const listCommand = experimentCommand.subCommands?.find(
|
||||
(c) => c.name === 'list',
|
||||
);
|
||||
|
||||
it('should list experiments', async () => {
|
||||
(mockContext.services.config!.getExperimentValue as Mock).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
await listCommand?.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('enable-preview'),
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('Value: true'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('set sub-command', () => {
|
||||
const setCommand = experimentCommand.subCommands?.find(
|
||||
(c) => c.name === 'set',
|
||||
);
|
||||
|
||||
it('should set a boolean experiment', async () => {
|
||||
await setCommand?.action!(mockContext, 'enable-preview true');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'experimental',
|
||||
expect.objectContaining({
|
||||
'enable-preview': true,
|
||||
}),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(
|
||||
'Experiment enable-preview set to true',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set a number experiment', async () => {
|
||||
await setCommand?.action!(mockContext, 'classifier-threshold 0.5');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'experimental',
|
||||
expect.objectContaining({
|
||||
'classifier-threshold': 0.5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error for unknown experiment', async () => {
|
||||
await setCommand?.action!(mockContext, 'unknown-exp true');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining('Unknown experiment: unknown-exp'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unset sub-command', () => {
|
||||
const unsetCommand = experimentCommand.subCommands?.find(
|
||||
(c) => c.name === 'unset',
|
||||
);
|
||||
|
||||
it('should unset an experiment', async () => {
|
||||
(mockContext.services.settings.user.settings as Record<string, unknown>)[
|
||||
'experimental'
|
||||
] = {
|
||||
'enable-preview': true,
|
||||
};
|
||||
await unsetCommand?.action!(mockContext, 'enable-preview');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'experimental',
|
||||
{},
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(
|
||||
'Local user override for experiment enable-preview removed',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if no override exists', async () => {
|
||||
await unsetCommand?.action!(mockContext, 'enable-preview');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining(
|
||||
'No local user override found for experiment: enable-preview',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ExperimentMetadata,
|
||||
getExperimentFlagIdFromName,
|
||||
getExperimentFlagName,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
const listExperimentsCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List all available experiments and their current values',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context: CommandContext) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return;
|
||||
|
||||
const entries = Object.entries(ExperimentMetadata).filter(
|
||||
([, metadata]) => !metadata.hidden,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'No experiments available.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let output = 'Available Experiments:\n\n';
|
||||
for (const [idStr, metadata] of entries) {
|
||||
const id = parseInt(idStr, 10);
|
||||
const name = getExperimentFlagName(id) || `ID: ${id}`;
|
||||
const value = config.getExperimentValue(id);
|
||||
output += `${name}\n`;
|
||||
output += ` Value: ${value}\n`;
|
||||
output += ` Description: ${metadata.description}\n`;
|
||||
output += ` Default: ${metadata.defaultValue}\n\n`;
|
||||
}
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: output.trim(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const setExperimentCommand: SlashCommand = {
|
||||
name: 'set',
|
||||
description:
|
||||
'Set a local override for an experiment. Usage: /experiment set <name> <value>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /experiment set <name> <value>',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const name = parts[0];
|
||||
const rawValue = args.trim().substring(name.length).trim();
|
||||
const id = getExperimentFlagIdFromName(name);
|
||||
|
||||
if (id === undefined) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Unknown experiment: ${name}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = ExperimentMetadata[id];
|
||||
let value: unknown;
|
||||
|
||||
// Helper to parse strings based on Zod schema type
|
||||
const parseValue = (raw: string, schema: z.ZodTypeAny): unknown => {
|
||||
if (schema instanceof z.ZodBoolean) {
|
||||
if (raw === 'true' || raw === 'on') return true;
|
||||
if (raw === 'false' || raw === 'off') return false;
|
||||
return raw;
|
||||
}
|
||||
if (schema instanceof z.ZodNumber) {
|
||||
return Number(raw);
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
value = parseValue(rawValue, metadata.schema);
|
||||
const result = metadata.schema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Invalid value for ${name}: ${rawValue}. Error: ${result.error.errors[0].message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
value = result.data;
|
||||
|
||||
const { settings, config } = context.services;
|
||||
if (!config) return;
|
||||
|
||||
// SECURITY: Only persist user-scoped settings to prevent untrusted workspace settings
|
||||
// from being promoted to the global user configuration.
|
||||
const userExperimental = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((settings.user.settings['experimental'] as Record<string, unknown>) ||
|
||||
{}),
|
||||
};
|
||||
userExperimental[name] = value;
|
||||
|
||||
settings.setValue(SettingScope.User, 'experimental', userExperimental);
|
||||
|
||||
// Update the live config with the merged state so it takes effect immediately
|
||||
const mergedExperimental = {
|
||||
...((settings.merged.experimental as Record<string, unknown>) || {}),
|
||||
...userExperimental,
|
||||
};
|
||||
config.updateExperimentalSettings(mergedExperimental);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Experiment ${name} set to ${value} (persisted in user settings).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const unsetExperimentCommand: SlashCommand = {
|
||||
name: 'unset',
|
||||
description:
|
||||
'Remove a local override for an experiment. Usage: /experiment unset <name>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /experiment unset <name>',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { settings, config } = context.services;
|
||||
if (!config) return;
|
||||
|
||||
const userExperimental = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((settings.user.settings['experimental'] as Record<string, unknown>) ||
|
||||
{}),
|
||||
};
|
||||
|
||||
if (!(name in userExperimental)) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `No local user override found for experiment: ${name}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
delete userExperimental[name];
|
||||
settings.setValue(SettingScope.User, 'experimental', userExperimental);
|
||||
|
||||
// Update the live config with the new merged state
|
||||
const mergedExperimental = {
|
||||
...((settings.merged.experimental as Record<string, unknown>) || {}),
|
||||
};
|
||||
delete mergedExperimental[name];
|
||||
config.updateExperimentalSettings(mergedExperimental);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Local user override for experiment ${name} removed.`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const experimentCommand: SlashCommand = {
|
||||
name: 'experiment',
|
||||
description: 'Manage experimental features',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
listExperimentsCommand,
|
||||
setExperimentCommand,
|
||||
unsetExperimentCommand,
|
||||
],
|
||||
action: async (context: CommandContext, args: string) =>
|
||||
listExperimentsCommand.action!(context, args),
|
||||
};
|
||||
@@ -57,3 +57,14 @@ export const tinyAsciiLogoCompactText = `
|
||||
▝█▖ ▜█▘
|
||||
▝▀▀▀▀
|
||||
`;
|
||||
|
||||
export const mattAsciiLogo = `
|
||||
██████ ██████ ██████ ███████████ ███████████
|
||||
░░██████ ░░██████ ░░██████░█░░░███░░░█░█░░░███░░░█
|
||||
░███░███ ███░███ ░███░███░ ░███ ░ ░ ░███ ░
|
||||
░███░░███░███░███ ░███░░███ ░███ ░███
|
||||
░███ ░░███░ ░███ ░███ ░░███ ░███ ░███
|
||||
░███ ░░███ ░███ ░███ ░░███ ░███ ░███
|
||||
█████ ░░█ █████ █████ ░░█████████ █████
|
||||
░░░░░ ░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░
|
||||
`;
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).toContain('85% used');
|
||||
expect(lastFrame()).toContain('85%');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -306,7 +306,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(normalizeFrame(lastFrame())).toContain('15% used');
|
||||
expect(normalizeFrame(lastFrame())).not.toContain('used');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -351,11 +351,13 @@ export const Footer: React.FC = () => {
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
forceShow={true}
|
||||
lowercase={true}
|
||||
/>
|
||||
),
|
||||
9, // "100% used" is 9 chars
|
||||
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('<FooterConfigDialog />', () => {
|
||||
expect(nextLine).toContain('·');
|
||||
expect(nextLine).toContain('~/project/path');
|
||||
expect(nextLine).toContain('docker');
|
||||
expect(nextLine).toContain('42% used');
|
||||
expect(nextLine).toContain('97%');
|
||||
});
|
||||
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
|
||||
@@ -242,7 +242,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
'context-used': (
|
||||
<Text color={getColor('context-used', itemColor)}>85% used</Text>
|
||||
),
|
||||
quota: <Text color={getColor('quota', itemColor)}>42% used</Text>,
|
||||
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
|
||||
'memory-usage': (
|
||||
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
|
||||
),
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import {
|
||||
shortAsciiLogo,
|
||||
longAsciiLogo,
|
||||
tinyAsciiLogo,
|
||||
mattAsciiLogo,
|
||||
} from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useSnowfall } from '../hooks/useSnowfall.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
@@ -23,6 +29,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
version,
|
||||
nightly,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
@@ -30,6 +37,8 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (config.isAwesomeEnabled()) {
|
||||
displayTitle = mattAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
|
||||
" workspace (/directory) sandbox /model quota
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
|
||||
" workspace (/directory) sandbox /model quota
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -39,7 +39,7 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
|
||||
" workspace (/directory) sandbox /model quota
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
|
||||
"
|
||||
`;
|
||||
|
||||
+9
-9
@@ -50,7 +50,7 @@
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
@@ -132,10 +132,10 @@
|
||||
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="631" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
|
||||
<text x="288" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="396" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="504" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="684" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
|
||||
<text x="297" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="405" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="513" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="693" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
|
||||
<rect x="801" y="629" width="36" height="17" fill="#001a00" />
|
||||
<text x="801" y="631" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
|
||||
<rect x="837" y="629" width="18" height="17" fill="#001a00" />
|
||||
@@ -144,10 +144,10 @@
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<text x="288" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="396" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="504" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="684" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
|
||||
<text x="297" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="405" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="513" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="693" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<rect x="801" y="646" width="27" height="17" fill="#001a00" />
|
||||
<text x="801" y="648" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
|
||||
<rect x="828" y="646" width="9" height="17" fill="#001a00" />
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
+9
-9
@@ -59,7 +59,7 @@
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
@@ -133,10 +133,10 @@
|
||||
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="45" y="629" width="198" height="17" fill="#001a00" />
|
||||
<text x="45" y="631" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
|
||||
<text x="315" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="450" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="585" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="783" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
|
||||
<text x="324" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="459" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="594" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="801" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
|
||||
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
@@ -144,10 +144,10 @@
|
||||
<rect x="45" y="646" width="126" height="17" fill="#001a00" />
|
||||
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<rect x="171" y="646" width="72" height="17" fill="#001a00" />
|
||||
<text x="315" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="450" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="585" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="783" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
|
||||
<text x="324" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="459" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="594" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="801" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
+8
-8
@@ -50,7 +50,7 @@
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
@@ -131,13 +131,13 @@
|
||||
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<text x="207" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="270" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="342" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="405" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="495" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="558" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="720" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="783" y="631" fill="#afafaf" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
|
||||
<text x="279" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="351" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="432" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="522" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="594" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="756" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="828" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -16,7 +16,7 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -38,8 +38,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model quota diff │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 42% used +12 -4 │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats diff │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
@@ -61,7 +61,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -83,8 +83,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model quota │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -107,7 +107,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -129,8 +129,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model quota │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
@@ -152,7 +152,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -174,7 +174,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ ~/project/path · main · docker · gemini-2.5-pro · 42% used │ │
|
||||
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
|
||||
@@ -21,12 +21,6 @@ exports[`ToolResultDisplay > renders file diff result 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > renders file diff result 2`] = `
|
||||
"
|
||||
No changes detected.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > renders nothing for todos result 1`] = `""`;
|
||||
|
||||
exports[`ToolResultDisplay > renders string result as markdown by default 1`] = `
|
||||
|
||||
@@ -46,5 +46,11 @@ export * from './src/utils/googleQuotaErrors.js';
|
||||
export type { GoogleApiError } from './src/utils/googleErrors.js';
|
||||
export { getCodeAssistServer } from './src/code_assist/codeAssist.js';
|
||||
export { getExperiments } from './src/code_assist/experiments/experiments.js';
|
||||
export { ExperimentFlags } from './src/code_assist/experiments/flagNames.js';
|
||||
export {
|
||||
ExperimentFlags,
|
||||
ExperimentMetadata,
|
||||
type ExperimentMetadataEntry,
|
||||
getExperimentFlagName,
|
||||
getExperimentFlagIdFromName,
|
||||
} from './src/code_assist/experiments/flagNames.js';
|
||||
export { getErrorStatus, ModelNotFoundError } from './src/utils/httpErrors.js';
|
||||
|
||||
@@ -14,7 +14,6 @@ import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { CliHelpAgent } from './cli-help-agent.js';
|
||||
import { GeneralistAgent } from './generalist-agent.js';
|
||||
import { WatcherAgent } from './watcher-agent.js';
|
||||
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
|
||||
import { MemoryManagerAgent } from './memory-manager-agent.js';
|
||||
import { AgentTool } from './agent-tool.js';
|
||||
@@ -267,7 +266,6 @@ export class AgentRegistry {
|
||||
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
|
||||
this.registerLocalAgent(CliHelpAgent(this.config));
|
||||
this.registerLocalAgent(GeneralistAgent(this.config));
|
||||
this.registerLocalAgent(WatcherAgent(this.config));
|
||||
|
||||
// Register the browser agent if enabled in settings.
|
||||
// Tools are configured dynamically at invocation time via browserAgentFactory.
|
||||
|
||||
@@ -4,22 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolConfirmationOutcome,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ExecuteOptions,
|
||||
import { BaseToolInvocation } from '../tools/tools.js';
|
||||
import type {
|
||||
ToolResult,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
type RemoteAgentInputs,
|
||||
type RemoteAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
getAgentCardLoadOptions,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
import type {
|
||||
RemoteAgentInputs,
|
||||
RemoteAgentDefinition,
|
||||
AgentInputs,
|
||||
SubagentProgress,
|
||||
} from './types.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type {
|
||||
|
||||
@@ -105,13 +105,6 @@ export interface SubagentProgress {
|
||||
terminateReason?: AgentTerminateMode;
|
||||
}
|
||||
|
||||
export interface WatcherProgress {
|
||||
primaryUserGoal: string;
|
||||
progressSummary: string;
|
||||
evaluation: string;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export function isSubagentProgress(obj: unknown): obj is SubagentProgress {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { WatcherAgent } from './watcher-agent.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import * as path from 'node:path';
|
||||
|
||||
describe('WatcherAgent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', '');
|
||||
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should create a valid watcher agent definition', () => {
|
||||
const config = makeFakeConfig();
|
||||
const projectTempDir = '/tmp/project';
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
|
||||
projectTempDir,
|
||||
);
|
||||
|
||||
Object.defineProperty(config, 'config', {
|
||||
get() {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
const agent = WatcherAgent(config);
|
||||
|
||||
expect(agent.name).toBe('watcher');
|
||||
expect(agent.kind).toBe('local');
|
||||
expect(agent.description).toContain('monitors the progress');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((agent.inputConfig.inputSchema as any).properties).toHaveProperty(
|
||||
'recentHistory',
|
||||
);
|
||||
expect(agent.outputConfig?.outputName).toBe('report');
|
||||
|
||||
const statusFilePath = path.join(projectTempDir, 'watcher_status.md');
|
||||
expect(agent.promptConfig.systemPrompt).toContain(statusFilePath);
|
||||
expect(agent.promptConfig.query).toContain(statusFilePath);
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { LocalAgentDefinition } from './types.js';
|
||||
import { READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const WatcherReportSchema = z.object({
|
||||
primaryUserGoal: z
|
||||
.string()
|
||||
.describe(
|
||||
'High level user directions/redirections and any change of plans.',
|
||||
),
|
||||
progressSummary: z
|
||||
.string()
|
||||
.describe('Concise summary of the progress made by the agent.'),
|
||||
evaluation: z
|
||||
.string()
|
||||
.describe(
|
||||
'Evaluation of whether the agent is going in the right direction.',
|
||||
),
|
||||
feedback: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Feedback to the main agent if necessary.'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Watcher subagent specialized in monitoring the main agent's progress and direction.
|
||||
*/
|
||||
export const WatcherAgent = (
|
||||
context: AgentLoopContext,
|
||||
): LocalAgentDefinition<typeof WatcherReportSchema> => {
|
||||
const projectTempDir = context.config.storage.getProjectTempDir();
|
||||
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
|
||||
|
||||
return {
|
||||
name: 'watcher',
|
||||
kind: 'local',
|
||||
displayName: 'Watcher Agent',
|
||||
description:
|
||||
'Specialized agent that monitors the progress and direction of the main agent.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recentHistory: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The transcript of the most recent turns of the conversation.',
|
||||
},
|
||||
},
|
||||
required: ['recentHistory'],
|
||||
},
|
||||
},
|
||||
outputConfig: {
|
||||
outputName: 'report',
|
||||
description: 'The progress report and evaluation.',
|
||||
schema: WatcherReportSchema,
|
||||
},
|
||||
|
||||
processOutput: (output) => JSON.stringify(output, null, 2),
|
||||
|
||||
modelConfig: {
|
||||
model: GEMINI_MODEL_ALIAS_FLASH,
|
||||
generateContentConfig: {
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
},
|
||||
},
|
||||
|
||||
runConfig: {
|
||||
maxTimeMinutes: 2,
|
||||
maxTurns: 5,
|
||||
},
|
||||
|
||||
toolConfig: {
|
||||
tools: [READ_FILE_TOOL_NAME],
|
||||
},
|
||||
|
||||
promptConfig: {
|
||||
query: `Analyze the recent conversation history and update the progress status.
|
||||
Status file path: ${statusFilePath}
|
||||
|
||||
<recent_history>
|
||||
\${recentHistory}
|
||||
</recent_history>`,
|
||||
systemPrompt: `You are **Watcher**, a highly analytical, objective overseer sub-agent in a coding agent harness. Your main purpose is to
|
||||
* ensure the main execution agent stays rigidly focused on the user's overarching goal,
|
||||
* avoids cognitive loops,
|
||||
* learns from failed strategies during complex, multi-step tasks,
|
||||
* maintains the correct scope; and
|
||||
* prevents unbounded token waste by "failing fast" when stuck.
|
||||
|
||||
You do not write code. You monitor, evaluate, and course-correct.
|
||||
|
||||
### Core Directives:
|
||||
|
||||
#### 1. Horizon Detection & Context Awareness (Triage)
|
||||
Not every interaction requires oversight, but you must be deeply aware of the current context before wiping anything in the status file.
|
||||
* **Standalone Short Requests:** If the user starts a session with a simple, isolated question (e.g., "How do I reverse a string in Python?", "Fix the typo on line 42") and there is *no active long-horizon task*, this tracking paradigm is unnecessary. Set the status file to empty.
|
||||
* **Tactical Asks within a Macro Task (DO NOT PURGE):** **CRITICAL:** If a long-horizon task is *already underway* (i.e., a status file exists with an active North Star), do NOT wipe the file just because the user asks a quick tactical question (e.g., "Why did that command fail?", "Wait, print that variable for me"). These are micro-steps within the macro-task. You must maintain the file and keep tracking the main goal.
|
||||
|
||||
#### 2. The North Star & Strategic Intent
|
||||
Maintain the definitive statement of the user's ultimate goal and *how* they want to achieve it.
|
||||
* **Determine Intent (Bias Towards Action):** Understand what the user wants to accomplish. Guage whether the user wants brainstorm a design, fix a bug or implement something. Depending on the established intent, provide feedback to the main agent if it's going off-track.
|
||||
* **Strategic vs. Tactical:** ONLY update the main goal if the user issues a *strategic pivot* within the current task (e.g., "Let's use Python instead of Rust"). Ignore tactical chatter.
|
||||
* **Task Transitions & Abandonment:** You must only **PURGE** the status file and start fresh IF the user explicitly moves on to a completely new macro-task (e.g., "Great, the API is done. Now let's write a deployment script") OR explicitly aborts the current task (e.g., "Actually, forget about this feature entirely, let's do something else").
|
||||
|
||||
#### 3. The Map (Progress & Dead Ends)
|
||||
For long-horizon tasks, maintain a living snapshot of the project state.
|
||||
* **Completed Milestones:** What features/fixes are verifiably complete?
|
||||
* **Failed Strategies (Crucial):** Explicitly track approaches that have *failed*. If the main agent tried a specific library, regex, or architectural pattern and it caused errors, record it so the agent doesn't repeat the mistake. **Monitor the length of this list closely.**
|
||||
|
||||
#### 4. The Compass (Evaluation & Intervention)
|
||||
Analyze the recent history against the North Star. Actively look for anti-patterns:
|
||||
* **Exhaustion / Token Churn (Fail Fast):** The agent has tried multiple distinct strategies (listed in Dead Ends) and all have failed. It is churning through tokens without progress. It must stop.
|
||||
* **Strategic Deviation & Scope Creep:**
|
||||
* *Premature Implementation:* The user explicitly requested a plan/discussion, but the agent is modifying files.
|
||||
* *Destructive Debugging:* The goal is to fix a bug or pass a test, but the agent is blindly rewriting entire functions or altering core business logic instead of adding logs to find the root cause.
|
||||
* **Cognitive Looping:** Repeatedly applying the same fix, reverting, or trying the exact same logic that just failed.
|
||||
* **Rabbit-Holing / Hyper-fixation:** Spending excessive turns fixing irrelevant tests or deep dependencies instead of the primary task.
|
||||
* **Goal Amnesia:** The agent finished a sub-task but forgot the overarching goal, idling or doing unprompted work.
|
||||
|
||||
### Standard Operating Procedure:
|
||||
1. **READ & TRIAGE:** Read the user's prompt, recent history, and the existing memory file at \`${statusFilePath}\`. Determine the state: Standalone Short-Horizon, Active Long-Horizon, or Task Transition.
|
||||
2. **ANALYZE:**
|
||||
* *If Standalone Short-Horizon (No active macro-task):* Note that the status should be empty.
|
||||
* *If Task Transition / Abort:* Purge old data, initialize a fresh state for the new task, or leave empty if no new task is given.
|
||||
* *If Active Long-Horizon (Even if current turn is a tactical question):* Compare history against the file, update progress, log dead ends, and track trajectory.
|
||||
3. **REPORT:** Call the \`complete_task\` tool with the updated state and sharp, direct feedback to snap the main agent out of loops, or stay silent if things are on track.
|
||||
|
||||
---
|
||||
|
||||
### Output JSON Format (Provide this to \`complete_task\`):
|
||||
|
||||
*(Note: If this is a Standalone Short-Horizon task with no ongoing goal, just set all fields to "EMPTY" or "N/A" and omit feedback.)*
|
||||
|
||||
* \`primaryUserGoal\`: A concise, 1-2 sentence strictly HIGH LEVEL GOAL. Any parsed _strategic_ changes, or note if the user transitioned/aborted tasks.
|
||||
* \`progressSummary\`: Brief text of what was achieved, or "N/A" for short-horizon. Use bullet points.
|
||||
* \`evaluation\`: "ON_TRACK", "DEVIATING", "STUCK", "LOOPING", or "NOT_APPLICABLE".
|
||||
* \`feedback\`: **CRITICAL: THIS IS THE ONLY FIELD INJECTED INTO THE AGENT'S CHAT HISTORY.**
|
||||
* **If Short-Horizon or ON_TRACK**: You MUST leave this field empty.
|
||||
* **If DEVIATING/STUCK/LOOPING**: Provide a strong, authoritative directive to the main agent. (e.g., _"WARNING: You are hyper fixating on fixing test_utils.py and looping as a result. The original goal is to build the API endpoint. Revert your last change, ignore the test warning for now, and return to the API endpoint."_). If the agent is truly stuck, provide the directive to stop unless the agent can get new information.
|
||||
|
||||
You MUST call \`complete_task\` with a JSON report containing \`primaryUserGoal\`, \`progressSummary\`, \`evaluation\`, and optional \`feedback\`.`,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ExperimentFlags = {
|
||||
CONTEXT_COMPRESSION_THRESHOLD: 45740197,
|
||||
USER_CACHING: 45740198,
|
||||
@@ -20,7 +22,239 @@ export const ExperimentFlags = {
|
||||
PRO_MODEL_NO_ACCESS: 45768879,
|
||||
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
|
||||
DEFAULT_REQUEST_TIMEOUT: 45773134,
|
||||
ENABLE_AWESOME: 45758820,
|
||||
|
||||
// Migrated from hardcoded experimental settings
|
||||
ENABLE_AGENTS: 45760001,
|
||||
EXTENSION_MANAGEMENT: 45760002,
|
||||
EXTENSION_CONFIG: 45760003,
|
||||
EXTENSION_REGISTRY: 45760004,
|
||||
EXTENSION_RELOADING: 45760005,
|
||||
JIT_CONTEXT: 45760006,
|
||||
USE_OSC52_PASTE: 45760007,
|
||||
USE_OSC52_COPY: 45760008,
|
||||
PLAN: 45760009,
|
||||
MODEL_STEERING: 45760010,
|
||||
DISABLE_LLM_CORRECTION: 45760011,
|
||||
ENABLE_TOOL_OUTPUT_MASKING: 45760012,
|
||||
} as const;
|
||||
|
||||
export type ExperimentFlagName =
|
||||
(typeof ExperimentFlags)[keyof typeof ExperimentFlags];
|
||||
|
||||
export interface ExperimentMetadataEntry {
|
||||
description: string;
|
||||
schema: z.ZodTypeAny;
|
||||
defaultValue: unknown;
|
||||
hidden?: boolean;
|
||||
/** The key used in settings.json (defaults to kebab-case version of flag name) */
|
||||
settingKey?: string;
|
||||
}
|
||||
|
||||
export const ExperimentMetadata: Record<number, ExperimentMetadataEntry> = {
|
||||
[ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD]: {
|
||||
description: 'Threshold at which context compression activates.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
},
|
||||
[ExperimentFlags.USER_CACHING]: {
|
||||
description: 'Enables caching of user contexts.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES]: {
|
||||
description: 'Banner text displayed when there are no capacity issues.',
|
||||
schema: z.string(),
|
||||
defaultValue: '',
|
||||
},
|
||||
[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES]: {
|
||||
description: 'Banner text displayed during capacity issues.',
|
||||
schema: z.string(),
|
||||
defaultValue: '',
|
||||
},
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: {
|
||||
description: 'Enables preview features globally.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.ENABLE_NUMERICAL_ROUTING]: {
|
||||
description: 'Enables numerical routing strategies for the model.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.CLASSIFIER_THRESHOLD]: {
|
||||
description: 'Threshold for the intent classifier.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0.5,
|
||||
},
|
||||
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
|
||||
description: 'Enables admin control features in the CLI.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.MASKING_PROTECTION_THRESHOLD]: {
|
||||
description: 'Threshold for masking protection logic.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
settingKey: 'toolOutputMasking.toolProtectionThreshold',
|
||||
},
|
||||
[ExperimentFlags.MASKING_PRUNABLE_THRESHOLD]: {
|
||||
description: 'Threshold for prunable masking.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
settingKey: 'toolOutputMasking.minPrunableTokensThreshold',
|
||||
},
|
||||
[ExperimentFlags.MASKING_PROTECT_LATEST_TURN]: {
|
||||
description: 'Protects the latest turn from being masked.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
settingKey: 'toolOutputMasking.protectLatestTurn',
|
||||
},
|
||||
|
||||
// Migrated settings (marked hidden to keep /experiment list focused)
|
||||
[ExperimentFlags.ENABLE_AGENTS]: {
|
||||
description: 'Enable local and remote subagents.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'enableAgents',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_MANAGEMENT]: {
|
||||
description: 'Enable extension management features.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'extensionManagement',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_CONFIG]: {
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'extensionConfig',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_REGISTRY]: {
|
||||
description: 'Enable extension registry explore UI.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'extensionRegistry',
|
||||
},
|
||||
[ExperimentFlags.EXTENSION_RELOADING]: {
|
||||
description: 'Enables extension loading/unloading within the CLI session.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'extensionReloading',
|
||||
},
|
||||
[ExperimentFlags.JIT_CONTEXT]: {
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'jitContext',
|
||||
},
|
||||
[ExperimentFlags.USE_OSC52_PASTE]: {
|
||||
description: 'Use OSC 52 for pasting.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'useOSC52Paste',
|
||||
},
|
||||
[ExperimentFlags.USE_OSC52_COPY]: {
|
||||
description: 'Use OSC 52 for copying.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'useOSC52Copy',
|
||||
},
|
||||
[ExperimentFlags.PLAN]: {
|
||||
description: 'Enable planning features (Plan Mode and tools).',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'plan',
|
||||
},
|
||||
[ExperimentFlags.MODEL_STEERING]: {
|
||||
description: 'Enable model steering (user hints).',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
hidden: true,
|
||||
settingKey: 'modelSteering',
|
||||
},
|
||||
[ExperimentFlags.DISABLE_LLM_CORRECTION]: {
|
||||
description: 'Disable LLM-based error correction for edit tools.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'disableLLMCorrection',
|
||||
},
|
||||
[ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING]: {
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: true,
|
||||
hidden: true,
|
||||
settingKey: 'toolOutputMasking.enabled',
|
||||
},
|
||||
[ExperimentFlags.GEMINI_3_1_PRO_LAUNCHED]: {
|
||||
description: 'Indicates if Gemini 3.1 Pro has been launched.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.PRO_MODEL_NO_ACCESS]: {
|
||||
description: 'Indicates if the user has no access to Pro models.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.GEMINI_3_1_FLASH_LITE_LAUNCHED]: {
|
||||
description: 'Indicates if Gemini 3.1 Flash Lite has been launched.',
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
|
||||
description: 'The default request timeout in seconds.',
|
||||
schema: z.number(),
|
||||
defaultValue: 0,
|
||||
},
|
||||
[ExperimentFlags.ENABLE_AWESOME]: {
|
||||
description: "When enabled, the ASCII art says 'matt'.",
|
||||
schema: z.boolean(),
|
||||
defaultValue: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the name of an experiment flag from its ID.
|
||||
*/
|
||||
export function getExperimentFlagName(flagId: number): string | undefined {
|
||||
const metadata = ExperimentMetadata[flagId];
|
||||
if (metadata?.settingKey) {
|
||||
return metadata.settingKey;
|
||||
}
|
||||
|
||||
for (const [name, id] of Object.entries(ExperimentFlags)) {
|
||||
if (id === flagId) {
|
||||
return name.toLowerCase().replace(/_/g, '-');
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ID of an experiment flag from its name (supports kebab-case or camelCase).
|
||||
*/
|
||||
export function getExperimentFlagIdFromName(name: string): number | undefined {
|
||||
// Check metadata for explicit settingKey matches first
|
||||
for (const [idStr, metadata] of Object.entries(ExperimentMetadata)) {
|
||||
if (metadata.settingKey === name) {
|
||||
return parseInt(idStr, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert enableNumericalRouting or enable-numerical-routing to ENABLE_NUMERICAL_ROUTING
|
||||
const constantName = name
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2') // camelCase to snake_case
|
||||
.toUpperCase()
|
||||
.replace(/-/g, '_'); // kebab-case to snake_case
|
||||
return (ExperimentFlags as Record<string, number>)[constantName];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
|
||||
describe('Config CLI Override', () => {
|
||||
const sessionId = 'test-session';
|
||||
const targetDir = process.cwd();
|
||||
const cwd = process.cwd();
|
||||
const model = 'gemini-pro';
|
||||
|
||||
it('should prioritize CLI argument over local setting', async () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-numerical-routing': true },
|
||||
experimentalSettings: { 'enable-numerical-routing': false },
|
||||
});
|
||||
|
||||
expect(await config.getNumericalRoutingEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize CLI argument over remote experiment', async () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-numerical-routing': true },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_NUMERICAL_ROUTING]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await config.getNumericalRoutingEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
|
||||
describe('Config getExperimentValue', () => {
|
||||
const sessionId = 'test-session';
|
||||
const targetDir = process.cwd();
|
||||
const cwd = process.cwd();
|
||||
const model = 'gemini-pro';
|
||||
|
||||
it('should prioritize CLI argument over others', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-preview': true },
|
||||
experimentalSettings: { 'enable-preview': false },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize local setting over remote experiment', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalSettings: { 'enable-preview': true },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use remote experiment if no local override', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: true },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default value if nothing else is set', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
});
|
||||
|
||||
// Default for ENABLE_PREVIEW is false
|
||||
expect(config.getExperimentValue(ExperimentFlags.ENABLE_PREVIEW)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle numeric values correctly', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'classifier-threshold': 0.8 },
|
||||
});
|
||||
|
||||
expect(
|
||||
config.getExperimentValue<number>(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.8);
|
||||
});
|
||||
|
||||
it('should handle string representation of numbers from remote', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
debugMode: false,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.CLASSIFIER_THRESHOLD]: { stringValue: '0.7' },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
config.getExperimentValue<number>(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.7);
|
||||
});
|
||||
|
||||
it('should return true for isAwesomeEnabled when flag is set', () => {
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
targetDir,
|
||||
cwd,
|
||||
model,
|
||||
debugMode: false,
|
||||
experimentalCliArgs: { 'enable-awesome': true },
|
||||
});
|
||||
|
||||
expect(config.isAwesomeEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -545,9 +545,9 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(await config.getUserCaching()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined if there are no experiments', async () => {
|
||||
it('should return the default value if there are no experiments', async () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(await config.getUserCaching()).toBeUndefined();
|
||||
expect(await config.getUserCaching()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3006,78 +3006,6 @@ describe('Config Quota & Preview Model Access', () => {
|
||||
// Never set => stays null (unknown); getter returns true so UI shows preview
|
||||
expect(config.getHasAccessToPreviewModel()).toBe(true);
|
||||
});
|
||||
it('should derive quota from remainingFraction when remainingAmount is missing', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-3-flash-preview',
|
||||
remainingFraction: 0.96,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.setModel('gemini-3-flash-preview');
|
||||
mockCoreEvents.emitQuotaChanged.mockClear();
|
||||
await config.refreshUserQuota();
|
||||
|
||||
// Normalized: limit=100, remaining=96
|
||||
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
|
||||
96,
|
||||
100,
|
||||
undefined,
|
||||
);
|
||||
expect(config.getQuotaRemaining()).toBe(96);
|
||||
expect(config.getQuotaLimit()).toBe(100);
|
||||
});
|
||||
|
||||
it('should store quota from remainingFraction when remainingFraction is 0', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-3-pro-preview',
|
||||
remainingFraction: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.setModel('gemini-3-pro-preview');
|
||||
mockCoreEvents.emitQuotaChanged.mockClear();
|
||||
await config.refreshUserQuota();
|
||||
|
||||
// remaining=0, limit=100 but limit>0 check still passes
|
||||
// however remaining=0 means 0% remaining = 100% used
|
||||
expect(config.getQuotaRemaining()).toBe(0);
|
||||
expect(config.getQuotaLimit()).toBe(100);
|
||||
});
|
||||
|
||||
it('should emit QuotaChanged when model is switched via setModel', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-2.5-pro',
|
||||
remainingAmount: '10',
|
||||
remainingFraction: 0.2,
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-2.5-flash',
|
||||
remainingAmount: '80',
|
||||
remainingFraction: 0.8,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.setModel('auto-gemini-2.5');
|
||||
await config.refreshUserQuota();
|
||||
mockCoreEvents.emitQuotaChanged.mockClear();
|
||||
|
||||
// Switch to a specific model — should re-emit quota for that model
|
||||
config.setModel('gemini-2.5-pro');
|
||||
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
|
||||
10,
|
||||
50,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshUserQuotaIfStale', () => {
|
||||
|
||||
+203
-152
@@ -44,7 +44,6 @@ import { AskUserTool } from '../tools/ask-user.js';
|
||||
import { UpdateTopicTool } from '../tools/topicTool.js';
|
||||
import { TopicState } from './topicState.js';
|
||||
import { AgentTool } from '../agents/agent-tool.js';
|
||||
import { WatcherTool } from '../tools/watcherTool.js';
|
||||
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
|
||||
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
|
||||
import {
|
||||
@@ -129,6 +128,7 @@ import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
import { MemoryContextManager } from '../context/memoryContextManager.js';
|
||||
import { TrackerService } from '../services/trackerService.js';
|
||||
import type { GenerateContentParameters } from '@google/genai';
|
||||
import { ExperimentManager } from './experimentManager.js';
|
||||
|
||||
// Re-export OAuth config type
|
||||
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
|
||||
@@ -156,14 +156,19 @@ import type {
|
||||
} from '../code_assist/types.js';
|
||||
import type { HierarchicalMemory } from './memory.js';
|
||||
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
|
||||
import { AgentRegistry } from '../agents/registry.js';
|
||||
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
||||
import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
|
||||
import { SubagentTool } from '../agents/subagent-tool.js';
|
||||
import {
|
||||
ExperimentFlags,
|
||||
ExperimentMetadata,
|
||||
getExperimentFlagName,
|
||||
} from '../code_assist/experiments/flagNames.js';
|
||||
import {
|
||||
getExperiments,
|
||||
type Experiments,
|
||||
} from '../code_assist/experiments/experiments.js';
|
||||
import { AgentRegistry } from '../agents/registry.js';
|
||||
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
||||
import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
|
||||
import { startupProfiler } from '../telemetry/startupProfiler.js';
|
||||
@@ -443,7 +448,6 @@ import {
|
||||
DEFAULT_MIN_PRUNABLE_TOKENS_THRESHOLD,
|
||||
DEFAULT_PROTECT_LATEST_TURN,
|
||||
} from '../context/toolOutputMaskingService.js';
|
||||
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
SimpleExtensionLoader,
|
||||
@@ -693,6 +697,8 @@ export interface ConfigParameters {
|
||||
disabledHooks?: string[];
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||
enableAgents?: boolean;
|
||||
experimentalSettings?: Record<string, unknown>;
|
||||
experimentalCliArgs?: Record<string, unknown>;
|
||||
enableEventDrivenScheduler?: boolean;
|
||||
skillsSupport?: boolean;
|
||||
disabledSkills?: string[];
|
||||
@@ -704,8 +710,6 @@ export interface ConfigParameters {
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
experimentalAgentHistorySummarization?: boolean;
|
||||
experimentalWatcher?: boolean;
|
||||
experimentalWatcherInterval?: number;
|
||||
memoryBoundaryMarkers?: string[];
|
||||
topicUpdateNarration?: boolean;
|
||||
|
||||
@@ -820,7 +824,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly listExtensions: boolean;
|
||||
private readonly _extensionLoader: ExtensionLoader;
|
||||
private readonly _enabledExtensions: string[];
|
||||
private readonly enableExtensionReloading: boolean;
|
||||
fallbackModelHandler?: FallbackModelHandler;
|
||||
validationHandler?: ValidationHandler;
|
||||
private quotaErrorOccurred: boolean = false;
|
||||
@@ -835,16 +838,18 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private lastEmittedQuotaLimit: number | undefined;
|
||||
|
||||
private emitQuotaChangedEvent(): void {
|
||||
const remaining = this.getQuotaRemaining();
|
||||
const limit = this.getQuotaLimit();
|
||||
const resetTime = this.getQuotaResetTime();
|
||||
const pooled = this.getPooledQuota();
|
||||
if (
|
||||
this.lastEmittedQuotaRemaining !== remaining ||
|
||||
this.lastEmittedQuotaLimit !== limit
|
||||
this.lastEmittedQuotaRemaining !== pooled.remaining ||
|
||||
this.lastEmittedQuotaLimit !== pooled.limit
|
||||
) {
|
||||
this.lastEmittedQuotaRemaining = remaining;
|
||||
this.lastEmittedQuotaLimit = limit;
|
||||
coreEvents.emitQuotaChanged(remaining, limit, resetTime);
|
||||
this.lastEmittedQuotaRemaining = pooled.remaining;
|
||||
this.lastEmittedQuotaLimit = pooled.limit;
|
||||
coreEvents.emitQuotaChanged(
|
||||
pooled.remaining,
|
||||
pooled.limit,
|
||||
pooled.resetTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,6 +881,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly extensionRegistryURI: string | undefined;
|
||||
private readonly enablePromptCompletion: boolean = false;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
private compressionTruncationCounter = 0;
|
||||
private initialized = false;
|
||||
@@ -888,6 +894,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly workspacePoliciesDir: string | undefined;
|
||||
private readonly _messageBus: MessageBus;
|
||||
private readonly policyEngine: PolicyEngine;
|
||||
private readonly experimentManager: ExperimentManager;
|
||||
private policyUpdateConfirmationRequest:
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
@@ -912,14 +919,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private pendingIncludeDirectories: string[];
|
||||
private readonly enableHooksUI: boolean;
|
||||
private readonly enableHooks: boolean;
|
||||
|
||||
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
private projectHooks:
|
||||
| ({ [K in HookEventName]?: HookDefinition[] } & { disabled?: string[] })
|
||||
| undefined;
|
||||
private disabledHooks: string[];
|
||||
private experiments: Experiments | undefined;
|
||||
private experimentsPromise: Promise<Experiments | undefined> | undefined;
|
||||
private experimentsPromise: Promise<void> | undefined;
|
||||
private hookSystem?: HookSystem;
|
||||
private readonly onModelChange: ((model: string) => void) | undefined;
|
||||
private readonly onReload:
|
||||
@@ -942,8 +942,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly adminSkillsEnabled: boolean;
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalWatcher: boolean;
|
||||
private readonly experimentalWatcherInterval: number;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
@@ -953,6 +951,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly modelSteering: boolean;
|
||||
private memoryContextManager?: MemoryContextManager;
|
||||
private readonly contextManagement: ContextManagementConfig;
|
||||
private contextManager?: ContextManager;
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
private remoteAdminSettings: AdminControlsSettings | undefined;
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
@@ -1095,15 +1094,51 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this._activeModel = params.model;
|
||||
this.enableAgents = params.enableAgents ?? true;
|
||||
this.agents = params.agents ?? {};
|
||||
this.experimentalSettings = params.experimentalSettings ?? {};
|
||||
this.experimentalCliArgs = params.experimentalCliArgs ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? true;
|
||||
this.trackerEnabled = params.tracker ?? false;
|
||||
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
|
||||
|
||||
// Merge legacy experimental parameters into a single object for the manager
|
||||
const experimentalSettings = {
|
||||
...(params.experimentalSettings ?? {}),
|
||||
};
|
||||
if (params.plan !== undefined) experimentalSettings['plan'] = params.plan;
|
||||
if (params.experimentalJitContext !== undefined)
|
||||
experimentalSettings['jitContext'] = params.experimentalJitContext;
|
||||
if (params.enableAgents !== undefined)
|
||||
experimentalSettings['enableAgents'] = params.enableAgents;
|
||||
if (params.modelSteering !== undefined)
|
||||
experimentalSettings['modelSteering'] = params.modelSteering;
|
||||
if (params.enableExtensionReloading !== undefined)
|
||||
experimentalSettings['extensionReloading'] =
|
||||
params.enableExtensionReloading;
|
||||
if (params.extensionManagement !== undefined)
|
||||
experimentalSettings['extensionManagement'] = params.extensionManagement;
|
||||
if (params.disableLLMCorrection !== undefined)
|
||||
experimentalSettings['disableLLMCorrection'] =
|
||||
params.disableLLMCorrection;
|
||||
if (params.toolOutputMasking?.enabled !== undefined) {
|
||||
experimentalSettings['toolOutputMasking'] = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((experimentalSettings['toolOutputMasking'] as object) ?? {}),
|
||||
enabled: params.toolOutputMasking.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
this.experimentManager = new ExperimentManager({
|
||||
experimentalSettings,
|
||||
experimentalCliArgs: params.experimentalCliArgs,
|
||||
experiments: params.experiments,
|
||||
});
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
this.disabledSkills = params.disabledSkills ?? [];
|
||||
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||
<<<<<<< HEAD
|
||||
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
|
||||
|
||||
// HACK: The settings loading logic doesn't currently merge the default
|
||||
@@ -1155,8 +1190,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalWatcher = params.experimentalWatcher ?? false;
|
||||
this.experimentalWatcherInterval = params.experimentalWatcherInterval ?? 20;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
this.contextManagement = {
|
||||
enabled: params.contextManagement?.enabled ?? false,
|
||||
@@ -1349,7 +1382,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.projectHooks = params.projectHooks;
|
||||
}
|
||||
|
||||
this.experiments = params.experiments;
|
||||
this.onModelChange = params.onModelChange;
|
||||
this.onReload = params.onReload;
|
||||
|
||||
@@ -1414,7 +1446,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
// Add plans directory to workspace context for plan file storage
|
||||
if (this.planEnabled) {
|
||||
if (this.isPlanEnabled()) {
|
||||
const plansDir = this.storage.getPlansDir();
|
||||
try {
|
||||
await fs.promises.access(plansDir);
|
||||
@@ -1496,9 +1528,15 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
await this.hookSystem.initialize();
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
if (this.experimentalJitContext) {
|
||||
this.memoryContextManager = new MemoryContextManager(this);
|
||||
await this.memoryContextManager.refresh();
|
||||
=======
|
||||
if (this.isJitContextEnabled()) {
|
||||
this.contextManager = new ContextManager(this);
|
||||
await this.contextManager.refresh();
|
||||
>>>>>>> 49ab62d87 (refactor: move experiment logic to ExperimentManager with Zod validation)
|
||||
}
|
||||
|
||||
await this._geminiClient.initialize();
|
||||
@@ -1597,9 +1635,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
|
||||
const adminControlsEnabled =
|
||||
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
|
||||
false;
|
||||
// Fetch admin controls
|
||||
await this.ensureExperimentsLoaded();
|
||||
const adminControlsEnabled = !!this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_ADMIN_CONTROLS,
|
||||
);
|
||||
const adminControls = await fetchAdminControls(
|
||||
codeAssistServer,
|
||||
this.getRemoteAdminSettings(),
|
||||
@@ -1617,8 +1657,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getExperimentsAsync(): Promise<Experiments | undefined> {
|
||||
if (this.experiments) {
|
||||
return this.experiments;
|
||||
if (this.experimentManager.getExperiments()) {
|
||||
return this.experimentManager.getExperiments();
|
||||
}
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
return getExperiments(codeAssistServer);
|
||||
@@ -1824,9 +1864,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
// When the user explicitly sets a model, that becomes the active model.
|
||||
this._activeModel = newModel;
|
||||
coreEvents.emitModelChanged(newModel);
|
||||
this.lastEmittedQuotaRemaining = undefined;
|
||||
this.lastEmittedQuotaLimit = undefined;
|
||||
this.emitQuotaChangedEvent();
|
||||
}
|
||||
if (this.onModelChange && !isTemporary) {
|
||||
this.onModelChange(newModel);
|
||||
@@ -2120,31 +2157,24 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.lastQuotaFetchTime = Date.now();
|
||||
|
||||
for (const bucket of quota.buckets) {
|
||||
if (!bucket.modelId || bucket.remainingFraction == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remaining: number;
|
||||
let limit: number;
|
||||
|
||||
if (bucket.remainingAmount) {
|
||||
remaining = parseInt(bucket.remainingAmount, 10);
|
||||
limit =
|
||||
if (
|
||||
bucket.modelId &&
|
||||
bucket.remainingAmount &&
|
||||
bucket.remainingFraction != null
|
||||
) {
|
||||
const remaining = parseInt(bucket.remainingAmount, 10);
|
||||
const limit =
|
||||
bucket.remainingFraction > 0
|
||||
? Math.round(remaining / bucket.remainingFraction)
|
||||
: (this.modelQuotas.get(bucket.modelId)?.limit ?? 0);
|
||||
} else {
|
||||
// Server only sent remainingFraction — use a normalized scale.
|
||||
limit = 100;
|
||||
remaining = Math.round(bucket.remainingFraction * limit);
|
||||
}
|
||||
|
||||
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
|
||||
this.modelQuotas.set(bucket.modelId, {
|
||||
remaining,
|
||||
limit,
|
||||
resetTime: bucket.resetTime,
|
||||
});
|
||||
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
|
||||
this.modelQuotas.set(bucket.modelId, {
|
||||
remaining,
|
||||
limit,
|
||||
resetTime: bucket.resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.emitQuotaChangedEvent();
|
||||
@@ -2335,12 +2365,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getUserMemory(): string | HierarchicalMemory {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
return {
|
||||
global: this.memoryContextManager.getGlobalMemory(),
|
||||
extension: this.memoryContextManager.getExtensionMemory(),
|
||||
project: this.memoryContextManager.getEnvironmentMemory(),
|
||||
userProjectMemory: this.memoryContextManager.getUserProjectMemory(),
|
||||
global: this.contextManager.getGlobalMemory(),
|
||||
extension: this.contextManager.getExtensionMemory(),
|
||||
project: this.contextManager.getEnvironmentMemory(),
|
||||
userProjectMemory: this.contextManager.getUserProjectMemory(),
|
||||
};
|
||||
}
|
||||
return this.userMemory;
|
||||
@@ -2350,8 +2380,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* Refreshes the MCP context, including memory, tools, and system instructions.
|
||||
*/
|
||||
async refreshMcpContext(): Promise<void> {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
await this.memoryContextManager.refresh();
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
await this.contextManager.refresh();
|
||||
} else {
|
||||
const { refreshServerHierarchicalMemory } = await import(
|
||||
'../utils/memoryDiscovery.js'
|
||||
@@ -2426,7 +2456,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
isJitContextEnabled(): boolean {
|
||||
return this.experimentalJitContext;
|
||||
return this.experimentManager.isJitContextEnabled();
|
||||
}
|
||||
|
||||
isContextManagementEnabled(): boolean {
|
||||
@@ -2441,14 +2471,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
isExperimentalWatcherEnabled(): boolean {
|
||||
return this.experimentalWatcher;
|
||||
}
|
||||
|
||||
getExperimentalWatcherInterval(): number {
|
||||
return this.experimentalWatcherInterval;
|
||||
}
|
||||
|
||||
getContextManagementConfig(): ContextManagementConfig {
|
||||
return this.contextManagement;
|
||||
}
|
||||
@@ -2470,49 +2492,37 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
isModelSteeringEnabled(): boolean {
|
||||
return this.modelSteering;
|
||||
return this.experimentManager.isModelSteeringEnabled();
|
||||
}
|
||||
|
||||
getToolOutputMaskingEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING,
|
||||
)!;
|
||||
}
|
||||
|
||||
async getToolOutputMaskingConfig(): Promise<ToolOutputMaskingConfig> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const remoteProtection =
|
||||
this.experiments?.flags[ExperimentFlags.MASKING_PROTECTION_THRESHOLD]
|
||||
?.intValue;
|
||||
const remotePrunable =
|
||||
this.experiments?.flags[ExperimentFlags.MASKING_PRUNABLE_THRESHOLD]
|
||||
?.intValue;
|
||||
const remoteProtectLatest =
|
||||
this.experiments?.flags[ExperimentFlags.MASKING_PROTECT_LATEST_TURN]
|
||||
?.boolValue;
|
||||
|
||||
const parsedProtection = remoteProtection
|
||||
? parseInt(remoteProtection, 10)
|
||||
: undefined;
|
||||
const parsedPrunable = remotePrunable
|
||||
? parseInt(remotePrunable, 10)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
protectionThresholdTokens:
|
||||
parsedProtection !== undefined && !isNaN(parsedProtection)
|
||||
? parsedProtection
|
||||
: this.contextManagement.tools.outputMasking
|
||||
.protectionThresholdTokens,
|
||||
minPrunableThresholdTokens:
|
||||
parsedPrunable !== undefined && !isNaN(parsedPrunable)
|
||||
? parsedPrunable
|
||||
: this.contextManagement.tools.outputMasking
|
||||
.minPrunableThresholdTokens,
|
||||
protectLatestTurn:
|
||||
remoteProtectLatest ??
|
||||
this.contextManagement.tools.outputMasking.protectLatestTurn,
|
||||
enabled: this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING,
|
||||
)!,
|
||||
toolProtectionThreshold: this.getExperimentValue<number>(
|
||||
ExperimentFlags.MASKING_PROTECTION_THRESHOLD,
|
||||
)!,
|
||||
minPrunableTokensThreshold: this.getExperimentValue<number>(
|
||||
ExperimentFlags.MASKING_PRUNABLE_THRESHOLD,
|
||||
)!,
|
||||
protectLatestTurn: this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.MASKING_PROTECT_LATEST_TURN,
|
||||
)!,
|
||||
};
|
||||
}
|
||||
|
||||
getGeminiMdFileCount(): number {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
return this.memoryContextManager.getLoadedPaths().size;
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
return this.contextManager.getLoadedPaths().size;
|
||||
}
|
||||
return this.geminiMdFileCount;
|
||||
}
|
||||
@@ -2522,8 +2532,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getGeminiMdFilePaths(): string[] {
|
||||
if (this.experimentalJitContext && this.memoryContextManager) {
|
||||
return Array.from(this.memoryContextManager.getLoadedPaths());
|
||||
if (this.isJitContextEnabled() && this.contextManager) {
|
||||
return Array.from(this.contextManager.getLoadedPaths());
|
||||
}
|
||||
return this.geminiMdFilePaths;
|
||||
}
|
||||
@@ -2621,6 +2631,45 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes enter/exit plan mode tools based on current mode.
|
||||
*/
|
||||
syncPlanModeTools(): void {
|
||||
const registry = this.getToolRegistry();
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
const approvalMode = this.getApprovalMode();
|
||||
const isPlanMode = approvalMode === ApprovalMode.PLAN;
|
||||
const isYoloMode = approvalMode === ApprovalMode.YOLO;
|
||||
|
||||
if (isPlanMode) {
|
||||
if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME);
|
||||
}
|
||||
if (!registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.registerTool(new ExitPlanModeTool(this, this.messageBus));
|
||||
}
|
||||
} else {
|
||||
if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME);
|
||||
}
|
||||
if (this.isPlanEnabled() && !isYoloMode) {
|
||||
if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.registerTool(new EnterPlanModeTool(this, this.messageBus));
|
||||
}
|
||||
} else {
|
||||
if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
|
||||
registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.geminiClient?.isInitialized()) {
|
||||
this.geminiClient.setTools().catch((err) => {
|
||||
debugLogger.error('Failed to update tools', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
* Logs the duration of the current approval mode.
|
||||
*/
|
||||
logCurrentModeDuration(mode: ApprovalMode): void {
|
||||
@@ -2852,7 +2901,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getExtensionManagement(): boolean {
|
||||
return this.extensionManagement;
|
||||
return this.experimentManager.getExperimentValue<boolean>(
|
||||
ExperimentFlags.EXTENSION_MANAGEMENT,
|
||||
);
|
||||
}
|
||||
|
||||
getExtensions(): GeminiCLIExtension[] {
|
||||
@@ -2870,15 +2921,15 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
getEnableExtensionReloading(): boolean {
|
||||
return this.enableExtensionReloading;
|
||||
return this.experimentManager.getEnableExtensionReloading();
|
||||
}
|
||||
|
||||
getDisableLLMCorrection(): boolean {
|
||||
return this.disableLLMCorrection;
|
||||
return this.experimentManager.getDisableLLMCorrection();
|
||||
}
|
||||
|
||||
isPlanEnabled(): boolean {
|
||||
return this.planEnabled;
|
||||
return this.experimentManager.isPlanEnabled();
|
||||
}
|
||||
|
||||
isTrackerEnabled(): boolean {
|
||||
@@ -2898,7 +2949,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
isAgentsEnabled(): boolean {
|
||||
return this.enableAgents;
|
||||
return this.experimentManager.isAgentsEnabled();
|
||||
}
|
||||
|
||||
isEventDrivenSchedulerEnabled(): boolean {
|
||||
@@ -3023,9 +3074,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const remoteThreshold =
|
||||
this.experiments?.flags[ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD]
|
||||
?.floatValue;
|
||||
const remoteThreshold = this.getExperimentValue<number>(
|
||||
ExperimentFlags.CONTEXT_COMPRESSION_THRESHOLD,
|
||||
);
|
||||
if (remoteThreshold === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -3033,9 +3084,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getUserCaching(): Promise<boolean | undefined> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue;
|
||||
return this.experimentManager.getUserCaching();
|
||||
}
|
||||
|
||||
async getPlanModeRoutingEnabled(): Promise<boolean> {
|
||||
@@ -3043,11 +3092,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getNumericalRoutingEnabled(): Promise<boolean> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const flag =
|
||||
this.experiments?.flags[ExperimentFlags.ENABLE_NUMERICAL_ROUTING];
|
||||
return flag?.boolValue ?? true;
|
||||
return this.experimentManager.isNumericalRoutingEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3072,29 +3117,15 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
async getClassifierThreshold(): Promise<number | undefined> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
const flag = this.experiments?.flags[ExperimentFlags.CLASSIFIER_THRESHOLD];
|
||||
if (flag?.intValue !== undefined) {
|
||||
return parseInt(flag.intValue, 10);
|
||||
}
|
||||
return flag?.floatValue;
|
||||
return this.experimentManager.getClassifierThreshold();
|
||||
}
|
||||
|
||||
async getBannerTextNoCapacityIssues(): Promise<string> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES]
|
||||
?.stringValue ?? ''
|
||||
);
|
||||
return this.experimentManager.getBannerTextNoCapacityIssues();
|
||||
}
|
||||
|
||||
async getBannerTextCapacityIssues(): Promise<string> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
return (
|
||||
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES]
|
||||
?.stringValue ?? ''
|
||||
);
|
||||
return this.experimentManager.getBannerTextCapacityIssues();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3218,6 +3249,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
);
|
||||
}
|
||||
|
||||
isAwesomeEnabled(): boolean {
|
||||
return (
|
||||
this.getExperimentValue<boolean>(ExperimentFlags.ENABLE_AWESOME) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureExperimentsLoaded(): Promise<void> {
|
||||
if (!this.experimentsPromise) {
|
||||
return;
|
||||
@@ -3651,12 +3688,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
registry.registerTool(new AgentTool(this, this.messageBus)),
|
||||
);
|
||||
|
||||
if (this.isExperimentalWatcherEnabled()) {
|
||||
maybeRegister(WatcherTool, () =>
|
||||
registry.registerTool(new WatcherTool(this, this.messageBus)),
|
||||
);
|
||||
}
|
||||
|
||||
await registry.discoverAllTools();
|
||||
registry.sortTools();
|
||||
return registry;
|
||||
@@ -3703,14 +3734,34 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* Get experiments configuration
|
||||
*/
|
||||
getExperiments(): Experiments | undefined {
|
||||
return this.experiments;
|
||||
return this.experimentManager.getExperiments();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the value of an experiment flag based on priority:
|
||||
* 1. Command-line argument (--experiment-<flag-name>)
|
||||
* 2. Local setting (experimental.<flag-name>)
|
||||
* 3. Remote experiment
|
||||
* 4. Default value
|
||||
*/
|
||||
getExperimentValue<T extends boolean | number | string>(
|
||||
flagId: number,
|
||||
): T | undefined {
|
||||
return this.experimentManager.getExperimentValue<T>(flagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates experimental settings.
|
||||
*/
|
||||
updateExperimentalSettings(settings: Record<string, unknown>): void {
|
||||
this.experimentManager.updateExperimentalSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set experiments configuration
|
||||
*/
|
||||
setExperiments(experiments: Experiments): void {
|
||||
this.experiments = experiments;
|
||||
this.experimentManager.setExperiments(experiments);
|
||||
const flagSummaries = Object.entries(experiments.flags ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([flagId, flag]) => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExperimentManager } from './experimentManager.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
|
||||
describe('ExperimentManager', () => {
|
||||
const baseOptions = {
|
||||
experimentalSettings: {},
|
||||
experimentalCliArgs: {},
|
||||
};
|
||||
|
||||
describe('getExperimentValue', () => {
|
||||
it('should return default value when no overrides are present', () => {
|
||||
const manager = new ExperimentManager(baseOptions);
|
||||
// USER_CACHING default is false
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize CLI arguments over all else', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalCliArgs: { 'user-caching': true },
|
||||
experimentalSettings: { 'user-caching': false },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.USER_CACHING]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize local settings over remote experiments', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: { 'user-caching': true },
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.USER_CACHING]: { boolValue: false },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use remote experiment if no local override', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.USER_CACHING]: { boolValue: true },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(manager.getExperimentValue(ExperimentFlags.USER_CACHING)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nested settings correctly', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: {
|
||||
toolOutputMasking: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
manager.getExperimentValue(ExperimentFlags.ENABLE_TOOL_OUTPUT_MASKING),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate values using Zod schema', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: {
|
||||
'classifier-threshold': 'not-a-number',
|
||||
},
|
||||
});
|
||||
// CLASSIFIER_THRESHOLD default is 0.5
|
||||
expect(
|
||||
manager.getExperimentValue(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should parse numeric strings from remote flags if metadata type is number', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experiments: {
|
||||
flags: {
|
||||
[ExperimentFlags.CLASSIFIER_THRESHOLD]: { stringValue: '0.8' },
|
||||
},
|
||||
experimentIds: [],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
manager.getExperimentValue(ExperimentFlags.CLASSIFIER_THRESHOLD),
|
||||
).toBe(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convenience getters', () => {
|
||||
it('isPlanEnabled should resolve correctly', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: { plan: true },
|
||||
});
|
||||
expect(manager.isPlanEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('isAgentsEnabled should resolve correctly', () => {
|
||||
const manager = new ExperimentManager({
|
||||
...baseOptions,
|
||||
experimentalSettings: { enableAgents: true },
|
||||
});
|
||||
expect(manager.isAgentsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ExperimentFlags,
|
||||
ExperimentMetadata,
|
||||
getExperimentFlagName,
|
||||
type ExperimentMetadataEntry,
|
||||
} from '../code_assist/experiments/flagNames.js';
|
||||
import type { Experiments } from '../code_assist/experiments/experiments.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ExperimentManagerOptions {
|
||||
experimentalSettings?: Record<string, unknown>;
|
||||
experimentalCliArgs?: Record<string, unknown>;
|
||||
experiments?: Experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages resolution and validation of experimental flags.
|
||||
*/
|
||||
export class ExperimentManager {
|
||||
private experimentalSettings: Record<string, unknown>;
|
||||
private readonly experimentalCliArgs: Record<string, unknown>;
|
||||
private experiments?: Experiments;
|
||||
|
||||
constructor(options: ExperimentManagerOptions) {
|
||||
this.experimentalSettings = options.experimentalSettings ?? {};
|
||||
this.experimentalCliArgs = options.experimentalCliArgs ?? {};
|
||||
this.experiments = options.experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the value of an experiment flag, applying layering logic:
|
||||
* 1. Command-line argument
|
||||
* 2. Local setting (settings.json)
|
||||
* 3. Remote experiment (server-side)
|
||||
* 4. Default value (from metadata)
|
||||
*/
|
||||
getExperimentValue<T>(flagId: number): T {
|
||||
const metadata = ExperimentMetadata[flagId];
|
||||
if (!metadata) {
|
||||
debugLogger.warn(`Unknown experiment flag ID: ${flagId}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
const flagName = getExperimentFlagName(flagId);
|
||||
|
||||
// 1. CLI Argument
|
||||
if (flagName && this.experimentalCliArgs[flagName] !== undefined) {
|
||||
let val: unknown = this.experimentalCliArgs[flagName];
|
||||
// Type coercion for CLI args
|
||||
val = this.coerceValue(val, metadata);
|
||||
|
||||
const result = metadata.schema.safeParse(val);
|
||||
if (result.success) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result.data as T;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`Invalid CLI value for ${flagName}: ${val}. Error: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Local Setting (settings.json)
|
||||
const settingKey = metadata.settingKey || flagName;
|
||||
if (settingKey) {
|
||||
const val = this.getNestedValue(this.experimentalSettings, settingKey);
|
||||
if (val !== undefined) {
|
||||
const result = metadata.schema.safeParse(val);
|
||||
if (result.success) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result.data as T;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`Invalid local setting for ${settingKey}: ${val}. Error: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remote Experiment
|
||||
const remoteFlag = this.experiments?.flags[flagId];
|
||||
if (remoteFlag) {
|
||||
let val: unknown =
|
||||
remoteFlag.boolValue ??
|
||||
remoteFlag.floatValue ??
|
||||
(remoteFlag.intValue ? Number(remoteFlag.intValue) : undefined) ??
|
||||
remoteFlag.stringValue;
|
||||
|
||||
if (val !== undefined) {
|
||||
val = this.coerceValue(val, metadata);
|
||||
const result = metadata.schema.safeParse(val);
|
||||
if (result.success) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result.data as T;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`Invalid remote value for flag ${flagId}: ${val}. Error: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Default Value
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return metadata.defaultValue as T;
|
||||
}
|
||||
|
||||
private coerceValue(
|
||||
val: unknown,
|
||||
metadata: ExperimentMetadataEntry,
|
||||
): unknown {
|
||||
if (metadata.schema instanceof z.ZodNumber && typeof val === 'string') {
|
||||
const num = Number(val);
|
||||
if (!isNaN(num)) return num;
|
||||
}
|
||||
if (metadata.schema instanceof z.ZodBoolean && typeof val === 'string') {
|
||||
if (val === 'true' || val === 'on') return true;
|
||||
if (val === 'false' || val === 'off') return false;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || typeof current !== 'object') return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local experimental settings.
|
||||
*/
|
||||
updateExperimentalSettings(settings: Record<string, unknown>): void {
|
||||
this.experimentalSettings = {
|
||||
...this.experimentalSettings,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates remote experiments.
|
||||
*/
|
||||
setExperiments(experiments: Experiments): void {
|
||||
this.experiments = experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all experimental settings (for serialization/display).
|
||||
*/
|
||||
getExperimentalSettings(): Record<string, unknown> {
|
||||
return this.experimentalSettings;
|
||||
}
|
||||
|
||||
getExperiments(): Experiments | undefined {
|
||||
return this.experiments;
|
||||
}
|
||||
|
||||
// Convenience getters for commonly used flags
|
||||
|
||||
isPlanEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.PLAN);
|
||||
}
|
||||
|
||||
isAgentsEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.ENABLE_AGENTS);
|
||||
}
|
||||
|
||||
getEnableExtensionReloading(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.EXTENSION_RELOADING,
|
||||
);
|
||||
}
|
||||
|
||||
getDisableLLMCorrection(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.DISABLE_LLM_CORRECTION,
|
||||
);
|
||||
}
|
||||
|
||||
isModelSteeringEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.MODEL_STEERING);
|
||||
}
|
||||
|
||||
isJitContextEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.JIT_CONTEXT);
|
||||
}
|
||||
|
||||
isNumericalRoutingEnabled(): boolean {
|
||||
return this.getExperimentValue<boolean>(
|
||||
ExperimentFlags.ENABLE_NUMERICAL_ROUTING,
|
||||
);
|
||||
}
|
||||
|
||||
getClassifierThreshold(): number | undefined {
|
||||
return this.getExperimentValue<number>(
|
||||
ExperimentFlags.CLASSIFIER_THRESHOLD,
|
||||
);
|
||||
}
|
||||
|
||||
getUserCaching(): boolean {
|
||||
return this.getExperimentValue<boolean>(ExperimentFlags.USER_CACHING);
|
||||
}
|
||||
|
||||
getBannerTextNoCapacityIssues(): string {
|
||||
return this.getExperimentValue<string>(
|
||||
ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES,
|
||||
);
|
||||
}
|
||||
|
||||
getBannerTextCapacityIssues(): string {
|
||||
return this.getExperimentValue<string>(
|
||||
ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
type Tool,
|
||||
type GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { partListUnionToString } from './geminiRequest.js';
|
||||
import {
|
||||
getDirectoryContextString,
|
||||
@@ -47,9 +45,6 @@ import type { ContentGenerator } from './contentGenerator.js';
|
||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||
import { ChatCompressionService } from '../context/chatCompressionService.js';
|
||||
import { AgentHistoryProvider } from '../context/agentHistoryProvider.js';
|
||||
import { isSubagentProgress, type WatcherProgress } from '../agents/types.js';
|
||||
import { extractAndParseJson } from '../utils/jsonUtils.js';
|
||||
import { WatcherReportSchema } from '../agents/watcher-agent.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import {
|
||||
logContentRetryFailure,
|
||||
@@ -323,20 +318,6 @@ export class GeminiClient {
|
||||
dispose() {
|
||||
coreEvents.off(CoreEvent.ModelChanged, this.handleModelChanged);
|
||||
coreEvents.off(CoreEvent.MemoryChanged, this.handleMemoryChanged);
|
||||
|
||||
// Clean up Watcher status file
|
||||
try {
|
||||
const projectTempDir = this.config.storage.getProjectTempDir();
|
||||
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
|
||||
if (fs.existsSync(statusFilePath)) {
|
||||
fs.unlinkSync(statusFilePath);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
'Failed to clean up watcher status file during dispose',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async resumeChat(
|
||||
@@ -617,8 +598,10 @@ export class GeminiClient {
|
||||
isInvalidStreamRetry: boolean,
|
||||
displayContent?: PartListUnion,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||
// Re-initialize turn (it was empty before if in loop, or new instance)
|
||||
let turn = new Turn(this.getChat(), prompt_id);
|
||||
|
||||
this.sessionTurnCount++;
|
||||
if (
|
||||
this.config.getMaxSessionTurns() > 0 &&
|
||||
this.sessionTurnCount > this.config.getMaxSessionTurns()
|
||||
@@ -894,7 +877,6 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turn;
|
||||
}
|
||||
|
||||
@@ -909,7 +891,6 @@ export class GeminiClient {
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||
if (!isInvalidStreamRetry) {
|
||||
this.config.resetTurn();
|
||||
this.sessionTurnCount++;
|
||||
}
|
||||
|
||||
const hooksEnabled = this.config.getEnableHooks();
|
||||
@@ -1052,41 +1033,6 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger Watcher after the full interaction (including tool recursions) is complete.
|
||||
// But only if we are at the top-level sendMessageStream (not a continuation).
|
||||
if (!continuationHandled && !isInvalidStreamRetry && !stopHookActive) {
|
||||
const watcherInterval = this.config.getExperimentalWatcherInterval();
|
||||
const currentTurn = this.sessionTurnCount;
|
||||
debugLogger.log(
|
||||
`[Watcher] Checking if trigger is needed at turn ${currentTurn}`,
|
||||
);
|
||||
if (
|
||||
this.config.isExperimentalWatcherEnabled() &&
|
||||
currentTurn > 0 &&
|
||||
(currentTurn === 1 || currentTurn % watcherInterval === 0)
|
||||
) {
|
||||
debugLogger.log(
|
||||
`[Watcher] Triggering subagent at turn ${currentTurn}`,
|
||||
);
|
||||
const watcherResult = await this.tryRunWatcher(prompt_id, signal);
|
||||
if (watcherResult?.feedback) {
|
||||
debugLogger.log(
|
||||
`[Watcher] Feedback provided: ${watcherResult.feedback}`,
|
||||
);
|
||||
const feedback = watcherResult.feedback;
|
||||
const feedbackRequest = [
|
||||
{
|
||||
text: `System: EXTREMELY IMPORTANT Feedback from Watcher Sub Agent based on recent progress (Review of last ${watcherInterval} turns):\n\n${feedback}`,
|
||||
},
|
||||
];
|
||||
// Inject feedback into the conversation for the NEXT turn
|
||||
this.getChat().addHistory(createUserContent(feedbackRequest));
|
||||
} else {
|
||||
debugLogger.log('[Watcher] No feedback provided.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turn;
|
||||
@@ -1333,128 +1279,4 @@ export class GeminiClient {
|
||||
displayContent,
|
||||
);
|
||||
}
|
||||
|
||||
private async tryRunWatcher(
|
||||
prompt_id: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<WatcherProgress | undefined> {
|
||||
const watcherTool = this.context.toolRegistry.getTool('watcher');
|
||||
if (!watcherTool) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const interval = this.config.getExperimentalWatcherInterval();
|
||||
|
||||
const projectTempDir = this.config.storage.getProjectTempDir();
|
||||
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
|
||||
|
||||
// Ensure the file exists before the subagent tries to read it
|
||||
if (!fs.existsSync(statusFilePath)) {
|
||||
try {
|
||||
if (!fs.existsSync(projectTempDir)) {
|
||||
fs.mkdirSync(projectTempDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(statusFilePath, 'EMPTY', 'utf-8');
|
||||
} catch (e) {
|
||||
debugLogger.warn('Failed to initialize watcher status file', e);
|
||||
}
|
||||
}
|
||||
|
||||
const history = this.getHistory();
|
||||
// Get last N turns (approx)
|
||||
const recentHistory = history
|
||||
.slice(-interval * 2)
|
||||
.map((m) => {
|
||||
const role = m.role ?? 'unknown';
|
||||
const parts =
|
||||
m.parts
|
||||
?.map((p) => {
|
||||
if (typeof p === 'string') return p;
|
||||
if (p && typeof p === 'object') {
|
||||
if ('text' in p && typeof p.text === 'string') return p.text;
|
||||
if (
|
||||
'functionCall' in p &&
|
||||
p.functionCall &&
|
||||
typeof p.functionCall === 'object' &&
|
||||
'name' in p.functionCall &&
|
||||
'args' in p.functionCall
|
||||
) {
|
||||
return `[CALL: ${String(p.functionCall.name)}(${JSON.stringify(p.functionCall.args)})]`;
|
||||
}
|
||||
if (
|
||||
'functionResponse' in p &&
|
||||
p.functionResponse &&
|
||||
typeof p.functionResponse === 'object' &&
|
||||
'name' in p.functionResponse &&
|
||||
'response' in p.functionResponse
|
||||
) {
|
||||
return `[RESULT: ${String(p.functionResponse.name)} -> ${JSON.stringify(p.functionResponse.response)}]`;
|
||||
}
|
||||
}
|
||||
return partToString(p, { verbose: true });
|
||||
})
|
||||
.join('\n') ?? '';
|
||||
return `[${role.toUpperCase()}]: ${parts}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
try {
|
||||
debugLogger.log('[Watcher] Executing subagent...');
|
||||
const invocation = watcherTool.build({ recentHistory });
|
||||
const result = await invocation.execute({ abortSignal: signal });
|
||||
|
||||
if (
|
||||
isSubagentProgress(result.returnDisplay) &&
|
||||
result.returnDisplay.result
|
||||
) {
|
||||
try {
|
||||
const rawOutput = result.returnDisplay.result;
|
||||
debugLogger.log(`[Watcher] Raw content response: ${rawOutput}`);
|
||||
const parsed = WatcherReportSchema.parse(
|
||||
extractAndParseJson(rawOutput),
|
||||
);
|
||||
|
||||
// Internally write the status report to avoid requiring user permission
|
||||
const projectTempDir = this.config.storage.getProjectTempDir();
|
||||
const statusFilePath = path.join(
|
||||
projectTempDir,
|
||||
'.sys_state_cache.log',
|
||||
);
|
||||
debugLogger.log(
|
||||
`[Watcher] Writing status report to ${statusFilePath}`,
|
||||
);
|
||||
const reportLines = [
|
||||
'# Watcher Memory State',
|
||||
'',
|
||||
'## 1. Primary User Goal',
|
||||
parsed.primaryUserGoal,
|
||||
'',
|
||||
'## 2. Progress Summary',
|
||||
parsed.progressSummary,
|
||||
'',
|
||||
'## 3. Current Trajectory Evaluation',
|
||||
`State: ${parsed.evaluation}`,
|
||||
'',
|
||||
'## 4. Feedback',
|
||||
parsed.feedback ?? 'N/A',
|
||||
];
|
||||
fs.writeFileSync(statusFilePath, reportLines.join('\n'), 'utf-8');
|
||||
|
||||
debugLogger.log('[Watcher] Subagent execution complete.');
|
||||
return parsed as WatcherProgress;
|
||||
} catch (e) {
|
||||
debugLogger.warn('Failed to parse watcher output', e);
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
'[Watcher] Subagent did not return structured result in returnDisplay',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.warn('Error running watcher subagent', e);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { GeminiClient } from './client.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
describe('GeminiClient Watcher Integration', () => {
|
||||
let config: Config;
|
||||
let client: GeminiClient;
|
||||
let mockContentGenerator: {
|
||||
countTokens: ReturnType<typeof vi.fn>;
|
||||
generateContentStream: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', '');
|
||||
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
|
||||
config = makeFakeConfig();
|
||||
|
||||
mockContentGenerator = {
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 10 }),
|
||||
generateContentStream: vi.fn().mockReturnValue({
|
||||
stream: (async function* () {
|
||||
yield {
|
||||
response: {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
},
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
};
|
||||
vi.spyOn(config, 'getContentGenerator').mockReturnValue(
|
||||
mockContentGenerator as unknown as ReturnType<
|
||||
typeof config.getContentGenerator
|
||||
>,
|
||||
);
|
||||
|
||||
client = new GeminiClient(config as unknown as AgentLoopContext);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
const projectTempDir = config.storage.getProjectTempDir();
|
||||
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
|
||||
if (fs.existsSync(statusFilePath)) {
|
||||
fs.unlinkSync(statusFilePath);
|
||||
}
|
||||
});
|
||||
|
||||
const createMockWatcherTool = (resultData: unknown) => ({
|
||||
build: vi.fn().mockReturnValue({
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: [{ text: 'Subagent finished' }],
|
||||
returnDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'watcher',
|
||||
recentActivity: [],
|
||||
state: 'completed',
|
||||
result: resultData ? JSON.stringify(resultData) : undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
name: 'watcher',
|
||||
displayName: 'Watcher',
|
||||
description: 'Watcher tool',
|
||||
inputConfig: {
|
||||
inputSchema: {},
|
||||
},
|
||||
outputConfig: {
|
||||
outputName: 'report',
|
||||
schema: {},
|
||||
},
|
||||
});
|
||||
|
||||
it('should trigger watcher periodically when enabled', async () => {
|
||||
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
|
||||
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(2);
|
||||
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
|
||||
|
||||
const mockWatcherTool = createMockWatcherTool({
|
||||
primaryUserGoal: 'Keep testing',
|
||||
progressSummary: 'Test in progress',
|
||||
evaluation: 'Good',
|
||||
feedback: 'Keep going',
|
||||
});
|
||||
|
||||
const mockToolRegistry = {
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||
getTool: vi.fn().mockImplementation((name) => {
|
||||
if (name === 'watcher') return mockWatcherTool;
|
||||
return undefined;
|
||||
}),
|
||||
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
|
||||
sortTools: vi.fn(),
|
||||
discoverAllTools: vi.fn(),
|
||||
};
|
||||
|
||||
const clientAccess = client as unknown as {
|
||||
context: AgentLoopContext;
|
||||
};
|
||||
|
||||
Object.defineProperty(clientAccess.context, 'toolRegistry', {
|
||||
get: () => mockToolRegistry,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(
|
||||
clientAccess.context as unknown as { agentRegistry: unknown }
|
||||
).agentRegistry = {
|
||||
getAllDefinitions: vi.fn().mockReturnValue([]),
|
||||
getDefinition: vi.fn().mockReturnValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await config.storage.initialize();
|
||||
await client.initialize();
|
||||
|
||||
const promptId = 'test-prompt';
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const generator = client.sendMessageStream(
|
||||
[{ text: 'test' }],
|
||||
signal,
|
||||
promptId,
|
||||
);
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(mockWatcherTool.build).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT trigger watcher when NOT enabled', async () => {
|
||||
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(false);
|
||||
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(2);
|
||||
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
|
||||
|
||||
const mockWatcherTool = {
|
||||
build: vi.fn(),
|
||||
name: 'watcher',
|
||||
};
|
||||
|
||||
const mockToolRegistry = {
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||
getTool: vi.fn().mockImplementation((name) => {
|
||||
if (name === 'watcher') return mockWatcherTool;
|
||||
return undefined;
|
||||
}),
|
||||
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
|
||||
sortTools: vi.fn(),
|
||||
discoverAllTools: vi.fn(),
|
||||
};
|
||||
|
||||
const clientAccess = client as unknown as {
|
||||
context: AgentLoopContext;
|
||||
};
|
||||
|
||||
Object.defineProperty(clientAccess.context, 'toolRegistry', {
|
||||
get: () => mockToolRegistry,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(
|
||||
clientAccess.context as unknown as { agentRegistry: unknown }
|
||||
).agentRegistry = {
|
||||
getAllDefinitions: vi.fn().mockReturnValue([]),
|
||||
getDefinition: vi.fn().mockReturnValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await config.storage.initialize();
|
||||
await client.initialize();
|
||||
|
||||
const promptId = 'test-prompt';
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const generator = client.sendMessageStream(
|
||||
[{ text: 'test' }],
|
||||
signal,
|
||||
promptId,
|
||||
);
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(mockWatcherTool.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger watcher multiple times in a long conversation and update status file', async () => {
|
||||
const interval = 5;
|
||||
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
|
||||
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(
|
||||
interval,
|
||||
);
|
||||
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
|
||||
|
||||
const mockWatcherTool = createMockWatcherTool({
|
||||
primaryUserGoal: 'Keep testing',
|
||||
progressSummary: 'Test in progress',
|
||||
evaluation: 'Good',
|
||||
feedback: 'Keep going',
|
||||
});
|
||||
|
||||
const mockToolRegistry = {
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||
getTool: vi.fn().mockImplementation((name) => {
|
||||
if (name === 'watcher') return mockWatcherTool;
|
||||
return undefined;
|
||||
}),
|
||||
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
|
||||
sortTools: vi.fn(),
|
||||
discoverAllTools: vi.fn(),
|
||||
};
|
||||
|
||||
const clientAccess = client as unknown as {
|
||||
context: AgentLoopContext;
|
||||
};
|
||||
|
||||
Object.defineProperty(clientAccess.context, 'toolRegistry', {
|
||||
get: () => mockToolRegistry,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(
|
||||
clientAccess.context as unknown as { agentRegistry: unknown }
|
||||
).agentRegistry = {
|
||||
getAllDefinitions: vi.fn().mockReturnValue([]),
|
||||
getDefinition: vi.fn().mockReturnValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await config.storage.initialize();
|
||||
await client.initialize();
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
// Simulate 11 turns
|
||||
for (let i = 1; i <= 11; i++) {
|
||||
const promptId = `test-prompt-${i}`;
|
||||
const generator = client.sendMessageStream(
|
||||
[{ text: `turn ${i}` }],
|
||||
signal,
|
||||
promptId,
|
||||
);
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
}
|
||||
|
||||
// With interval 5, it should trigger at turn 1, turn 5 and turn 10
|
||||
expect(mockWatcherTool.build).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify the status file exists (written by GeminiClient internally)
|
||||
const projectTempDir = config.storage.getProjectTempDir();
|
||||
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
|
||||
expect(fs.existsSync(statusFilePath)).toBe(true);
|
||||
const content = fs.readFileSync(statusFilePath, 'utf-8');
|
||||
expect(content).toContain('# Watcher Memory State');
|
||||
expect(content).toContain('Keep testing');
|
||||
|
||||
// Verify cleanup in dispose
|
||||
client.dispose();
|
||||
expect(fs.existsSync(statusFilePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should robustly handle messy subagent output with conversational filler and markdown', async () => {
|
||||
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
|
||||
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(1);
|
||||
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
|
||||
|
||||
const reportData = {
|
||||
primaryUserGoal: 'Messy test direction',
|
||||
progressSummary: 'Messy progress',
|
||||
evaluation: 'ON_TRACK',
|
||||
};
|
||||
|
||||
const messyOutput = `
|
||||
Subagent "watcher" finished with result:
|
||||
\`\`\`json
|
||||
${JSON.stringify(reportData, null, 2)}
|
||||
\`\`\`
|
||||
I hope this status update is helpful!
|
||||
`;
|
||||
|
||||
const mockWatcherTool = {
|
||||
build: vi.fn().mockReturnValue({
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: [{ text: 'Subagent finished' }],
|
||||
returnDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'watcher',
|
||||
recentActivity: [],
|
||||
state: 'completed',
|
||||
result: messyOutput,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
name: 'watcher',
|
||||
displayName: 'Watcher',
|
||||
description: 'Watcher tool',
|
||||
inputConfig: { inputSchema: {} },
|
||||
outputConfig: { outputName: 'report', schema: {} },
|
||||
};
|
||||
|
||||
const mockToolRegistry = {
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||
getTool: vi.fn().mockImplementation((name) => {
|
||||
if (name === 'watcher') return mockWatcherTool;
|
||||
return undefined;
|
||||
}),
|
||||
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
|
||||
sortTools: vi.fn(),
|
||||
discoverAllTools: vi.fn(),
|
||||
};
|
||||
|
||||
const clientAccess = client as unknown as {
|
||||
context: AgentLoopContext;
|
||||
};
|
||||
|
||||
Object.defineProperty(clientAccess.context, 'toolRegistry', {
|
||||
get: () => mockToolRegistry,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(
|
||||
clientAccess.context as unknown as { agentRegistry: unknown }
|
||||
).agentRegistry = {
|
||||
getAllDefinitions: vi.fn().mockReturnValue([]),
|
||||
getDefinition: vi.fn().mockReturnValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await config.storage.initialize();
|
||||
await client.initialize();
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
const generator = client.sendMessageStream(
|
||||
[{ text: 'test turn' }],
|
||||
signal,
|
||||
'test-prompt',
|
||||
);
|
||||
for await (const _ of generator) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
const projectTempDir = config.storage.getProjectTempDir();
|
||||
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
|
||||
expect(fs.existsSync(statusFilePath)).toBe(true);
|
||||
const content = fs.readFileSync(statusFilePath, 'utf-8');
|
||||
expect(content).toContain('Messy test direction');
|
||||
expect(content).toContain('Messy progress');
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,6 @@ describe('Core System Prompt Substitution', () => {
|
||||
}),
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
isModelSteeringEnabled: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
|
||||
@@ -103,7 +103,6 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(true),
|
||||
@@ -458,7 +457,6 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
||||
getModel: vi.fn().mockReturnValue('auto'),
|
||||
|
||||
@@ -71,7 +71,6 @@ describe('PromptProvider', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getSkills: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -140,7 +140,6 @@ export class PromptProvider {
|
||||
hasHierarchicalMemory,
|
||||
contextFilenames,
|
||||
topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(),
|
||||
watcherEnabled: context.config.isExperimentalWatcherEnabled(),
|
||||
})),
|
||||
subAgents: this.withSection(
|
||||
'agentContexts',
|
||||
|
||||
@@ -56,7 +56,6 @@ export interface CoreMandatesOptions {
|
||||
hasSkills: boolean;
|
||||
hasHierarchicalMemory: boolean;
|
||||
topicUpdateNarration?: boolean;
|
||||
watcherEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PrimaryWorkflowsOptions {
|
||||
@@ -170,11 +169,6 @@ export function renderPreamble(options?: PreambleOptions): string {
|
||||
: 'You are a non-interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.';
|
||||
}
|
||||
|
||||
function mandateWatcher(watcherEnabled: boolean): string {
|
||||
if (!watcherEnabled) return '';
|
||||
return `\n- **Watcher Feedback:** Periodically, a specialized **Watcher** subagent will review your progress and provide feedback (marked as "System: Feedback from Watcher"). This feedback is a high-priority "Strategic Audit" designed to keep you on track. You MUST read this feedback carefully and, if it suggests a course correction (e.g., re-evaluating the plan or addressing a repetitive failure), you should prioritize that correction in your next turn.`;
|
||||
}
|
||||
|
||||
export function renderCoreMandates(options?: CoreMandatesOptions): string {
|
||||
if (!options) return '';
|
||||
return `
|
||||
@@ -188,7 +182,7 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string {
|
||||
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
|
||||
- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments.
|
||||
- **Proactiveness:** Fulfill the user's request thoroughly. When adding features or fixing bugs, this includes adding tests to ensure quality. Consider all created files, especially tests, to be permanent artifacts unless the user says otherwise.${mandateConflictResolution(options.hasHierarchicalMemory)}
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.${mandateWatcher(options.watcherEnabled)}
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
- ${mandateConfirm(options.interactive)}
|
||||
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
|
||||
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface CoreMandatesOptions {
|
||||
hasHierarchicalMemory: boolean;
|
||||
contextFilenames?: string[];
|
||||
topicUpdateNarration: boolean;
|
||||
watcherEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PrimaryWorkflowsOptions {
|
||||
@@ -176,11 +175,6 @@ export function renderPreamble(options?: PreambleOptions): string {
|
||||
: 'You are Gemini CLI, an autonomous CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and effectively.';
|
||||
}
|
||||
|
||||
function mandateWatcher(watcherEnabled: boolean): string {
|
||||
if (!watcherEnabled) return '';
|
||||
return `\n- **Watcher Feedback:** Periodically, a specialized **Watcher** subagent will review your progress and provide feedback (marked as "System: EXTREMELY IMPORTANT Feedback from Watcher"). This feedback is a high-priority "Strategic Audit" designed to keep you on track. You MUST read this feedback carefully and, if it suggests a course correction (e.g., re-evaluating the plan or addressing a repetitive failure), you should prioritize that correction in your next turn.`;
|
||||
}
|
||||
|
||||
export function renderCoreMandates(options?: CoreMandatesOptions): string {
|
||||
if (!options) return '';
|
||||
const filenames = options.contextFilenames ?? [DEFAULT_CONTEXT_FILENAME];
|
||||
@@ -243,7 +237,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.${mandateConflictResolution(options.hasHierarchicalMemory)}
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.${mandateWatcher(options.watcherEnabled)}
|
||||
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
|
||||
- ${mandateConfirm(options.interactive)}${
|
||||
options.topicUpdateNarration
|
||||
? mandateTopicUpdateModel()
|
||||
|
||||
@@ -211,9 +211,12 @@ describe('NumericalClassifierStrategy', () => {
|
||||
describe('Remote Threshold Logic', () => {
|
||||
it('should use the remote CLASSIFIER_THRESHOLD if provided (int value)', async () => {
|
||||
vi.mocked(mockConfig.getClassifierThreshold).mockResolvedValue(70);
|
||||
<<<<<<< HEAD
|
||||
vi.mocked(mockConfig.getResolvedClassifierThreshold).mockResolvedValue(
|
||||
70,
|
||||
);
|
||||
=======
|
||||
>>>>>>> b9035a18d (refactor(config): revert experiment getter names and async signatures)
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Test task',
|
||||
complexity_score: 60,
|
||||
@@ -241,9 +244,12 @@ describe('NumericalClassifierStrategy', () => {
|
||||
|
||||
it('should use the remote CLASSIFIER_THRESHOLD if provided (float value)', async () => {
|
||||
vi.mocked(mockConfig.getClassifierThreshold).mockResolvedValue(45.5);
|
||||
<<<<<<< HEAD
|
||||
vi.mocked(mockConfig.getResolvedClassifierThreshold).mockResolvedValue(
|
||||
45.5,
|
||||
);
|
||||
=======
|
||||
>>>>>>> b9035a18d (refactor(config): revert experiment getter names and async signatures)
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Test task',
|
||||
complexity_score: 40,
|
||||
@@ -271,9 +277,12 @@ describe('NumericalClassifierStrategy', () => {
|
||||
|
||||
it('should use PRO model if score >= remote CLASSIFIER_THRESHOLD', async () => {
|
||||
vi.mocked(mockConfig.getClassifierThreshold).mockResolvedValue(30);
|
||||
<<<<<<< HEAD
|
||||
vi.mocked(mockConfig.getResolvedClassifierThreshold).mockResolvedValue(
|
||||
30,
|
||||
);
|
||||
=======
|
||||
>>>>>>> b9035a18d (refactor(config): revert experiment getter names and async signatures)
|
||||
const mockApiResponse = {
|
||||
complexity_reasoning: 'Test task',
|
||||
complexity_score: 35,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import 'vitest';
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
|
||||
@@ -137,8 +137,3 @@ export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
|
||||
// -- complete_task --
|
||||
export const COMPLETE_TASK_TOOL_NAME = 'complete_task';
|
||||
export const COMPLETE_TASK_DISPLAY_NAME = 'Complete Task';
|
||||
|
||||
// -- watcher --
|
||||
export const WATCHER_TOOL_NAME = 'watcher';
|
||||
export const WATCHER_DISPLAY_NAME = 'Watcher';
|
||||
export const WATCHER_PARAM_RECENT_HISTORY = 'recentHistory';
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
type ToolResult,
|
||||
Kind,
|
||||
type ToolInvocation,
|
||||
} from './tools.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import {
|
||||
WATCHER_TOOL_NAME,
|
||||
WATCHER_DISPLAY_NAME,
|
||||
} from './definitions/base-declarations.js';
|
||||
import { LocalSubagentInvocation } from '../agents/local-invocation.js';
|
||||
import { WatcherAgent } from '../agents/watcher-agent.js';
|
||||
import type { AgentInputs } from '../agents/types.js';
|
||||
|
||||
/**
|
||||
* A specialized tool for the internal Watcher agent loop.
|
||||
*
|
||||
* This tool wraps the Watcher sub-agent to allow it to be discovered and
|
||||
* executed by the GeminiClient's internal monitoring loop.
|
||||
*/
|
||||
export class WatcherTool extends BaseDeclarativeTool<
|
||||
AgentInputs,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = WATCHER_TOOL_NAME;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
const definition = WatcherAgent(context.config);
|
||||
super(
|
||||
WATCHER_TOOL_NAME,
|
||||
WATCHER_DISPLAY_NAME,
|
||||
definition.description,
|
||||
Kind.Agent,
|
||||
definition.inputConfig.inputSchema,
|
||||
messageBus,
|
||||
/* isOutputMarkdown */ true,
|
||||
/* canUpdateOutput */ true,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: AgentInputs,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
): ToolInvocation<AgentInputs, ToolResult> {
|
||||
const definition = WatcherAgent(this.context.config);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return new LocalSubagentInvocation(
|
||||
definition as any,
|
||||
this.context,
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
) as any;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractAndParseJson } from './jsonUtils.js';
|
||||
|
||||
describe('extractAndParseJson', () => {
|
||||
it('should parse pure JSON objects', () => {
|
||||
const input = '{"key": "value"}';
|
||||
expect(extractAndParseJson(input)).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should parse pure JSON arrays', () => {
|
||||
const input = '[1, 2, 3]';
|
||||
expect(extractAndParseJson(input)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should extract JSON from conversational filler (The Watcher Bug)', () => {
|
||||
const input =
|
||||
'Subagent "watcher" finished with result: {"userDirections": "Keep going", "progressSummary": "Done", "evaluation": "ON_TRACK"}';
|
||||
expect(extractAndParseJson(input)).toEqual({
|
||||
userDirections: 'Keep going',
|
||||
progressSummary: 'Done',
|
||||
evaluation: 'ON_TRACK',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract JSON from markdown code blocks', () => {
|
||||
const input =
|
||||
'Here is the report:\n```json\n{"status": "ok"}\n```\nHope this helps!';
|
||||
expect(extractAndParseJson(input)).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('should handle leading and trailing filler simultaneously', () => {
|
||||
const input = 'PREFIX {"a": 1} SUFFIX';
|
||||
expect(extractAndParseJson(input)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('should handle nested braces correctly', () => {
|
||||
const input = 'Result: {"outer": {"inner": 42}} - end';
|
||||
expect(extractAndParseJson(input)).toEqual({ outer: { inner: 42 } });
|
||||
});
|
||||
|
||||
it('should fallback to full string if no braces found (for numbers/booleans/strings)', () => {
|
||||
expect(extractAndParseJson('true')).toBe(true);
|
||||
expect(extractAndParseJson('123')).toBe(123);
|
||||
expect(extractAndParseJson('"just a string"')).toBe('just a string');
|
||||
});
|
||||
|
||||
it('should throw SyntaxError for truly invalid JSON', () => {
|
||||
expect(() => extractAndParseJson('not json at all')).toThrow(SyntaxError);
|
||||
expect(() => extractAndParseJson('{"unfinished": ')).toThrow(SyntaxError);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attempts to extract and parse a JSON object or array from a string that may
|
||||
* contain conversational filler or markdown code blocks.
|
||||
*
|
||||
* @param text The text to extract JSON from.
|
||||
* @returns The parsed JSON object or array as unknown.
|
||||
* @throws SyntaxError if no valid JSON is found.
|
||||
*/
|
||||
export function extractAndParseJson(text: string): unknown {
|
||||
const firstBrace = text.indexOf('{');
|
||||
const firstBracket = text.indexOf('[');
|
||||
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
|
||||
// Determine if we should look for an object or an array first based on which starts earlier.
|
||||
if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
|
||||
start = firstBrace;
|
||||
end = text.lastIndexOf('}');
|
||||
} else if (firstBracket !== -1) {
|
||||
start = firstBracket;
|
||||
end = text.lastIndexOf(']');
|
||||
}
|
||||
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
// Fallback: try parsing the whole trimmed text.
|
||||
return JSON.parse(text.trim());
|
||||
}
|
||||
|
||||
const cleanJson = text.substring(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(cleanJson);
|
||||
} catch (e) {
|
||||
// If extraction failed to produce valid JSON (e.g. mismatched braces),
|
||||
// try the whole text as a last resort.
|
||||
try {
|
||||
return JSON.parse(text.trim());
|
||||
} catch {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2877,6 +2877,7 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
"directWebFetch": {
|
||||
"title": "Direct Web Fetch",
|
||||
"description": "Enable web fetch behavior that bypasses LLM summarization.",
|
||||
@@ -2959,6 +2960,13 @@
|
||||
"markdownDescription": "Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable-awesome": {
|
||||
"title": "Enable Awesome",
|
||||
"description": "When enabled, the ASCII art says 'matt'.",
|
||||
"markdownDescription": "When enabled, the ASCII art says 'matt'.\n\n- Category: `Experimental`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user