mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ac89ae244 | |||
| e937be01bc | |||
| 8929ffbc2d | |||
| 5f94473123 | |||
| 33a0378438 | |||
| 6675d5a42a | |||
| b6c6473804 | |||
| 52e039ce71 | |||
| 8b48961c6c | |||
| c1b7acfb9d | |||
| e2ee279f6f | |||
| 4b88dc1bcf | |||
| a9a37b2b3f | |||
| 79f10004a6 | |||
| fdb61b470d | |||
| 705214ed5b | |||
| 081f91db08 | |||
| dfa89d516b | |||
| f87447e2b4 |
@@ -2,7 +2,10 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"topicUpdateNarration": true
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"watcher": true,
|
||||
"watcherInterval": 10
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
Generated
+31
-3
@@ -447,7 +447,8 @@
|
||||
"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)"
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -1471,6 +1472,7 @@
|
||||
"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"
|
||||
@@ -2177,6 +2179,7 @@
|
||||
"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",
|
||||
@@ -2357,6 +2360,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -2406,6 +2410,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2780,6 +2785,7 @@
|
||||
"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"
|
||||
@@ -2813,6 +2819,7 @@
|
||||
"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"
|
||||
@@ -2867,6 +2874,7 @@
|
||||
"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",
|
||||
@@ -4103,6 +4111,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4377,6 +4386,7 @@
|
||||
"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",
|
||||
@@ -5250,6 +5260,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7390,7 +7401,8 @@
|
||||
"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"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7975,6 +7987,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8492,6 +8505,7 @@
|
||||
"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",
|
||||
@@ -9804,6 +9818,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -10082,6 +10097,7 @@
|
||||
"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",
|
||||
@@ -13838,6 +13854,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -13848,6 +13865,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15988,6 +16006,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16210,7 +16229,8 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16218,6 +16238,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16383,6 +16404,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16605,6 +16627,7 @@
|
||||
"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",
|
||||
@@ -17175,6 +17198,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17187,6 +17211,7 @@
|
||||
"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",
|
||||
@@ -17837,6 +17862,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -18280,6 +18306,7 @@
|
||||
"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"
|
||||
@@ -18383,6 +18410,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -990,6 +990,8 @@ export async function loadCliConfig(
|
||||
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,
|
||||
|
||||
@@ -2217,6 +2217,26 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.',
|
||||
showInDialog: true,
|
||||
},
|
||||
watcher: {
|
||||
type: 'boolean',
|
||||
label: 'Watcher Subagent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
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.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
|
||||
@@ -21,6 +21,12 @@ 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`] = `
|
||||
|
||||
@@ -14,6 +14,7 @@ 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';
|
||||
@@ -266,6 +267,7 @@ 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.
|
||||
|
||||
@@ -105,6 +105,13 @@ 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' &&
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @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\`.`,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -44,6 +44,7 @@ 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 {
|
||||
@@ -703,6 +704,8 @@ export interface ConfigParameters {
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
experimentalAgentHistorySummarization?: boolean;
|
||||
experimentalWatcher?: boolean;
|
||||
experimentalWatcherInterval?: number;
|
||||
memoryBoundaryMarkers?: string[];
|
||||
topicUpdateNarration?: boolean;
|
||||
|
||||
@@ -939,6 +942,8 @@ 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;
|
||||
@@ -1150,6 +1155,8 @@ 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,
|
||||
@@ -2434,6 +2441,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
isExperimentalWatcherEnabled(): boolean {
|
||||
return this.experimentalWatcher;
|
||||
}
|
||||
|
||||
getExperimentalWatcherInterval(): number {
|
||||
return this.experimentalWatcherInterval;
|
||||
}
|
||||
|
||||
getContextManagementConfig(): ContextManagementConfig {
|
||||
return this.contextManagement;
|
||||
}
|
||||
@@ -3636,6 +3651,12 @@ 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;
|
||||
|
||||
@@ -12,6 +12,8 @@ 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,
|
||||
@@ -45,6 +47,9 @@ 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,
|
||||
@@ -318,6 +323,20 @@ 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(
|
||||
@@ -598,10 +617,8 @@ 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()
|
||||
@@ -877,6 +894,7 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turn;
|
||||
}
|
||||
|
||||
@@ -891,6 +909,7 @@ export class GeminiClient {
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||
if (!isInvalidStreamRetry) {
|
||||
this.config.resetTurn();
|
||||
this.sessionTurnCount++;
|
||||
}
|
||||
|
||||
const hooksEnabled = this.config.getEnableHooks();
|
||||
@@ -1033,6 +1052,41 @@ 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;
|
||||
@@ -1279,4 +1333,128 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @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,6 +61,7 @@ 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,6 +103,7 @@ 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),
|
||||
@@ -457,6 +458,7 @@ 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,6 +71,7 @@ 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,6 +140,7 @@ export class PromptProvider {
|
||||
hasHierarchicalMemory,
|
||||
contextFilenames,
|
||||
topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(),
|
||||
watcherEnabled: context.config.isExperimentalWatcherEnabled(),
|
||||
})),
|
||||
subAgents: this.withSection(
|
||||
'agentContexts',
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface CoreMandatesOptions {
|
||||
hasSkills: boolean;
|
||||
hasHierarchicalMemory: boolean;
|
||||
topicUpdateNarration?: boolean;
|
||||
watcherEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PrimaryWorkflowsOptions {
|
||||
@@ -169,6 +170,11 @@ 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 `
|
||||
@@ -182,7 +188,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.
|
||||
- **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)}
|
||||
- ${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,6 +65,7 @@ export interface CoreMandatesOptions {
|
||||
hasHierarchicalMemory: boolean;
|
||||
contextFilenames?: string[];
|
||||
topicUpdateNarration: boolean;
|
||||
watcherEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PrimaryWorkflowsOptions {
|
||||
@@ -175,6 +176,11 @@ 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];
|
||||
@@ -237,7 +243,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.
|
||||
- **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)}
|
||||
- ${mandateConfirm(options.interactive)}${
|
||||
options.topicUpdateNarration
|
||||
? mandateTopicUpdateModel()
|
||||
|
||||
@@ -137,3 +137,8 @@ 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';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user