Compare commits

..

20 Commits

Author SHA1 Message Date
Aishanee Shah 3ac89ae244 fix(watcher): obfuscate status file name to prevent main agent discovery 2026-04-14 20:31:55 +00:00
Aishanee Shah e937be01bc chore(watcher): refine core mandate wording in system prompt 2026-04-14 18:45:06 +00:00
Aishanee Shah 8929ffbc2d chore(watcher): mark subagent feedback as extremely important 2026-04-14 03:42:30 +00:00
Aishanee Shah 5f94473123 chore(watcher): update system instructions for improved field clarity 2026-04-14 03:42:19 +00:00
Aishanee Shah 33a0378438 fix(watcher): sync primaryUserGoal field rename across codebase 2026-04-14 03:22:29 +00:00
Aishanee Shah 6675d5a42a chore(watcher): update system instructions for context awareness 2026-04-14 02:57:17 +00:00
Aishanee Shah b6c6473804 fix(watcher): enable and robustify watcher sub-agent loop 2026-04-13 17:29:09 +00:00
Aishanee Shah 52e039ce71 fix(watcher): robustly extract JSON from sub-agent output 2026-04-13 17:29:09 +00:00
Aishanee Shah 8b48961c6c chore(watcher): increase subagent temperature to 1.0 2026-04-13 17:29:09 +00:00
Aishanee Shah c1b7acfb9d chore: add detailed watcher debug logs and fix integration test mocks 2026-04-13 17:29:08 +00:00
Aishanee Shah e2ee279f6f fix(watcher): safely parse structured subagent output and clean up tests 2026-04-13 17:29:08 +00:00
Aishanee Shah 4b88dc1bcf feat(watcher): ensure subagent triggers after turns and only once per interaction 2026-04-13 17:29:08 +00:00
Aishanee Shah a9a37b2b3f fix(watcher): initialize status file and correct turn counting to prevent subagent loops 2026-04-13 17:29:08 +00:00
Aishanee Shah 79f10004a6 feat(watcher): remove write_file tool from subagent to avoid permission popups 2026-04-13 17:29:08 +00:00
Aishanee Shah fdb61b470d feat(watcher): handle status file writing internally and cleanup on dispose 2026-04-13 17:29:08 +00:00
Aishanee Shah 705214ed5b feat(watcher): trigger watcher after the first user message 2026-04-13 17:29:08 +00:00
Aishanee Shah 081f91db08 chore: fix test mocks and resolve rebase conflicts for watcher subagent 2026-04-13 17:29:08 +00:00
Aishanee Shah dfa89d516b feat(watcher): enhance Watcher subagent directives and feedback messaging 2026-04-13 17:29:07 +00:00
Aishanee Shah f87447e2b4 feat: implement Watcher subagent for periodic progress monitoring 2026-04-13 17:29:07 +00:00
Jack Wotherspoon 6b6ea56437 fix(core): fix quota footer for non-auto models and improve display (#25121) 2026-04-13 17:03:41 +00:00
41 changed files with 1216 additions and 2427 deletions
+4 -1
View File
@@ -2,7 +2,10 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"topicUpdateNarration": true
"memoryManager": true,
"topicUpdateNarration": true,
"watcher": true,
"watcherInterval": 10
},
"general": {
"devtools": true
+31 -3
View File
@@ -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"
},
+2
View File
@@ -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,
+2 -2
View File
@@ -34,8 +34,8 @@ export const ALL_ITEMS = [
},
{
id: 'quota',
header: '/stats',
description: 'Remaining usage on daily limit (not shown when unavailable)',
header: 'quota',
description: 'Percentage of daily limit used (not shown when unavailable)',
},
{
id: 'memory-usage',
+20
View File
@@ -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: {
@@ -281,7 +281,7 @@ describe('<Footer />', () => {
},
},
});
expect(lastFrame()).toContain('85%');
expect(lastFrame()).toContain('85% used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -306,7 +306,7 @@ describe('<Footer />', () => {
},
},
});
expect(normalizeFrame(lastFrame())).not.toContain('used');
expect(normalizeFrame(lastFrame())).toContain('15% used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
+1 -3
View File
@@ -351,13 +351,11 @@ export const Footer: React.FC = () => {
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
forceShow={true}
lowercase={true}
/>
),
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
9, // "100% used" is 9 chars
);
}
break;
@@ -256,7 +256,7 @@ describe('<FooterConfigDialog />', () => {
expect(nextLine).toContain('·');
expect(nextLine).toContain('~/project/path');
expect(nextLine).toContain('docker');
expect(nextLine).toContain('97%');
expect(nextLine).toContain('42% used');
});
await expect(renderResult).toMatchSvgSnapshot();
@@ -242,7 +242,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
'context-used': (
<Text color={getColor('context-used', itemColor)}>85% used</Text>
),
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
quota: <Text color={getColor('quota', itemColor)}>42% used</Text>,
'memory-usage': (
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
),
@@ -1,14 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" workspace (/directory) sandbox /model /stats
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
"
`;
@@ -39,7 +39,7 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
"
`;
@@ -50,7 +50,7 @@
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
@@ -132,10 +132,10 @@
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="297" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="405" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="513" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="693" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<text x="288" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="396" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="504" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="684" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<rect x="801" y="629" width="36" height="17" fill="#001a00" />
<text x="801" y="631" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="837" y="629" width="18" height="17" fill="#001a00" />
@@ -144,10 +144,10 @@
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="297" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="405" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="513" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="693" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="288" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="396" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="504" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="684" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<rect x="801" y="646" width="27" height="17" fill="#001a00" />
<text x="801" y="648" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="828" y="646" width="9" height="17" fill="#001a00" />

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -59,7 +59,7 @@
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
@@ -133,10 +133,10 @@
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="629" width="198" height="17" fill="#001a00" />
<text x="45" y="631" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="324" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="459" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="594" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="801" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<text x="315" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="450" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="585" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="783" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -144,10 +144,10 @@
<rect x="45" y="646" width="126" height="17" fill="#001a00" />
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<rect x="171" y="646" width="72" height="17" fill="#001a00" />
<text x="324" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="459" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="594" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="801" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="315" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="450" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="585" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="783" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -50,7 +50,7 @@
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
@@ -131,13 +131,13 @@
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="207" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="279" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="351" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="432" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="522" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="594" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="756" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="828" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="270" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="342" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="405" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="495" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="558" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="720" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="783" y="631" fill="#afafaf" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -16,7 +16,7 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -38,8 +38,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats diff │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
│ │ workspace (/directory) branch sandbox /model quota diff │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used +12 -4 │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -61,7 +61,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -83,8 +83,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ │ workspace (/directory) branch sandbox /model quota │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -107,7 +107,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -129,8 +129,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ │ workspace (/directory) branch sandbox /model quota │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -152,7 +152,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Remaining usage on daily limit (not shown when unavailable) │
Percentage of daily limit used (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -174,7 +174,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
│ │ ~/project/path · main · docker · gemini-2.5-pro · 42% used │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -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`] = `
View File
View File
+19 -32
View File
@@ -1012,57 +1012,44 @@ describe('AgentRegistry', () => {
);
});
it('should override an existing agent but warn on duplicate name', async () => {
it('should overwrite an existing agent definition', async () => {
await registry.testRegisterAgent(MOCK_AGENT_V1);
expect(registry.getDefinition('MockAgent')?.description).toBe(
'Mock Description V1',
);
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
await registry.testRegisterAgent(MOCK_AGENT_V2);
// Should override to V2 (preserves existing precedence)
expect(registry.getDefinition('MockAgent')?.description).toBe(
'Mock Description V2 (Updated)',
);
expect(registry.getAllDefinitions()).toHaveLength(1);
// But should emit a warning about the duplicate
expect(feedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining("Duplicate agent name 'MockAgent'"),
);
});
it('should not warn when re-registering the same definition (refresh)', async () => {
await registry.testRegisterAgent(MOCK_AGENT_V1);
expect(registry.getDefinition('MockAgent')?.description).toBe(
'Mock Description V1',
);
// Re-registering the exact same object should succeed without warning
const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
await registry.testRegisterAgent(MOCK_AGENT_V1);
expect(registry.getDefinition('MockAgent')?.description).toBe(
'Mock Description V1',
);
expect(feedbackSpy).not.toHaveBeenCalledWith(
'warning',
expect.stringContaining('Duplicate'),
);
});
it('should warn on duplicate in debug logs', async () => {
it('should log overwrites when in debug mode', async () => {
const debugConfig = makeMockedConfig({ debugMode: true });
const debugRegistry = new TestableAgentRegistry(debugConfig);
const warnSpy = vi
.spyOn(debugLogger, 'warn')
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await debugRegistry.testRegisterAgent(MOCK_AGENT_V1);
await debugRegistry.testRegisterAgent(MOCK_AGENT_V2);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Overriding agent'),
expect(debugLogSpy).toHaveBeenCalledWith(
`[AgentRegistry] Overriding agent 'MockAgent'`,
);
});
it('should not log overwrites when not in debug mode', async () => {
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await registry.testRegisterAgent(MOCK_AGENT_V1);
await registry.testRegisterAgent(MOCK_AGENT_V2);
expect(debugLogSpy).not.toHaveBeenCalledWith(
`[AgentRegistry] Overriding agent 'MockAgent'`,
);
});
+5 -17
View File
@@ -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.
@@ -317,27 +319,13 @@ export class AgentRegistry {
}
/**
* Registers an agent definition. If an agent with the same name already
* exists from a different definition, a warning is emitted to surface
* potential state collision. The new definition still overwrites the old
* one to preserve existing precedence order (user project extension).
* Registers an agent definition. If an agent with the same name exists,
* it will be overwritten, respecting the precedence established by the
* initialization order.
*/
protected async registerAgent<TOutput extends z.ZodTypeAny>(
definition: AgentDefinition<TOutput>,
): Promise<void> {
const existing = this.agents.get(definition.name);
if (existing && existing !== definition) {
coreEvents.emitFeedback(
'warning',
`Duplicate agent name '${definition.name}' detected. ` +
`The later definition will override the earlier one. ` +
`Rename one of the agents to avoid this conflict.`,
);
debugLogger.warn(
`[AgentRegistry] Overriding agent '${definition.name}' — duplicate name from a different definition.`,
);
}
if (definition.kind === 'local') {
this.registerLocalAgent(definition);
} else if (definition.kind === 'remote') {
@@ -1,643 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RemoteSessionInvocation } from './remote-session-invocation.js';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import type { RemoteAgentDefinition, SubagentProgress } from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { Config } from '../config/config.js';
import type { ToolResult } from '../tools/tools.js';
import type { AgentEvent } from '../agent/types.js';
vi.mock('./remote-subagent-protocol.js');
const mockDefinition: RemoteAgentDefinition = {
name: 'test-agent',
kind: 'remote',
agentCardUrl: 'http://test-agent/card',
displayName: 'Test Agent',
description: 'A test agent',
inputConfig: { inputSchema: { type: 'object' } },
};
const mockMessageBus = createMockMessageBus();
interface MockSessionSetupOptions {
result?: ToolResult;
error?: Error;
progress?: SubagentProgress;
sessionState?: { contextId?: string; taskId?: string };
}
function setupMockSession(options: MockSessionSetupOptions = {}) {
const {
result = {
llmContent: [{ text: 'done' }],
returnDisplay: {
isSubagentProgress: true,
agentName: 'Test Agent',
state: 'completed',
result: 'done',
recentActivity: [],
} satisfies SubagentProgress,
},
error,
progress,
sessionState = {},
} = options;
const subscriberCallbacks: Array<(event: AgentEvent) => void> = [];
const mockSession = {
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
getResult: error
? vi.fn().mockRejectedValue(error)
: vi.fn().mockResolvedValue(result),
getLatestProgress: vi.fn().mockReturnValue(progress),
getSessionState: vi.fn().mockReturnValue(sessionState),
subscribe: vi.fn((cb: (event: AgentEvent) => void) => {
subscriberCallbacks.push(cb);
return vi.fn(); // unsubscribe
}),
abort: vi.fn(),
};
vi.mocked(RemoteSubagentSession).mockImplementation(
() => mockSession as unknown as RemoteSubagentSession,
);
return {
mockSession,
subscriberCallbacks,
/** Fire a message event through all subscribed callbacks. */
emitEvent(event: AgentEvent) {
for (const cb of subscriberCallbacks) {
cb(event);
}
},
};
}
describe('RemoteSessionInvocation', () => {
let mockContext: AgentLoopContext;
beforeEach(() => {
vi.clearAllMocks();
const mockConfig = {
getA2AClientManager: vi.fn().mockReturnValue({}),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
mockContext = { config: mockConfig } as unknown as AgentLoopContext;
// Clear the static sessionState map between tests
(
RemoteSessionInvocation as unknown as {
sessionState?: Map<string, unknown>;
}
).sessionState?.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Constructor Validation
// ---------------------------------------------------------------------------
describe('Constructor Validation', () => {
it('accepts valid input with string query', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hello' },
mockMessageBus,
);
}).not.toThrow();
});
it('accepts missing query (defaults to "Get Started!")', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{},
mockMessageBus,
);
}).not.toThrow();
});
it('throws if query is not a string', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 123 },
mockMessageBus,
);
}).toThrow("requires a string 'query' input");
});
it('throws if A2AClientManager is not available', () => {
const noA2AConfig = {
getA2AClientManager: vi.fn().mockReturnValue(undefined),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
const noA2AContext = {
config: noA2AConfig,
} as unknown as AgentLoopContext;
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
noA2AContext,
{ query: 'hi' },
mockMessageBus,
);
}).toThrow('A2AClientManager is not available');
});
});
// ---------------------------------------------------------------------------
// Execution Logic
// ---------------------------------------------------------------------------
describe('Execution Logic', () => {
it('should create session and return result', async () => {
const completedProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: 'completed',
result: 'Agent output',
recentActivity: [],
};
const expectedResult: ToolResult = {
llmContent: [{ text: 'Agent output' }],
returnDisplay: completedProgress,
};
setupMockSession({
result: expectedResult,
progress: completedProgress,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'do stuff' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(RemoteSubagentSession).toHaveBeenCalledOnce();
expect(result).toBe(expectedResult);
});
it('should pass initial state from static map to session', async () => {
const priorState = { contextId: 'ctx-42', taskId: 'task-42' };
// Seed the static map before constructing the invocation (composite key)
(
RemoteSessionInvocation as unknown as {
sessionState: Map<string, unknown>;
}
).sessionState.set('test-agent::http://test-agent/card', priorState);
setupMockSession();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
// Verify the session constructor received the prior state
expect(RemoteSubagentSession).toHaveBeenCalledWith(
mockDefinition,
mockContext,
mockMessageBus,
priorState,
);
});
it('should persist session state in finally block', async () => {
const newState = { contextId: 'ctx-new', taskId: 'task-new' };
setupMockSession({ sessionState: newState });
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
// Verify the state was persisted in the static map (composite key)
const storedState = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState.get('test-agent::http://test-agent/card');
expect(storedState).toEqual(newState);
});
it('should persist session state across invocations', async () => {
// First invocation returns state
const firstState = { contextId: 'ctx-1', taskId: 'task-1' };
setupMockSession({ sessionState: firstState });
const invocation1 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'first' },
mockMessageBus,
);
await invocation1.execute({
abortSignal: new AbortController().signal,
});
// Second invocation — the mock constructor should receive firstState
const secondState = { contextId: 'ctx-2', taskId: 'task-2' };
setupMockSession({ sessionState: secondState });
const invocation2 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'second' },
mockMessageBus,
);
await invocation2.execute({
abortSignal: new AbortController().signal,
});
// The second invocation should have received the first's state
const secondCallArgs = vi.mocked(RemoteSubagentSession).mock.calls[1];
expect(secondCallArgs[3]).toEqual(firstState);
});
it('should subscribe for progress updates', async () => {
const completedProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: 'running',
result: 'partial',
recentActivity: [],
};
const { mockSession, emitEvent } = setupMockSession({
progress: completedProgress,
});
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
// Override getResult to emit a message event mid-execution
mockSession.getResult.mockImplementation(async () => {
emitEvent({
type: 'message',
id: 'e1',
timestamp: new Date().toISOString(),
streamId: 's1',
role: 'agent',
content: [{ type: 'text', text: 'hello' }],
});
return {
llmContent: [{ text: 'done' }],
returnDisplay: completedProgress,
};
});
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
// subscribe should have been called (at least once for progress, possibly for parent)
expect(mockSession.subscribe).toHaveBeenCalled();
// updateOutput should have been called with the progress from getLatestProgress
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
}),
);
});
it('should handle abort gracefully', async () => {
const controller = new AbortController();
const { mockSession } = setupMockSession();
// When getResult resolves, the signal will already be aborted
mockSession.getResult.mockImplementation(async () => {
controller.abort();
return {
llmContent: [{ text: '' }],
returnDisplay: '',
};
});
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect(result.llmContent).toEqual([
{ text: 'Operation cancelled by user' },
]);
});
});
// ---------------------------------------------------------------------------
// Error Handling
// ---------------------------------------------------------------------------
describe('Error Handling', () => {
it('should handle execution errors gracefully', async () => {
setupMockSession({ error: new Error('Network failure') });
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
'Network failure',
);
// updateOutput should be called with error progress
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({ state: 'error' }),
);
});
it('should include partial output in error display', async () => {
const partialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: 'running',
result: 'Partial work so far',
recentActivity: [
{
id: 'a1',
type: 'thought',
content: 'Thinking...',
status: 'running',
},
],
};
setupMockSession({
error: new Error('mid-stream error'),
progress: partialProgress,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const display = result.returnDisplay as SubagentProgress;
// Should contain both the partial output and the error
expect(display.result).toContain('Partial work so far');
expect(display.result).toContain('mid-stream error');
// Should preserve partial activity
expect(display.recentActivity).toHaveLength(1);
expect(display.recentActivity[0].content).toBe('Thinking...');
});
it('should clean up listeners in finally', async () => {
const { mockSession } = setupMockSession();
const controller = new AbortController();
const removeEventListenerSpy = vi.spyOn(
controller.signal,
'removeEventListener',
);
const onAgentEvent = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
{ onAgentEvent },
);
await invocation.execute({
abortSignal: controller.signal,
});
// removeEventListener should have been called for the abort listener
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
// All unsubscribe functions returned by subscribe during execute should be called
const postExecuteUnsubscribes = mockSession.subscribe.mock.results.map(
(r) => r.value,
);
for (const unsub of postExecuteUnsubscribes) {
expect(unsub).toHaveBeenCalled();
}
});
});
// ---------------------------------------------------------------------------
// SessionState Management
// ---------------------------------------------------------------------------
describe('SessionState Management', () => {
it('should use composite name::url as session state key', async () => {
const secondDefinition: RemoteAgentDefinition = {
...mockDefinition,
name: 'other-agent',
displayName: 'Other Agent',
agentCardUrl: 'http://other-agent/card',
};
// First agent
setupMockSession({
sessionState: { contextId: 'ctx-a' },
});
const inv1 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv1.execute({ abortSignal: new AbortController().signal });
// Second agent
setupMockSession({
sessionState: { contextId: 'ctx-b' },
});
const inv2 = new RemoteSessionInvocation(
secondDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv2.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
// Each agent should have its own entry keyed by name::url
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual({
contextId: 'ctx-a',
});
expect(stateMap.get('other-agent::http://other-agent/card')).toEqual({
contextId: 'ctx-b',
});
});
it('should isolate same-name agents with different URLs', async () => {
const defA: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: 'http://host-a/card',
};
const defB: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: 'http://host-b/card',
};
// Agent A
setupMockSession({ sessionState: { contextId: 'ctx-a' } });
const invA = new RemoteSessionInvocation(
defA,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invA.execute({ abortSignal: new AbortController().signal });
// Agent B (same name, different URL)
setupMockSession({ sessionState: { contextId: 'ctx-b' } });
const invB = new RemoteSessionInvocation(
defB,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invB.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent::http://host-a/card')).toEqual({
contextId: 'ctx-a',
});
expect(stateMap.get('test-agent::http://host-b/card')).toEqual({
contextId: 'ctx-b',
});
});
it('should fall back to name-only key when URL is unavailable', async () => {
const noUrlDef: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: undefined,
};
setupMockSession({ sessionState: { contextId: 'ctx-no-url' } });
const inv = new RemoteSessionInvocation(
noUrlDef,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent')).toEqual({ contextId: 'ctx-no-url' });
});
it('should persist state even on error', async () => {
const stateOnError = { contextId: 'ctx-err', taskId: 'task-err' };
setupMockSession({
error: new Error('boom'),
sessionState: stateOnError,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual(
stateOnError,
);
});
});
});
@@ -1,251 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseToolInvocation,
type ToolConfirmationOutcome,
type ToolResult,
type ToolCallConfirmationDetails,
type ExecuteOptions,
} from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
type RemoteAgentInputs,
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
getRemoteAgentTargetUrl,
} from './types.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { A2AAgentError } from './a2a-errors.js';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import type { AgentEvent } from '../agent/types.js';
/** Optional configuration for remote agent invocations. */
export interface SubagentInvocationOptions {
toolName?: string;
toolDisplayName?: string;
onAgentEvent?: (event: AgentEvent) => void;
}
/**
* Session-based remote agent invocation.
*
* This implementation delegates execution to {@link RemoteSubagentSession},
* which wraps the A2A client streaming behind the AgentProtocol interface.
*
* Cross-invocation A2A session state (contextId/taskId) is persisted via a
* static map keyed by a composite of agent name and target URL. This ensures
* agents with the same name but different endpoints maintain independent state.
*/
export class RemoteSessionInvocation extends BaseToolInvocation<
RemoteAgentInputs,
ToolResult
> {
// Persist A2A conversation state across ephemeral invocation instances.
// Keyed by composite of name + target URL so agents with the same name
// but different endpoints don't share state.
private static readonly sessionState = new Map<
string,
{ contextId?: string; taskId?: string }
>();
/**
* Builds a composite key for the sessionState map.
* Format: `name::targetUrl` (or just `name` if no URL can be derived).
*/
private static sessionKey(definition: RemoteAgentDefinition): string {
const url = getRemoteAgentTargetUrl(definition);
return url ? `${definition.name}::${url}` : definition.name;
}
private readonly _onAgentEvent?: (event: AgentEvent) => void;
constructor(
private readonly definition: RemoteAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
options?: SubagentInvocationOptions,
) {
const query = params['query'] ?? DEFAULT_QUERY_STRING;
if (typeof query !== 'string') {
throw new Error(
`Remote agent '${definition.name}' requires a string 'query' input.`,
);
}
// Safe to pass strict object to super
super(
{ query },
messageBus,
options?.toolName ?? definition.name,
options?.toolDisplayName ?? definition.displayName,
);
this._onAgentEvent = options?.onAgentEvent;
// Validate that A2AClientManager is available at construction time
if (!this.context.config.getA2AClientManager()) {
throw new Error(
`Failed to initialize RemoteSessionInvocation for '${definition.name}': A2AClientManager is not available.`,
);
}
}
getDescription(): string {
return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
return {
type: 'info',
title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`,
prompt: `Calling remote agent: "${this.params.query}"`,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Policy updates are now handled centrally by the scheduler
},
};
}
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: _signal, updateOutput } = options;
const agentName = this.definition.displayName ?? this.definition.name;
// Seed session with prior A2A conversation state
const stateKey = RemoteSessionInvocation.sessionKey(this.definition);
const priorState = RemoteSessionInvocation.sessionState.get(stateKey);
const session = new RemoteSubagentSession(
this.definition,
this.context,
this.messageBus,
priorState,
);
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
_signal.addEventListener('abort', abortListener, { once: true });
// Subscribe for parent session observability
let unsubscribeParent: (() => void) | undefined;
if (this._onAgentEvent) {
unsubscribeParent = session.subscribe(this._onAgentEvent);
}
// Subscribe to message events for live SubagentProgress updates
const unsubscribeProgress = session.subscribe((event: AgentEvent) => {
if (event.type === 'message' && updateOutput) {
const currentProgress = session.getLatestProgress();
if (currentProgress) updateOutput(currentProgress);
}
});
try {
if (updateOutput) {
updateOutput({
isSubagentProgress: true,
agentName,
state: 'running',
recentActivity: [
{
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
},
],
});
}
await session.send({
message: { content: [{ type: 'text', text: this.params.query }] },
});
const result = await session.getResult();
// The protocol resolves aborts with an empty result rather than
// rejecting. Detect this and surface proper error state.
if (_signal.aborted) {
const partialProgress = session.getLatestProgress();
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: 'error',
result:
typeof partialProgress?.result === 'string'
? partialProgress.result
: '',
recentActivity: partialProgress?.recentActivity ?? [],
};
if (updateOutput) updateOutput(errorProgress);
return {
llmContent: [{ text: 'Operation cancelled by user' }],
returnDisplay: errorProgress,
};
}
// Emit final completed progress
if (updateOutput) {
const finalProgress = session.getLatestProgress();
if (finalProgress) updateOutput(finalProgress);
}
return result;
} catch (error: unknown) {
const partialProgress = session.getLatestProgress();
const partialOutput =
typeof partialProgress?.result === 'string'
? partialProgress.result
: '';
const errorMessage = this.formatExecutionError(error);
const fullDisplay = partialOutput
? `${partialOutput}\n\n${errorMessage}`
: errorMessage;
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: 'error',
result: fullDisplay,
recentActivity: partialProgress?.recentActivity ?? [],
};
if (updateOutput) {
updateOutput(errorProgress);
}
return {
llmContent: [{ text: fullDisplay }],
returnDisplay: errorProgress,
};
} finally {
// Persist A2A state for next invocation — even on abort/error
RemoteSessionInvocation.sessionState.set(
stateKey,
session.getSessionState(),
);
_signal.removeEventListener('abort', abortListener);
unsubscribeProgress();
unsubscribeParent?.();
}
}
/**
* Formats an execution error into a user-friendly message.
* Recognizes typed A2AAgentError subclasses and falls back to
* a generic message for unknown errors.
*/
private formatExecutionError(error: unknown): string {
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
@@ -1,947 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { RemoteAgentDefinition, SubagentProgress } from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { AgentEvent } from '../agent/types.js';
import type { Config } from '../config/config.js';
import type { A2AAuthProvider } from './auth-provider/types.js';
// Mock A2AClientManager at module level
vi.mock('./a2a-client-manager.js', () => ({
A2AClientManager: vi.fn().mockImplementation(() => ({
getClient: vi.fn(),
loadAgent: vi.fn(),
sendMessageStream: vi.fn(),
})),
}));
// Mock A2AAuthProviderFactory
vi.mock('./auth-provider/factory.js', () => ({
A2AAuthProviderFactory: {
create: vi.fn(),
},
}));
const mockDefinition: RemoteAgentDefinition = {
name: 'test-remote-agent',
kind: 'remote',
agentCardUrl: 'http://test-agent/card',
displayName: 'Test Remote Agent',
description: 'A test remote agent',
inputConfig: {
inputSchema: { type: 'object' },
},
};
function makeChunk(text: string) {
return {
kind: 'message' as const,
messageId: `msg-${Math.random()}`,
role: 'agent' as const,
parts: [{ kind: 'text' as const, text }],
};
}
describe('RemoteSubagentSession (protocol)', () => {
let mockClientManager: {
getClient: Mock;
loadAgent: Mock;
sendMessageStream: Mock;
};
let mockContext: AgentLoopContext;
let mockMessageBus: ReturnType<typeof createMockMessageBus>;
beforeEach(() => {
vi.clearAllMocks();
// Each test creates fresh session instances. contextId/taskId persist as
// instance fields within a session, not via static state.
mockClientManager = {
getClient: vi.fn().mockReturnValue(undefined), // client not yet loaded
loadAgent: vi.fn().mockResolvedValue(undefined),
sendMessageStream: vi.fn(),
};
const mockConfig = {
getA2AClientManager: vi.fn().mockReturnValue(mockClientManager),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
mockContext = { config: mockConfig } as unknown as AgentLoopContext;
mockMessageBus = createMockMessageBus();
// Default: sendMessageStream yields one chunk with "Hello"
mockClientManager.sendMessageStream.mockImplementation(async function* () {
yield makeChunk('Hello');
});
});
afterEach(() => {
vi.restoreAllMocks();
});
// Helper: run a session with the default or custom stream and collect events
async function runSession(
definition: RemoteAgentDefinition = mockDefinition,
query = 'test query',
) {
const session = new RemoteSubagentSession(
definition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: query }] },
});
const result = await session.getResult();
return { session, events, result };
}
// ---------------------------------------------------------------------------
// Lifecycle events
// ---------------------------------------------------------------------------
describe('lifecycle events', () => {
it('emits agent_start then agent_end(completed) on success', async () => {
const { events } = await runSession();
const types = events.map((e) => e.type);
expect(types[0]).toBe('agent_start');
expect(types[types.length - 1]).toBe('agent_end');
const end = events[events.length - 1];
if (end.type === 'agent_end') {
expect(end.reason).toBe('completed');
}
});
it('emits agent_start exactly once', async () => {
const { events } = await runSession();
expect(events.filter((e) => e.type === 'agent_start')).toHaveLength(1);
});
it('emits agent_end exactly once on error path', async () => {
mockClientManager.sendMessageStream.mockReturnValue({
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<never>> {
throw new Error('stream error');
},
};
},
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow('stream error');
expect(events.filter((e) => e.type === 'agent_end')).toHaveLength(1);
});
it('all events share the same streamId', async () => {
const { events } = await runSession();
const streamIds = new Set(events.map((e) => e.streamId));
expect(streamIds.size).toBe(1);
});
it('message returns a non-null streamId; unsupported payload returns null', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const updateResult = await session.send({
update: { config: { key: 'val' } },
});
expect(updateResult.streamId).toBeNull();
const messageResult = await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
expect(messageResult.streamId).not.toBeNull();
// complete the session to avoid dangling execution
await session.getResult();
});
});
// ---------------------------------------------------------------------------
// Chunk → AgentEvent translation
// ---------------------------------------------------------------------------
describe('chunk → AgentEvent translation', () => {
it('each A2A chunk produces a message event with incremental delta text', async () => {
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield makeChunk('Hello');
yield makeChunk(' world');
},
);
const { events } = await runSession();
const msgEvents = events.filter((e) => e.type === 'message');
expect(msgEvents).toHaveLength(2);
// Each message event contains only the delta, not accumulated text
if (msgEvents[0]?.type === 'message') {
const text = msgEvents[0].content.find((c) => c.type === 'text');
expect(text?.type === 'text' && text.text).toBe('Hello');
}
if (msgEvents[1]?.type === 'message') {
const text = msgEvents[1].content.find((c) => c.type === 'text');
expect(text?.type === 'text' && text.text).toBe(' world');
}
});
it('getLatestProgress() is updated per chunk with state running', async () => {
let capturedProgress: SubagentProgress | undefined;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield makeChunk('Partial');
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
session.subscribe((e) => {
if (e.type === 'message') {
capturedProgress = session.getLatestProgress();
}
});
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
// During streaming, progress should be 'running'
expect(capturedProgress).toBeDefined();
// Note: by the time we check, progress may be 'completed'.
// During the message event, it was 'running'.
expect(capturedProgress?.isSubagentProgress).toBe(true);
expect(capturedProgress?.agentName).toBe('Test Remote Agent');
});
it('getLatestProgress() state is completed after getResult() resolves', async () => {
const { session } = await runSession();
const progress = session.getLatestProgress();
expect(progress?.state).toBe('completed');
expect(progress?.result).toBe('Hello');
});
});
// ---------------------------------------------------------------------------
// getResult() promise
// ---------------------------------------------------------------------------
describe('getResult()', () => {
it('resolves with ToolResult containing llmContent and SubagentProgress returnDisplay', async () => {
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield makeChunk('Result text');
},
);
const { result } = await runSession();
expect(result.llmContent).toEqual([{ text: 'Result text' }]);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('completed');
expect(display.result).toBe('Result text');
expect(display.agentName).toBe('Test Remote Agent');
});
it('rejects when stream throws a non-A2A error', async () => {
mockClientManager.sendMessageStream.mockReturnValue({
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<never>> {
throw new Error('network failure');
},
};
},
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow();
});
it('resolves even with empty stream (empty final output)', async () => {
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
// yield nothing
},
);
const { result } = await runSession();
expect(result.llmContent).toEqual([{ text: '' }]);
});
});
// ---------------------------------------------------------------------------
// Session state persistence
// ---------------------------------------------------------------------------
describe('session state persistence', () => {
it('second send reuses contextId captured from first send', async () => {
let callCount = 0;
mockClientManager.sendMessageStream.mockImplementation(async function* (
_name: string,
_query: string,
opts: { contextId?: string },
) {
callCount++;
if (callCount === 1) {
yield {
kind: 'message' as const,
messageId: 'msg-1',
role: 'agent' as const,
contextId: 'ctx-from-server',
parts: [{ kind: 'text' as const, text: 'First response' }],
};
} else {
// Second send on same session should pass the contextId
expect(opts.contextId).toBe('ctx-from-server');
yield makeChunk('Second response');
}
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
// First send — establishes contextId
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
// Second send on same session — should reuse contextId
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
expect(callCount).toBe(2);
});
it('separate session instances have independent state', async () => {
const capturedContextIds: Array<string | undefined> = [];
mockClientManager.sendMessageStream.mockImplementation(async function* (
_name: string,
_query: string,
opts: { contextId?: string },
) {
capturedContextIds.push(opts.contextId);
yield {
kind: 'message' as const,
messageId: 'msg-1',
role: 'agent' as const,
contextId: 'ctx-from-server',
parts: [{ kind: 'text' as const, text: 'ok' }],
};
});
// Two separate sessions for the same agent — state is NOT shared
const session1 = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session1.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session1.getResult();
const session2 = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session2.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session2.getResult();
// Both start with no contextId — separate instances, no shared state
expect(capturedContextIds[0]).toBeUndefined();
expect(capturedContextIds[1]).toBeUndefined();
});
it('taskId is cleared when a terminal-state task chunk is received', async () => {
let callCount = 0;
const capturedTaskIds: Array<string | undefined> = [];
mockClientManager.sendMessageStream.mockImplementation(async function* (
_n: string,
_q: string,
opts: { taskId?: string },
) {
callCount++;
capturedTaskIds.push(opts.taskId);
if (callCount === 1) {
yield {
kind: 'task' as const,
id: 'task-123',
contextId: 'ctx-1',
status: { state: 'completed' as const },
};
} else {
yield makeChunk('done');
}
});
// Use same session for multi-send
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
expect(callCount).toBe(2);
// First call starts with no taskId
expect(capturedTaskIds[0]).toBeUndefined();
// Second call: taskId was cleared because terminal-state task chunk was received
expect(capturedTaskIds[1]).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Auth setup
// ---------------------------------------------------------------------------
describe('auth setup', () => {
it('no auth → loadAgent called without auth handler', async () => {
await runSession();
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-remote-agent',
{ type: 'url', url: 'http://test-agent/card' },
undefined,
);
});
it('definition.auth present → A2AAuthProviderFactory.create called', async () => {
const authDef: RemoteAgentDefinition = {
...mockDefinition,
name: 'auth-agent',
auth: {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'secret',
},
};
const mockProvider = {
type: 'http' as const,
headers: vi.fn().mockResolvedValue({ Authorization: 'Bearer secret' }),
shouldRetryWithHeaders: vi.fn(),
} as unknown as A2AAuthProvider;
(A2AAuthProviderFactory.create as Mock).mockResolvedValue(mockProvider);
await runSession(authDef, 'q');
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith(
expect.objectContaining({
agentName: 'auth-agent',
agentCardUrl: 'http://test-agent/card',
}),
);
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'auth-agent',
expect.any(Object),
mockProvider,
);
});
it('auth factory returns undefined → throws error that rejects getResult()', async () => {
const authDef: RemoteAgentDefinition = {
...mockDefinition,
name: 'failing-auth-agent',
auth: {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'secret',
},
};
(A2AAuthProviderFactory.create as Mock).mockResolvedValue(undefined);
const session = new RemoteSubagentSession(
authDef,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow(
"Failed to create auth provider for agent 'failing-auth-agent'",
);
});
it('agent already loaded → loadAgent not called again', async () => {
// Return a client object (truthy) so getClient returns defined
mockClientManager.getClient.mockReturnValue({});
await runSession();
expect(mockClientManager.loadAgent).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
describe('error handling', () => {
it('stream error → error event + agent_end(failed)', async () => {
mockClientManager.sendMessageStream.mockReturnValue({
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<never>> {
throw new Error('network error');
},
};
},
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow();
const errEvent = events.find((e) => e.type === 'error');
expect(errEvent).toBeDefined();
const endEvent = events.find((e) => e.type === 'agent_end');
expect(endEvent).toBeDefined();
if (endEvent?.type === 'agent_end') {
expect(endEvent.reason).toBe('failed');
}
});
it('missing A2AClientManager → rejects getResult()', async () => {
const mockConfig = {
getA2AClientManager: vi.fn().mockReturnValue(undefined),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
const noClientContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const session = new RemoteSubagentSession(
mockDefinition,
noClientContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow(
'A2AClientManager not available',
);
});
});
// ---------------------------------------------------------------------------
// Subscription
// ---------------------------------------------------------------------------
describe('subscription', () => {
it('unsubscribe stops event delivery', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const received: AgentEvent[] = [];
const unsub = session.subscribe((e) => received.push(e));
unsub();
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(received).toHaveLength(0);
});
it('multiple subscribers all receive events', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events1: AgentEvent[] = [];
const events2: AgentEvent[] = [];
session.subscribe((e) => events1.push(e));
session.subscribe((e) => events2.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(events1.length).toBeGreaterThan(0);
expect(events1).toEqual(events2);
});
});
// ---------------------------------------------------------------------------
// Abort
// ---------------------------------------------------------------------------
describe('abort()', () => {
it('abort() causes agent_end(reason:aborted)', async () => {
let rejectWithAbort: ((err: Error) => void) | undefined;
// Stream that blocks until aborted, then throws AbortError
mockClientManager.sendMessageStream.mockImplementation(
// eslint-disable-next-line require-yield
async function* () {
await new Promise<void>((_resolve, reject) => {
rejectWithAbort = reject;
});
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
void session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
// Wait for agent_start to be emitted before aborting
await vi.waitFor(() => {
expect(events.some((e) => e.type === 'agent_start')).toBe(true);
});
await session.abort();
// Simulate the transport throwing AbortError when signal fires
const abortErr = new Error('AbortError');
abortErr.name = 'AbortError';
rejectWithAbort?.(abortErr);
const result = await session.getResult();
expect(result.llmContent).toEqual([{ text: '' }]);
expect(result.returnDisplay).toBe('');
const endEvent = events.find((e) => e.type === 'agent_end');
expect(endEvent).toBeDefined();
if (endEvent?.type === 'agent_end') {
expect(endEvent.reason).toBe('aborted');
}
});
});
// ---------------------------------------------------------------------------
// sendMessageStream call args
// ---------------------------------------------------------------------------
describe('sendMessageStream call arguments', () => {
it('passes the query string from the message payload', async () => {
await runSession(mockDefinition, 'my specific query');
expect(mockClientManager.sendMessageStream).toHaveBeenCalledWith(
'test-remote-agent',
'my specific query',
expect.objectContaining({ signal: expect.any(Object) }),
);
});
it('uses DEFAULT_QUERY_STRING when message text is empty', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: '' }] },
});
await session.getResult();
// DEFAULT_QUERY_STRING = 'Get Started!'
expect(mockClientManager.sendMessageStream).toHaveBeenCalledWith(
'test-remote-agent',
'Get Started!',
expect.objectContaining({ signal: expect.any(Object) }),
);
});
});
// ---------------------------------------------------------------------------
// Concurrent send() guard
// ---------------------------------------------------------------------------
describe('concurrent send() guard', () => {
it('calling send() while a stream is active throws', async () => {
let resolveChunk!: () => void;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
// Block until test releases the chunk
await new Promise<void>((resolve) => {
resolveChunk = resolve;
});
yield makeChunk('late');
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
void session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
// Wait for the stream to actually start (agent_start emitted)
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await vi.waitFor(() => {
expect(events.some((e) => e.type === 'agent_start')).toBe(true);
});
// Second send() while first stream is active must throw
await expect(
session.send({
message: { content: [{ type: 'text', text: 'second' }] },
}),
).rejects.toThrow('cannot be called while a stream is active');
// Clean up: release the blocked generator so getResult() can settle
resolveChunk();
await session.getResult().catch(() => {});
});
});
// ---------------------------------------------------------------------------
// Multi-send support
// ---------------------------------------------------------------------------
describe('multi-send', () => {
it('supports sequential sends after stream completion', async () => {
let callCount = 0;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
callCount++;
yield makeChunk(`Response ${callCount}`);
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
// First send
const result1 = await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
expect(result1.streamId).not.toBeNull();
const output1 = await session.getResult();
expect(output1.llmContent).toEqual([{ text: 'Response 1' }]);
// Second send — should work, not throw
const result2 = await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
expect(result2.streamId).not.toBeNull();
expect(result2.streamId).not.toBe(result1.streamId);
const output2 = await session.getResult();
expect(output2.llmContent).toEqual([{ text: 'Response 2' }]);
});
it('getResult() returns the latest stream result', async () => {
let callCount = 0;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
callCount++;
yield makeChunk(`Result ${callCount}`);
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
const result1 = await session.getResult();
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
const result2 = await session.getResult();
expect(result1.llmContent).toEqual([{ text: 'Result 1' }]);
expect(result2.llmContent).toEqual([{ text: 'Result 2' }]);
});
it('contextId/taskId persist across sends within the same session', async () => {
let sendCallCount = 0;
mockClientManager.sendMessageStream.mockImplementation(async function* (
_name: string,
_query: string,
_opts: Record<string, unknown>,
) {
sendCallCount++;
// First call returns ids; second call should receive them
yield {
kind: 'message' as const,
messageId: `msg-${sendCallCount}`,
contextId: `ctx-${sendCallCount}`,
taskId: `task-${sendCallCount}`,
role: 'agent' as const,
parts: [{ kind: 'text' as const, text: `Response ${sendCallCount}` }],
};
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
// First send — establishes contextId/taskId
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
// Second send — should pass the persisted contextId/taskId
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
// Verify the second call received the contextId/taskId from first call
expect(mockClientManager.sendMessageStream).toHaveBeenCalledTimes(2);
const secondCallOpts =
mockClientManager.sendMessageStream.mock.calls[1]?.[2];
expect(secondCallOpts).toHaveProperty('contextId', 'ctx-1');
expect(secondCallOpts).toHaveProperty('taskId', 'task-1');
});
it('getResult() rejects when called before any send', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await expect(session.getResult()).rejects.toThrow(
'No active or completed stream',
);
});
it('emits fresh agent_start/agent_end per stream', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
// First send
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
const firstStreamEvents = events.length;
expect(events[0]?.type).toBe('agent_start');
expect(events[firstStreamEvents - 1]?.type).toBe('agent_end');
// Second send
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
// Should have a second agent_start/agent_end pair
const secondStreamStart = events[firstStreamEvents];
const lastEvent = events[events.length - 1];
expect(secondStreamStart?.type).toBe('agent_start');
expect(lastEvent?.type).toBe('agent_end');
expect(secondStreamStart?.streamId).not.toBe(events[0]?.streamId);
});
});
});
@@ -1,454 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview RemoteSubagentProtocol wraps A2A remote agent streaming
* behind the AgentProtocol interface.
*
* Pattern mirrors LocalSubagentProtocol and LegacyAgentProtocol, but the loop
* body drives A2AClientManager instead of LocalAgentExecutor.
*/
import { randomUUID } from 'node:crypto';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { AgentSession } from '../agent/agent-session.js';
import type {
AgentProtocol,
AgentSend,
AgentEvent,
StreamEndReason,
Unsubscribe,
ContentPart,
} from '../agent/types.js';
import type { ToolResult } from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
type RemoteAgentDefinition,
type SubagentProgress,
getRemoteAgentTargetUrl,
getAgentCardLoadOptions,
} from './types.js';
import { A2AResultReassembler, extractIdsFromResponse } from './a2aUtils.js';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import { A2AAgentError } from './a2a-errors.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isAbortLikeError(err: unknown): boolean {
return err instanceof Error && err.name === 'AbortError';
}
// ---------------------------------------------------------------------------
// RemoteSubagentProtocol
// ---------------------------------------------------------------------------
class RemoteSubagentProtocol implements AgentProtocol {
private _events: AgentEvent[] = [];
private _subscribers = new Set<(event: AgentEvent) => void>();
private _streamId: string = randomUUID();
private _eventCounter = 0;
private _agentStartEmitted = false;
private _agentEndEmitted = false;
private _activeStreamId: string | undefined;
private _abortController = new AbortController();
// A2A conversation state — persists across sends within this session instance
private contextId: string | undefined;
private taskId: string | undefined;
private authHandler: AuthenticationHandler | undefined;
// Agent display name (for SubagentProgress construction)
private readonly _agentName: string;
// Latest SubagentProgress — updated per chunk, used for error recovery
private _latestProgress: SubagentProgress | undefined;
// Result promise wiring — re-created per stream in _beginNewStream()
private _resultResolve!: (result: ToolResult) => void;
private _resultReject!: (err: unknown) => void;
private _resultPromise: Promise<ToolResult> | undefined;
constructor(
private readonly definition: RemoteAgentDefinition,
private readonly context: AgentLoopContext,
// Required for API parity across protocol constructors (local, remote, legacy)
_messageBus: MessageBus,
initialState?: { contextId?: string; taskId?: string },
) {
this._agentName = definition.displayName ?? definition.name;
if (initialState) {
this.contextId = initialState.contextId;
this.taskId = initialState.taskId;
}
}
/**
* Returns the current A2A conversation state.
* Used by the invocation layer to persist state across invocations.
*/
getSessionState(): { contextId?: string; taskId?: string } {
return { contextId: this.contextId, taskId: this.taskId };
}
// ---------------------------------------------------------------------------
// AgentProtocol interface
// ---------------------------------------------------------------------------
get events(): readonly AgentEvent[] {
return this._events;
}
subscribe(callback: (event: AgentEvent) => void): Unsubscribe {
this._subscribers.add(callback);
return () => {
this._subscribers.delete(callback);
};
}
async send(payload: AgentSend): Promise<{ streamId: string | null }> {
if ('message' in payload && payload.message) {
if (this._activeStreamId) {
throw new Error(
'RemoteSubagentProtocol.send() cannot be called while a stream is active.',
);
}
const query =
payload.message.content
.filter((p): p is ContentPart & { type: 'text' } => p.type === 'text')
.map((p) => p.text)
.join('') || DEFAULT_QUERY_STRING;
this._beginNewStream();
const streamId = this._streamId;
setTimeout(() => {
void this._runStreamInBackground(query);
}, 0);
return { streamId };
}
// update/action/elicitations not used for remote agents
return { streamId: null };
}
async abort(): Promise<void> {
this._abortController.abort();
}
// ---------------------------------------------------------------------------
// Protocol-specific: result access
// ---------------------------------------------------------------------------
getResult(): Promise<ToolResult> {
if (!this._resultPromise) {
return Promise.reject(new Error('No active or completed stream'));
}
return this._resultPromise;
}
getLatestProgress(): SubagentProgress | undefined {
return this._latestProgress;
}
// ---------------------------------------------------------------------------
// Core: A2A streaming
// ---------------------------------------------------------------------------
private _beginNewStream(): void {
this._streamId = randomUUID();
this._eventCounter = 0;
this._abortController = new AbortController();
this._agentStartEmitted = false;
this._agentEndEmitted = false;
this._activeStreamId = this._streamId;
this._resultPromise = new Promise<ToolResult>((resolve, reject) => {
this._resultResolve = resolve;
this._resultReject = reject;
});
}
private async _runStreamInBackground(query: string): Promise<void> {
this._ensureAgentStart();
try {
await this._runStream(query);
} catch (err: unknown) {
if (this._abortController.signal.aborted || isAbortLikeError(err)) {
this._ensureAgentEnd('aborted');
// Abort resolves with an empty result — partial output is intentionally
// dropped since the caller requested cancellation.
this._resultResolve({
llmContent: [{ text: '' }],
returnDisplay: '',
});
} else {
this._emitErrorAndAgentEnd(err);
this._resultReject(err);
}
} finally {
this._clearActiveStream();
}
}
private async _runStream(query: string): Promise<void> {
const clientManager = this.context.config.getA2AClientManager();
if (!clientManager) {
throw new Error(
`RemoteSubagentProtocol: A2AClientManager not available for '${this.definition.name}'.`,
);
}
const authHandler = await this._getAuthHandler();
if (!clientManager.getClient(this.definition.name)) {
await clientManager.loadAgent(
this.definition.name,
getAgentCardLoadOptions(this.definition),
authHandler,
);
}
const reassembler = new A2AResultReassembler();
let prevText = '';
const stream = clientManager.sendMessageStream(
this.definition.name,
query,
{
contextId: this.contextId,
taskId: this.taskId,
signal: this._abortController.signal,
},
);
for await (const chunk of stream) {
reassembler.update(chunk);
const {
contextId: newContextId,
taskId: newTaskId,
clearTaskId,
} = extractIdsFromResponse(chunk);
if (newContextId) this.contextId = newContextId;
this.taskId = clearTaskId ? undefined : (newTaskId ?? this.taskId);
const currentText = reassembler.toString();
// Update latest progress snapshot (for invocation's error recovery)
this._latestProgress = {
isSubagentProgress: true,
agentName: this._agentName,
state: 'running',
recentActivity: reassembler.toActivityItems(),
result: currentText,
};
// Emit delta as a message event
const delta = currentText.slice(prevText.length);
if (delta) {
this._emit([
this._makeEvent('message', {
role: 'agent',
content: [{ type: 'text', text: delta }],
}),
]);
prevText = currentText;
}
}
const finalOutput = reassembler.toString();
debugLogger.debug(
`[RemoteSubagentProtocol] ${this.definition.name} finished, output length: ${finalOutput.length}`,
);
const finalProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this._agentName,
state: 'completed',
result: finalOutput,
recentActivity: reassembler.toActivityItems(),
};
this._latestProgress = finalProgress;
this._finishStream('completed');
this._resultResolve({
llmContent: [{ text: finalOutput }],
returnDisplay: finalProgress,
});
}
private async _getAuthHandler(): Promise<AuthenticationHandler | undefined> {
if (this.authHandler) return this.authHandler;
if (!this.definition.auth) return undefined;
const targetUrl = getRemoteAgentTargetUrl(this.definition);
const provider = await A2AAuthProviderFactory.create({
authConfig: this.definition.auth,
agentName: this.definition.name,
targetUrl,
agentCardUrl: this.definition.agentCardUrl,
});
if (!provider) {
throw new Error(
`Failed to create auth provider for agent '${this.definition.name}'`,
);
}
this.authHandler = provider;
return this.authHandler;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
private _emit(events: AgentEvent[]): void {
if (events.length === 0) return;
const subscribers = [...this._subscribers];
for (const event of events) {
this._events.push(event);
if (event.type === 'agent_end') {
this._agentEndEmitted = true;
}
for (const sub of subscribers) {
sub(event);
}
}
}
private _clearActiveStream(): void {
this._activeStreamId = undefined;
}
private _ensureAgentStart(): void {
if (!this._agentStartEmitted) {
this._agentStartEmitted = true;
this._emit([this._makeEvent('agent_start', {})]);
}
}
private _ensureAgentEnd(reason: StreamEndReason = 'completed'): void {
if (!this._agentEndEmitted && this._agentStartEmitted) {
this._emit([this._makeEvent('agent_end', { reason })]);
}
}
private _finishStream(reason: StreamEndReason): void {
this._ensureAgentEnd(reason);
this._clearActiveStream();
}
private _emitErrorAndAgentEnd(err: unknown): void {
const message = this._formatError(err);
this._ensureAgentStart();
const meta: Record<string, unknown> = {};
if (err instanceof Error) {
meta['errorName'] = err.constructor.name;
meta['stack'] = err.stack;
}
this._emit([
this._makeEvent('error', {
status: 'INTERNAL',
message,
fatal: true,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}),
]);
this._ensureAgentEnd('failed');
}
private _formatError(error: unknown): string {
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
}
private _nextEventFields() {
return {
id: `${this._streamId}-${this._eventCounter++}`,
timestamp: new Date().toISOString(),
streamId: this._streamId,
};
}
private _makeEvent<T extends AgentEvent['type']>(
type: T,
payload: Omit<AgentEvent<T>, 'id' | 'timestamp' | 'streamId' | 'type'>,
): AgentEvent {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...this._nextEventFields(),
type,
...payload,
} as AgentEvent;
}
}
// ---------------------------------------------------------------------------
// Public export
// ---------------------------------------------------------------------------
export class RemoteSubagentSession extends AgentSession {
private readonly _remoteProtocol: RemoteSubagentProtocol;
constructor(
definition: RemoteAgentDefinition,
context: AgentLoopContext,
messageBus: MessageBus,
initialState?: { contextId?: string; taskId?: string },
) {
const protocol = new RemoteSubagentProtocol(
definition,
context,
messageBus,
initialState,
);
super(protocol);
this._remoteProtocol = protocol;
}
/**
* Returns the ToolResult once the remote agent stream completes.
* Used by RemoteAgentInvocation to return the result.
*/
getResult(): Promise<ToolResult> {
return this._remoteProtocol.getResult();
}
/**
* Returns the most recent SubagentProgress snapshot, updated per streaming
* chunk. Useful for constructing error progress when getResult() rejects.
*/
getLatestProgress(): SubagentProgress | undefined {
return this._remoteProtocol.getLatestProgress();
}
/**
* Returns the current A2A conversation state (contextId/taskId).
* Used by the invocation layer to persist state across invocations.
*/
getSessionState(): { contextId?: string; taskId?: string } {
return this._remoteProtocol.getSessionState();
}
/**
* Convenience: start execution with a query string.
* Equivalent to send({message: {content: [{type:'text', text: query}]}}).
*/
async startWithQuery(query: string): Promise<{ streamId: string | null }> {
return this.send({
message: { content: [{ type: 'text', text: query }] },
});
}
}
+7
View File
@@ -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);
});
});
+155
View File
@@ -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\`.`,
},
};
};
+72
View File
@@ -3006,6 +3006,78 @@ describe('Config Quota & Preview Model Access', () => {
// Never set => stays null (unknown); getter returns true so UI shows preview
expect(config.getHasAccessToPreviewModel()).toBe(true);
});
it('should derive quota from remainingFraction when remainingAmount is missing', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3-flash-preview',
remainingFraction: 0.96,
},
],
});
config.setModel('gemini-3-flash-preview');
mockCoreEvents.emitQuotaChanged.mockClear();
await config.refreshUserQuota();
// Normalized: limit=100, remaining=96
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
96,
100,
undefined,
);
expect(config.getQuotaRemaining()).toBe(96);
expect(config.getQuotaLimit()).toBe(100);
});
it('should store quota from remainingFraction when remainingFraction is 0', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3-pro-preview',
remainingFraction: 0,
},
],
});
config.setModel('gemini-3-pro-preview');
mockCoreEvents.emitQuotaChanged.mockClear();
await config.refreshUserQuota();
// remaining=0, limit=100 but limit>0 check still passes
// however remaining=0 means 0% remaining = 100% used
expect(config.getQuotaRemaining()).toBe(0);
expect(config.getQuotaLimit()).toBe(100);
});
it('should emit QuotaChanged when model is switched via setModel', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-2.5-pro',
remainingAmount: '10',
remainingFraction: 0.2,
},
{
modelId: 'gemini-2.5-flash',
remainingAmount: '80',
remainingFraction: 0.8,
},
],
});
config.setModel('auto-gemini-2.5');
await config.refreshUserQuota();
mockCoreEvents.emitQuotaChanged.mockClear();
// Switch to a specific model — should re-emit quota for that model
config.setModel('gemini-2.5-pro');
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
10,
50,
undefined,
);
});
});
describe('refreshUserQuotaIfStale', () => {
+53 -24
View File
@@ -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;
@@ -832,18 +835,16 @@ export class Config implements McpContext, AgentLoopContext {
private lastEmittedQuotaLimit: number | undefined;
private emitQuotaChangedEvent(): void {
const pooled = this.getPooledQuota();
const remaining = this.getQuotaRemaining();
const limit = this.getQuotaLimit();
const resetTime = this.getQuotaResetTime();
if (
this.lastEmittedQuotaRemaining !== pooled.remaining ||
this.lastEmittedQuotaLimit !== pooled.limit
this.lastEmittedQuotaRemaining !== remaining ||
this.lastEmittedQuotaLimit !== limit
) {
this.lastEmittedQuotaRemaining = pooled.remaining;
this.lastEmittedQuotaLimit = pooled.limit;
coreEvents.emitQuotaChanged(
pooled.remaining,
pooled.limit,
pooled.resetTime,
);
this.lastEmittedQuotaRemaining = remaining;
this.lastEmittedQuotaLimit = limit;
coreEvents.emitQuotaChanged(remaining, limit, resetTime);
}
}
@@ -941,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;
@@ -1152,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,
@@ -1819,6 +1824,9 @@ export class Config implements McpContext, AgentLoopContext {
// When the user explicitly sets a model, that becomes the active model.
this._activeModel = newModel;
coreEvents.emitModelChanged(newModel);
this.lastEmittedQuotaRemaining = undefined;
this.lastEmittedQuotaLimit = undefined;
this.emitQuotaChangedEvent();
}
if (this.onModelChange && !isTemporary) {
this.onModelChange(newModel);
@@ -2112,24 +2120,31 @@ export class Config implements McpContext, AgentLoopContext {
this.lastQuotaFetchTime = Date.now();
for (const bucket of quota.buckets) {
if (
bucket.modelId &&
bucket.remainingAmount &&
bucket.remainingFraction != null
) {
const remaining = parseInt(bucket.remainingAmount, 10);
const limit =
if (!bucket.modelId || bucket.remainingFraction == null) {
continue;
}
let remaining: number;
let limit: number;
if (bucket.remainingAmount) {
remaining = parseInt(bucket.remainingAmount, 10);
limit =
bucket.remainingFraction > 0
? Math.round(remaining / bucket.remainingFraction)
: (this.modelQuotas.get(bucket.modelId)?.limit ?? 0);
} else {
// Server only sent remainingFraction — use a normalized scale.
limit = 100;
remaining = Math.round(bucket.remainingFraction * limit);
}
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
}
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
}
}
this.emitQuotaChangedEvent();
@@ -2426,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;
}
@@ -3628,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;
+180 -2
View File
@@ -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),
+2
View File
@@ -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'),
+3
View File
@@ -264,6 +264,9 @@ export * from './hooks/index.js';
// Export hook types
export * from './hooks/types.js';
// Export agent types
export * from './agents/types.js';
// Export stdio utils
export * from './utils/stdio.js';
export * from './utils/terminal.js';
@@ -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',
+7 -1
View File
@@ -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)}${
+7 -1
View File
@@ -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';
+69
View File
@@ -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;
}
}
+57
View File
@@ -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);
});
});
+48
View File
@@ -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;
}
}
}