From f87447e2b4bd710e9a5c5930798da7da80ae390d Mon Sep 17 00:00:00 2001 From: Aishanee Shah Date: Tue, 7 Apr 2026 15:04:57 +0000 Subject: [PATCH] feat: implement Watcher subagent for periodic progress monitoring --- package-lock.json | 34 +- packages/cli/src/config/config.ts | 2 + packages/cli/src/config/settingsSchema.ts | 20 + .../ToolResultDisplay.test.tsx.snap | 6 + packages/core/.geminiignore | 0 packages/core/.gitignore | 0 packages/core/src/agents/registry.ts | 2 + packages/core/src/agents/types.ts | 7 + .../core/src/agents/watcher-agent.test.ts | 50 +++ packages/core/src/agents/watcher-agent.ts | 127 ++++++ packages/core/src/config/config.ts | 42 ++ packages/core/src/core/client.ts | 91 ++++- packages/core/src/core/client_watcher.test.ts | 360 ++++++++++++++++++ packages/core/src/prompts/promptProvider.ts | 1 + packages/core/src/prompts/snippets.legacy.ts | 8 +- packages/core/src/prompts/snippets.ts | 8 +- 16 files changed, 752 insertions(+), 6 deletions(-) create mode 100644 packages/core/.geminiignore create mode 100644 packages/core/.gitignore create mode 100644 packages/core/src/agents/watcher-agent.test.ts create mode 100644 packages/core/src/agents/watcher-agent.ts create mode 100644 packages/core/src/core/client_watcher.test.ts diff --git a/package-lock.json b/package-lock.json index 0c6c449d32..b1a42089e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4e7e1db6f2..8b3f1e218c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fcfd604e3a..819c99c7da 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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: { diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap index 2175679bfa..f2d1f8edbb 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap @@ -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`] = ` diff --git a/packages/core/.geminiignore b/packages/core/.geminiignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index ebb757487c..127fd565d8 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -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. diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 6cf30bcfb4..acf3b7b437 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -105,6 +105,13 @@ export interface SubagentProgress { terminateReason?: AgentTerminateMode; } +export interface WatcherProgress { + userDirections: string; + progressSummary: string; + evaluation: string; + feedback?: string; +} + export function isSubagentProgress(obj: unknown): obj is SubagentProgress { return ( typeof obj === 'object' && diff --git a/packages/core/src/agents/watcher-agent.test.ts b/packages/core/src/agents/watcher-agent.test.ts new file mode 100644 index 0000000000..3332d3a972 --- /dev/null +++ b/packages/core/src/agents/watcher-agent.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/agents/watcher-agent.ts b/packages/core/src/agents/watcher-agent.ts new file mode 100644 index 0000000000..12f2a72003 --- /dev/null +++ b/packages/core/src/agents/watcher-agent.ts @@ -0,0 +1,127 @@ +/** + * @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, + WRITE_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({ + userDirections: 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 => { + const projectTempDir = context.config.storage.getProjectTempDir(); + const statusFilePath = path.join(projectTempDir, 'watcher_status.md'); + + 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: 0.1, + topP: 0.95, + }, + }, + + runConfig: { + maxTimeMinutes: 2, + maxTurns: 5, + }, + + toolConfig: { + tools: [READ_FILE_TOOL_NAME, WRITE_FILE_TOOL_NAME], + }, + + promptConfig: { + query: `Analyze the recent conversation history and update the progress status. +Status file path: ${statusFilePath} + + +\${recentHistory} +`, + systemPrompt: `You are **Watcher**, a specialized monitoring subagent. Your purpose is to ensure the main agent stays on track and follows the user's directions. + +### Your Objectives: +1. **Track Directions**: Identify high-level user directions, redirections, and any changes in plans. +2. **Summarize Progress**: Provide a concise summary of the progress made in the last few turns. +3. **Evaluate Direction**: Determine if the agent is moving towards the goal or if it's deviating/stuck. +4. **Maintain Continuity**: Read the previous status update from the designated file if it exists, and always overwrite it with the latest findings. + +### Instructions: +- **Read Previous Status**: Start by reading the status file: \`${statusFilePath}\`. +- **Analyze History**: Compare the recent history with the previous status and the overall goal (if a plan file exists). +- **Update Status**: Write the updated status back to \`${statusFilePath}\` in a clear Markdown format. +- **Provide Feedback**: If the agent is going in the wrong direction or is stuck, provide specific feedback to be shared with the main agent. If everything is on track, feedback should be empty or a simple confirmation. + +### Status File Format: +\`\`\`md +# Watcher Status Update +## User Directions +[Summary of initial goal and changes] + +## Progress Summary +[Concise summary of actions taken] + +## Evaluation +[Is it on track? Is it following the plan?] +\`\`\` + +You MUST call \`complete_task\` with a JSON report containing \`userDirections\`, \`progressSummary\`, \`evaluation\`, and optional \`feedback\`.`, + }, + }; +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 258e58ea35..3381c475cf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -703,6 +703,8 @@ export interface ConfigParameters { experimentalAgentHistoryTruncationThreshold?: number; experimentalAgentHistoryRetainedMessages?: number; experimentalAgentHistorySummarization?: boolean; + experimentalWatcher?: boolean; + experimentalWatcherInterval?: number; memoryBoundaryMarkers?: string[]; topicUpdateNarration?: boolean; @@ -939,6 +941,12 @@ export class Config implements McpContext, AgentLoopContext { private readonly adminSkillsEnabled: boolean; private readonly experimentalJitContext: boolean; private readonly experimentalMemoryManager: boolean; + private readonly experimentalAgentHistoryTruncation: boolean; + private readonly experimentalAgentHistoryTruncationThreshold: number; + private readonly experimentalAgentHistoryRetainedMessages: number; + private readonly experimentalAgentHistorySummarization: boolean; + private readonly experimentalWatcher: boolean; + private readonly experimentalWatcherInterval: number; private readonly memoryBoundaryMarkers: readonly string[]; private readonly topicUpdateNarration: boolean; private readonly disableLLMCorrection: boolean; @@ -1150,6 +1158,16 @@ export class Config implements McpContext, AgentLoopContext { this.experimentalJitContext = params.experimentalJitContext ?? false; this.experimentalMemoryManager = params.experimentalMemoryManager ?? false; + this.experimentalAgentHistoryTruncation = + params.experimentalAgentHistoryTruncation ?? false; + this.experimentalAgentHistoryTruncationThreshold = + params.experimentalAgentHistoryTruncationThreshold ?? 30; + this.experimentalAgentHistoryRetainedMessages = + params.experimentalAgentHistoryRetainedMessages ?? 15; + this.experimentalAgentHistorySummarization = + params.experimentalAgentHistorySummarization ?? false; + this.experimentalWatcher = params.experimentalWatcher ?? false; + this.experimentalWatcherInterval = params.experimentalWatcherInterval ?? 20; this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git']; this.contextManagement = { enabled: params.contextManagement?.enabled ?? false, @@ -2450,6 +2468,30 @@ export class Config implements McpContext, AgentLoopContext { }; } + isExperimentalAgentHistoryTruncationEnabled(): boolean { + return this.experimentalAgentHistoryTruncation; + } + + getExperimentalAgentHistoryTruncationThreshold(): number { + return this.experimentalAgentHistoryTruncationThreshold; + } + + getExperimentalAgentHistoryRetainedMessages(): number { + return this.experimentalAgentHistoryRetainedMessages; + } + + isExperimentalAgentHistorySummarizationEnabled(): boolean { + return this.experimentalAgentHistorySummarization; + } + + isExperimentalWatcherEnabled(): boolean { + return this.experimentalWatcher; + } + + getExperimentalWatcherInterval(): number { + return this.experimentalWatcherInterval; + } + isTopicUpdateNarrationEnabled(): boolean { return this.topicUpdateNarration; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 25509862fb..54ee26f6c6 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -45,6 +45,8 @@ 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 type { WatcherProgress } from '../agents/types.js'; +import { WatcherReportSchema } from '../agents/watcher-agent.js'; import { ideContextStore } from '../ide/ideContext.js'; import { logContentRetryFailure, @@ -598,10 +600,28 @@ export class GeminiClient { isInvalidStreamRetry: boolean, displayContent?: PartListUnion, ): AsyncGenerator { - // 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.isExperimentalWatcherEnabled() && + this.sessionTurnCount > 0 && + this.sessionTurnCount % this.config.getExperimentalWatcherInterval() === 0 + ) { + const watcherResult = await this.tryRunWatcher(prompt_id, signal); + if (watcherResult?.feedback) { + const feedback = watcherResult.feedback; + const feedbackRequest = [ + { + text: `System: Feedback from Watcher (Review of last ${this.config.getExperimentalWatcherInterval()} turns):\n\n${feedback}`, + }, + ]; + // Inject feedback into the conversation + this.getChat().addHistory(createUserContent(feedbackRequest)); + } + } + if ( this.config.getMaxSessionTurns() > 0 && this.sessionTurnCount > this.config.getMaxSessionTurns() @@ -1279,4 +1299,73 @@ export class GeminiClient { displayContent, ); } + + private async tryRunWatcher( + prompt_id: string, + signal: AbortSignal, + ): Promise { + const watcherTool = this.context.toolRegistry.getTool('watcher'); + if (!watcherTool) { + return undefined; + } + + const interval = this.config.getExperimentalWatcherInterval(); + 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 { + const invocation = watcherTool.build({ recentHistory }); + const result = await invocation.execute(signal); + + if (result.llmContent) { + try { + const contentString = partListUnionToString(result.llmContent); + const parsed = WatcherReportSchema.parse(JSON.parse(contentString)); + return parsed as WatcherProgress; + } catch (e) { + debugLogger.warn('Failed to parse watcher output', e); + return undefined; + } + } + } catch (e) { + debugLogger.warn('Error running watcher subagent', e); + } + + return undefined; + } } diff --git a/packages/core/src/core/client_watcher.test.ts b/packages/core/src/core/client_watcher.test.ts new file mode 100644 index 0000000000..50fe7d0c3f --- /dev/null +++ b/packages/core/src/core/client_watcher.test.ts @@ -0,0 +1,360 @@ +/** + * @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 { WatcherProgress } from '../agents/types.js'; +import type { Config } from '../config/config.js'; + +describe('GeminiClient Watcher Integration', () => { + let config: Config; + let client: GeminiClient; + let mockContentGenerator: { + countTokens: ReturnType; + generateContentStream: ReturnType; + }; + + 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, 'watcher_status.md'); + if (fs.existsSync(statusFilePath)) { + fs.unlinkSync(statusFilePath); + } + }); + + 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); + + // Mock toolRegistry before initialize calls startChat + const mockWatcherTool = { + build: vi.fn().mockReturnValue({ + execute: vi.fn().mockResolvedValue({ + llmContent: [ + { + text: JSON.stringify({ + userDirections: 'Keep testing', + progressSummary: 'Test in progress', + evaluation: 'Good', + feedback: 'Keep going', + } as WatcherProgress), + }, + ], + }), + }), + 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(), + }; + + // Use type assertion for testing purposes to access protected members + const clientAccess = client as unknown as { + context: AgentLoopContext; + sessionTurnCount: number; + tryCompressChat: () => Promise<{ compressionStatus: string }>; + _getActiveModelForCurrentTurn: () => string; + processTurn: ( + request: unknown, + signal: AbortSignal, + promptId: string, + maxTokens: number, + forceFullContext: boolean, + ) => AsyncGenerator; + }; + + Object.defineProperty(clientAccess.context, 'toolRegistry', { + get: () => mockToolRegistry, + configurable: true, + }); + + ( + clientAccess.context as unknown as { agentRegistry: unknown } + ).agentRegistry = { + getAllDefinitions: vi.fn().mockReturnValue([]), + }; + + await config.storage.initialize(); + await client.initialize(); + + vi.spyOn(clientAccess, 'tryCompressChat').mockResolvedValue({ + compressionStatus: 'skipped', + }); + vi.spyOn(clientAccess, '_getActiveModelForCurrentTurn').mockReturnValue( + 'gemini-pro', + ); + + clientAccess.sessionTurnCount = 1; // Will become 2 inside processTurn + + const promptId = 'test-prompt'; + const signal = new AbortController().signal; + + const generator = clientAccess.processTurn( + [{ text: 'test' }], + signal, + promptId, + 10, + false, + ); + for await (const _ of generator) { + // Intentionally 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); + + // Mock toolRegistry before initialize calls startChat + 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(), + }; + + // Use type assertion for testing purposes to access protected members + const clientAccess = client as unknown as { + context: AgentLoopContext; + sessionTurnCount: number; + tryCompressChat: () => Promise<{ compressionStatus: string }>; + _getActiveModelForCurrentTurn: () => string; + processTurn: ( + request: unknown, + signal: AbortSignal, + promptId: string, + maxTokens: number, + forceFullContext: boolean, + ) => AsyncGenerator; + }; + + Object.defineProperty(clientAccess.context, 'toolRegistry', { + get: () => mockToolRegistry, + configurable: true, + }); + + ( + clientAccess.context as unknown as { agentRegistry: unknown } + ).agentRegistry = { + getAllDefinitions: vi.fn().mockReturnValue([]), + }; + + await config.storage.initialize(); + await client.initialize(); + + vi.spyOn(clientAccess, 'tryCompressChat').mockResolvedValue({ + compressionStatus: 'skipped', + }); + vi.spyOn(clientAccess, '_getActiveModelForCurrentTurn').mockReturnValue( + 'gemini-pro', + ); + + clientAccess.sessionTurnCount = 1; // Will become 2 inside processTurn + + const promptId = 'test-prompt'; + const signal = new AbortController().signal; + + const generator = clientAccess.processTurn( + [{ text: 'test' }], + signal, + promptId, + 10, + false, + ); + for await (const _ of generator) { + // Intentionally 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 = { + build: vi.fn().mockReturnValue({ + execute: vi.fn().mockResolvedValue({ + llmContent: [ + { + text: JSON.stringify({ + userDirections: 'Keep testing', + progressSummary: 'Test in progress', + evaluation: 'Good', + feedback: 'Keep going', + } as WatcherProgress), + }, + ], + }), + }), + name: 'watcher', + displayName: 'Watcher', + description: 'Watcher tool', + inputConfig: { + inputName: 'history', + description: 'history', + schema: {}, + }, + outputConfig: { + outputName: 'report', + description: '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(), + }; + + // Use type assertion for testing purposes to access protected members + const clientAccess = client as unknown as { + context: AgentLoopContext; + sessionTurnCount: number; + tryCompressChat: () => Promise<{ compressionStatus: string }>; + _getActiveModelForCurrentTurn: () => string; + processTurn: ( + request: unknown, + signal: AbortSignal, + promptId: string, + maxTokens: number, + forceFullContext: boolean, + ) => AsyncGenerator; + }; + + Object.defineProperty(clientAccess.context, 'toolRegistry', { + get: () => mockToolRegistry, + configurable: true, + }); + + ( + clientAccess.context as unknown as { agentRegistry: unknown } + ).agentRegistry = { + getAllDefinitions: vi.fn().mockReturnValue([]), + }; + + await config.storage.initialize(); + await client.initialize(); + + vi.spyOn(clientAccess, 'tryCompressChat').mockResolvedValue({ + compressionStatus: 'skipped', + }); + vi.spyOn(clientAccess, '_getActiveModelForCurrentTurn').mockReturnValue( + 'gemini-pro', + ); + + const promptId = 'test-prompt'; + const signal = new AbortController().signal; + + // Simulate 11 turns + for (let i = 1; i <= 11; i++) { + clientAccess.sessionTurnCount = i - 1; // Will become i inside processTurn + // In a real scenario, the subagent would write this file via WRITE_FILE_TOOL. + // We simulate this side effect here when the watcher is triggered. + if (i % interval === 0) { + const projectTempDir = config.storage.getProjectTempDir(); + const statusFilePath = path.join(projectTempDir, 'watcher_status.md'); + fs.writeFileSync( + statusFilePath, + '# Watcher Status Update\nDummy status', + ); + } + + const generator = clientAccess.processTurn( + [{ text: `turn ${i}` }], + signal, + promptId, + 10, + false, + ); + for await (const _ of generator) { + // consume + } + } + + // With interval 5, it should trigger at turn 5 and turn 10 + expect(mockWatcherTool.build).toHaveBeenCalledTimes(2); + + // Verify the status file exists + const projectTempDir = config.storage.getProjectTempDir(); + const statusFilePath = path.join(projectTempDir, 'watcher_status.md'); + expect(fs.existsSync(statusFilePath)).toBe(true); + const content = fs.readFileSync(statusFilePath, 'utf-8'); + expect(content).toContain('Watcher Status Update'); + }); +}); diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 36d08c7e74..beb1289df4 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -140,6 +140,7 @@ export class PromptProvider { hasHierarchicalMemory, contextFilenames, topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(), + watcherEnabled: context.config.isExperimentalWatcherEnabled(), })), subAgents: this.withSection( 'agentContexts', diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts index 5f9552b96b..37784ce7d5 100644 --- a/packages/core/src/prompts/snippets.legacy.ts +++ b/packages/core/src/prompts/snippets.legacy.ts @@ -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)}${ diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index a2853c8964..d71488ef9e 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -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: 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()