Compare commits

..

3 Commits

Author SHA1 Message Date
Michael Bleigh 3824f8a6cb chore: merge origin/main and resolve JSONL recording conflict 2026-04-09 21:08:42 -07:00
Michael Bleigh 5ed5db00a4 feat: use native TypeScript syntax for required/optional tool parameters 2026-04-09 17:56:29 -07:00
Michael Bleigh d7ecbb072f feat: implement experimental dynamic tools documentation-only pattern 2026-04-09 17:39:16 -07:00
164 changed files with 1958 additions and 837678 deletions
+1 -4
View File
@@ -2,10 +2,7 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true,
"topicUpdateNarration": true,
"watcher": true,
"watcherInterval": 10
"topicUpdateNarration": true
},
"general": {
"devtools": true
+2 -14
View File
@@ -1,4 +1,4 @@
name: 'Automated Documentation Audit'
name: 'Weekly Docs Audit'
on:
schedule:
@@ -26,7 +26,6 @@ jobs:
node-version: '20'
- name: 'Run Docs Audit with Gemini'
id: 'run_gemini'
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31'
with:
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
@@ -34,28 +33,17 @@ jobs:
Activate the 'docs-writer' skill.
**Task:** Execute the docs audit procedure, as defined in your 'docs-auditing.md' reference.
Provide a detailed summary of the changes you make.
- name: 'Get current date'
id: 'date'
run: |
echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
- name: 'Create Pull Request with Audit Results'
uses: 'peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c'
with:
token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
commit-message: 'docs: weekly audit results for ${{ github.run_id }}'
title: 'Docs audit: ${{ steps.date.outputs.date }}'
title: 'Docs Audit for Week of ${{ github.event.schedule }}'
body: |
This PR contains the auto-generated documentation audit for the week. It includes a new `audit-results-*.md` file with findings and any direct fixes applied by the agent.
### Audit Summary:
${{ steps.run_gemini.outputs.summary || 'No summary provided.' }}
Please review the suggestions and merge.
Related to #25152
branch: 'docs-audit-${{ github.run_id }}'
base: 'main'
team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers'
+3 -7
View File
@@ -505,19 +505,15 @@ events. For more information, see the [telemetry documentation](./telemetry.md).
## Authentication
You can enforce a specific authentication method for all users by setting the
`security.auth.enforcedType` in the system-level `settings.json` file. This
prevents users from choosing a different authentication method. See the
`enforcedAuthType` in the system-level `settings.json` file. This prevents users
from choosing a different authentication method. See the
[Authentication docs](../get-started/authentication.md) for more details.
**Example:** Enforce the use of Google login for all users.
```json
{
"security": {
"auth": {
"enforcedType": "oauth-personal"
}
}
"enforcedAuthType": "oauth-personal"
}
```
+1 -3
View File
@@ -309,9 +309,7 @@ $env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping
**Missing commands**
- Add to a custom Dockerfile. Automatic `BUILD_SANDBOX` builds are only
available when running Gemini CLI from source; npm installs need a prebuilt
image instead.
- Add to custom Dockerfile.
- Install via `sandbox.bashrc`.
**Network issues**
-4
View File
@@ -2509,10 +2509,6 @@ sandbox image:
BUILD_SANDBOX=1 gemini -s
```
Building a custom sandbox with `BUILD_SANDBOX` is only supported when running
Gemini CLI from source. If you installed the CLI with npm, build the Docker
image separately and reference that image in your sandbox configuration.
## Usage statistics
To help us improve Gemini CLI, we collect anonymized usage statistics. This data
+1 -78
View File
@@ -99,7 +99,7 @@ available combinations.
| Command | Action | Keys |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `app.showErrorDetails` | Toggle the debug console for detailed error information. | `F12` |
| `app.showErrorDetails` | Toggle detailed error information. | `F12` |
| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` |
| `app.showIdeContextDetail` | Show IDE context details. | `F4` |
| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` |
@@ -248,83 +248,6 @@ a `key` combination.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
## Vi mode shortcuts
When vim mode is enabled with `/vim` or `general.vimMode: true`, Gemini CLI
supports NORMAL and INSERT modes.
### Mode switching
| Action | Keys |
| -------------------------------------------- | --------- |
| Enter NORMAL mode from INSERT mode | `Esc` |
| Enter INSERT mode at the cursor | `i` |
| Enter INSERT mode after the cursor | `a` |
| Enter INSERT mode at the start of the line | `I` |
| Enter INSERT mode at the end of the line | `A` |
| Insert a new line below and switch to INSERT | `o` |
| Insert a new line above and switch to INSERT | `O` |
| Clear input in NORMAL mode | `Esc Esc` |
### Navigation in NORMAL mode
| Action | Keys |
| --------------------------------- | --------------- |
| Move left | `h` |
| Move down | `j` |
| Move up | `k` |
| Move right | `l` |
| Move to start of line | `0` |
| Move to first non-whitespace char | `^` |
| Move to end of line | `$` |
| Move forward by word | `w` |
| Move backward by word | `b` |
| Move to end of word | `e` |
| Move forward by WORD | `W` |
| Move backward by WORD | `B` |
| Move to end of WORD | `E` |
| Go to first line | `gg` |
| Go to last line | `G` |
| Go to line N | `N G` or `N gg` |
Counts are supported for navigation commands. For example, `5j` moves down five
lines and `3w` moves forward three words.
### Editing in NORMAL mode
| Action | Keys |
| ------------------------------ | ----- |
| Delete character under cursor | `x` |
| Delete to end of line | `D` |
| Delete line | `dd` |
| Change to end of line | `C` |
| Change line | `cc` |
| Delete forward word | `dw` |
| Delete backward word | `db` |
| Delete to end of word | `de` |
| Delete forward WORD | `dW` |
| Delete backward WORD | `dB` |
| Delete to end of WORD | `dE` |
| Change forward word | `cw` |
| Change backward word | `cb` |
| Change to end of word | `ce` |
| Change forward WORD | `cW` |
| Change backward WORD | `cB` |
| Change to end of WORD | `cE` |
| Delete to start of line | `d0` |
| Delete to first non-whitespace | `d^` |
| Change to start of line | `c0` |
| Change to first non-whitespace | `c^` |
| Delete from first line to here | `dgg` |
| Delete from here to last line | `dG` |
| Change from first line to here | `cgg` |
| Change from here to last line | `cG` |
| Undo last change | `u` |
| Repeat last command | `.` |
Counts are also supported for editing commands. For example, `3dd` deletes three
lines and `2cw` changes two words.
## Limitations
- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
+1 -1
View File
@@ -62,7 +62,7 @@ const external = [
'@lydell/node-pty-linux-x64',
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'@github/keytar',
'keytar',
'@google/gemini-cli-devtools',
];
+4 -12
View File
@@ -76,9 +76,7 @@ describe('ripgrep-real-direct', () => {
it('should find matches using the real ripgrep binary', async () => {
const invocation = tool.build({ pattern: 'hello' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 2 matches');
expect(result.llmContent).toContain('file1.txt');
@@ -92,9 +90,7 @@ describe('ripgrep-real-direct', () => {
it('should handle no matches correctly', async () => {
const invocation = tool.build({ pattern: 'nonexistent_pattern_123' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('No matches found');
});
@@ -110,9 +106,7 @@ describe('ripgrep-real-direct', () => {
pattern: 'hello',
include_pattern: '*.js',
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 1 match');
expect(result.llmContent).toContain('script.js');
@@ -130,9 +124,7 @@ describe('ripgrep-real-direct', () => {
pattern: 'match',
context: 1,
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 1 match');
expect(result.llmContent).toContain('context.txt');
+5 -30
View File
@@ -1,55 +1,30 @@
{
"version": 1,
"updatedAt": "2026-04-10T15:36:04.547Z",
"updatedAt": "2026-04-08T01:21:58.770Z",
"scenarios": {
"multi-turn-conversation": {
"heapUsedBytes": 120082704,
"heapTotalBytes": 177586176,
"rssBytes": 269172736,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:17.603Z"
"timestamp": "2026-04-08T01:21:57.127Z"
},
"multi-function-call-repo-search": {
"heapUsedBytes": 104644984,
"heapTotalBytes": 111575040,
"rssBytes": 204079104,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:22.480Z"
"timestamp": "2026-04-08T01:21:58.770Z"
},
"idle-session-startup": {
"heapUsedBytes": 119813672,
"heapTotalBytes": 177061888,
"rssBytes": 267943936,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:08.035Z"
"timestamp": "2026-04-08T01:21:53.855Z"
},
"simple-prompt-response": {
"heapUsedBytes": 119722064,
"heapTotalBytes": 177324032,
"rssBytes": 268812288,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:12.770Z"
},
"resume-large-chat-with-messages": {
"heapUsedBytes": 106545568,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:36:04.547Z"
},
"resume-large-chat": {
"heapUsedBytes": 106513760,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:35:59.528Z"
},
"large-chat": {
"heapUsedBytes": 106471568,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:35:53.180Z"
"timestamp": "2026-04-08T01:21:55.491Z"
}
}
}
File diff suppressed because one or more lines are too long
-317
View File
@@ -8,15 +8,6 @@ import { describe, it, beforeAll, afterAll, afterEach } from 'vitest';
import { TestRig, MemoryTestHarness } from '@google/gemini-cli-test-utils';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createWriteStream,
copyFileSync,
readFileSync,
existsSync,
mkdirSync,
rmSync,
} from 'node:fs';
import { randomUUID } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASELINES_PATH = join(__dirname, 'baselines.json');
@@ -191,312 +182,4 @@ describe('Memory Usage Tests', () => {
harness.assertWithinBaseline(result);
}
});
describe('Large Chat Scenarios', () => {
let sharedResumeResponsesPath: string;
let sharedActiveResponsesPath: string;
let sharedHistoryPath: string;
let sharedPrompts: string;
let tempDir: string;
beforeAll(async () => {
tempDir = join(__dirname, `large-chat-tmp-${randomUUID()}`);
mkdirSync(tempDir, { recursive: true });
const { resumeResponsesPath, activeResponsesPath, historyPath, prompts } =
await generateSharedLargeChatData(tempDir);
sharedActiveResponsesPath = activeResponsesPath;
sharedResumeResponsesPath = resumeResponsesPath;
sharedHistoryPath = historyPath;
sharedPrompts = prompts;
}, 60000);
afterAll(() => {
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
afterEach(async () => {
await rig.cleanup();
});
it('large-chat: memory usage within baseline', async () => {
rig = new TestRig();
rig.setup('memory-large-chat', {
fakeResponsesPath: sharedActiveResponsesPath,
});
const result = await harness.runScenario(
'large-chat',
async (recordSnapshot) => {
await rig.run({
stdin: sharedPrompts,
timeout: 600000,
env: TEST_ENV,
});
await recordSnapshot('after-large-chat');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for large-chat: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('resume-large-chat: memory usage within baseline', async () => {
rig = new TestRig();
rig.setup('memory-resume-large-chat', {
fakeResponsesPath: sharedResumeResponsesPath,
});
const result = await harness.runScenario(
'resume-large-chat',
async (recordSnapshot) => {
// Ensure the history file is linked
const targetChatsDir = join(
rig.testDir!,
'tmp',
'test-project-hash',
'chats',
);
mkdirSync(targetChatsDir, { recursive: true });
const targetHistoryPath = join(
targetChatsDir,
'large-chat-session.json',
);
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
copyFileSync(sharedHistoryPath, targetHistoryPath);
await rig.run({
// add a prompt to make sure it does not hang there and exits immediately
args: ['--resume', 'latest', '--prompt', 'hello'],
timeout: 600000,
env: TEST_ENV,
});
await recordSnapshot('after-resume-large-chat');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for resume-large-chat: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('resume-large-chat-with-messages: memory usage within baseline', async () => {
rig = new TestRig();
rig.setup('memory-resume-large-chat-msgs', {
fakeResponsesPath: sharedResumeResponsesPath,
});
const result = await harness.runScenario(
'resume-large-chat-with-messages',
async (recordSnapshot) => {
// Ensure the history file is linked
const targetChatsDir = join(
rig.testDir!,
'tmp',
'test-project-hash',
'chats',
);
mkdirSync(targetChatsDir, { recursive: true });
const targetHistoryPath = join(
targetChatsDir,
'large-chat-session.json',
);
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
copyFileSync(sharedHistoryPath, targetHistoryPath);
const stdinContent = 'new prompt 1\nnew prompt 2\n';
await rig.run({
args: ['--resume', 'latest'],
stdin: stdinContent,
timeout: 600000,
env: TEST_ENV,
});
await recordSnapshot('after-resume-and-append');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for resume-large-chat-with-messages: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
});
});
async function generateSharedLargeChatData(tempDir: string) {
const resumeResponsesPath = join(tempDir, 'large-chat-resume-chat.responses');
const activeResponsesPath = join(tempDir, 'large-chat-active-chat.responses');
const historyPath = join(tempDir, 'large-chat-history.json');
const sourceSessionPath = join(__dirname, 'large-chat-session.json');
const session = JSON.parse(readFileSync(sourceSessionPath, 'utf8'));
const messages = session.messages;
copyFileSync(sourceSessionPath, historyPath);
// Generate fake responses for active chat
const promptsList: string[] = [];
const activeResponsesStream = createWriteStream(activeResponsesPath);
const complexityResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: {
parts: [
{
text: '{"complexity_reasoning":"simple","complexity_score":1}',
},
],
role: 'model',
},
finishReason: 'STOP',
index: 0,
},
],
},
};
const summaryResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: {
parts: [
{ text: '{"originalSummary":"large chat summary","events":[]}' },
],
role: 'model',
},
finishReason: 'STOP',
index: 0,
},
],
},
};
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.type === 'user') {
promptsList.push(msg.content[0].text);
// Start of a new turn
activeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
// Find all subsequent gemini messages until the next user message
let j = i + 1;
while (j < messages.length && messages[j].type === 'gemini') {
const geminiMsg = messages[j];
const parts = [];
if (geminiMsg.content) {
parts.push({ text: geminiMsg.content });
}
if (geminiMsg.toolCalls) {
for (const tc of geminiMsg.toolCalls) {
parts.push({
functionCall: {
name: tc.name,
args: tc.args,
},
});
}
}
activeResponsesStream.write(
JSON.stringify({
method: 'generateContentStream',
response: [
{
candidates: [
{
content: { parts, role: 'model' },
finishReason: 'STOP',
index: 0,
},
],
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 100,
totalTokenCount: 200,
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 100 }],
},
},
],
}) + '\n',
);
j++;
}
// End of turn
activeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
// Skip the gemini messages we just processed
i = j - 1;
}
}
activeResponsesStream.end();
// Generate responses for resumed chat
const resumeResponsesStream = createWriteStream(resumeResponsesPath);
for (let i = 0; i < 5; i++) {
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
resumeResponsesStream.write(
JSON.stringify({
method: 'generateContentStream',
response: [
{
candidates: [
{
content: {
parts: [{ text: `Resume response ${i}` }],
role: 'model',
},
finishReason: 'STOP',
index: 0,
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 10,
totalTokenCount: 20,
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 10 }],
},
},
],
}) + '\n',
);
resumeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
}
resumeResponsesStream.end();
// Wait for streams to finish
await Promise.all([
new Promise((res) => activeResponsesStream.on('finish', res)),
new Promise((res) => resumeResponsesStream.on('finish', res)),
]);
return {
resumeResponsesPath,
activeResponsesPath,
historyPath,
prompts: promptsList.join('\n'),
};
}
+84 -568
View File
@@ -74,13 +74,13 @@
"node": ">=20.0.0"
},
"optionalDependencies": {
"@github/keytar": "^7.10.6",
"@lydell/node-pty": "1.1.0",
"@lydell/node-pty-darwin-arm64": "1.1.0",
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
}
},
@@ -447,8 +447,7 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1100,27 +1099,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@github/keytar": {
"version": "7.10.6",
"resolved": "https://registry.npmjs.org/@github/keytar/-/keytar-7.10.6.tgz",
"integrity": "sha512-mRW6cUsSG+nj4jp5gp8e91zPySaT73r+2JM6VyMZfrEgksjPmjSMr+tPGNOK3HUHV+GUU9B1LAiiYy/wmAnIxA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.3.0"
}
},
"node_modules/@github/keytar/node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/@google-cloud/common": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz",
@@ -1472,7 +1450,6 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -1500,9 +1477,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.13",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -2179,7 +2156,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2360,7 +2336,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2410,7 +2385,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2785,7 +2759,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2819,7 +2792,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2874,7 +2846,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4111,7 +4082,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4386,7 +4356,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5260,7 +5229,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5852,9 +5820,9 @@
"license": "MIT"
},
"node_modules/basic-ftp": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz",
"integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -5947,9 +5915,9 @@
"license": "BSD-2-Clause"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -6793,9 +6761,9 @@
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
"engines": {
@@ -7401,8 +7369,7 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7880,7 +7847,6 @@
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
"integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -7987,7 +7953,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8505,7 +8470,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9814,11 +9778,10 @@
}
},
"node_modules/hono": {
"version": "4.12.12",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"version": "4.12.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10097,7 +10060,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -11235,6 +11197,26 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
}
},
"node_modules/keytar/node_modules/prebuild-install": {
"name": "nop",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nop/-/nop-1.0.0.tgz",
"integrity": "sha512-XdkOuXGx0DTwlqb0DWTcDqelgU/F3YyZ+PTRaecpDVpkYskcnh3OeUYKfvjcRQ2D1diTIGxi/a3eHVjW5yPupQ==",
"license": "MIT",
"optional": true
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11512,9 +11494,9 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
@@ -12257,6 +12239,13 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -12564,9 +12553,9 @@
}
},
"node_modules/npm-run-all2/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -13249,16 +13238,6 @@
"node": "20 || >=22"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/path-type": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
@@ -13309,9 +13288,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -13854,7 +13833,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13865,7 +13843,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -14394,6 +14371,15 @@
"node": ">= 18"
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/run-applescript": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
@@ -16002,11 +15988,10 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16229,8 +16214,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16238,7 +16222,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16404,7 +16387,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16623,13 +16605,12 @@
}
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -16719,463 +16700,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -17194,11 +16718,10 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17211,7 +16734,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17280,9 +16802,9 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -17712,18 +17234,15 @@
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
@@ -17862,7 +17381,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18250,13 +17768,13 @@
"node": ">=20"
},
"optionalDependencies": {
"@github/keytar": "^7.10.6",
"@lydell/node-pty": "1.1.0",
"@lydell/node-pty-darwin-arm64": "1.1.0",
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
}
},
@@ -18306,7 +17824,6 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -18406,11 +17923,10 @@
}
},
"packages/core/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+1 -1
View File
@@ -150,13 +150,13 @@
"simple-git": "^3.28.0"
},
"optionalDependencies": {
"@github/keytar": "^7.10.6",
"@lydell/node-pty": "1.1.0",
"@lydell/node-pty-darwin-arm64": "1.1.0",
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
"lint-staged": {
+2 -2
View File
@@ -101,8 +101,8 @@ export class AddMemoryCommand implements Command {
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const abortSignal = abortController.signal;
await tool.buildAndExecute(result.toolArgs, abortSignal, undefined, {
const signal = abortController.signal;
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
shellExecutionConfig: {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: loopContext.sandboxManager,
+2 -4
View File
@@ -1129,9 +1129,7 @@ export class Session {
});
}
const toolResult: ToolResult = await invocation.execute({
abortSignal,
});
const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
const updateContent: acp.ToolCallContent[] = content ? [content] : [];
@@ -1673,7 +1671,7 @@ export class Session {
kind: toAcpToolKind(readManyFilesTool.kind),
});
const result = await invocation.execute({ abortSignal });
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || {
type: 'content',
content: {
+1 -2
View File
@@ -990,8 +990,7 @@ export async function loadCliConfig(
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalMemoryManager: settings.experimental?.memoryManager,
experimentalWatcher: settings.experimental?.watcher,
experimentalWatcherInterval: settings.experimental?.watcherInterval,
experimentalDynamicTools: settings.experimental?.dynamicTools,
contextManagement,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
+2 -2
View File
@@ -34,8 +34,8 @@ export const ALL_ITEMS = [
},
{
id: 'quota',
header: 'quota',
description: 'Percentage of daily limit used (not shown when unavailable)',
header: '/stats',
description: 'Remaining usage on daily limit (not shown when unavailable)',
},
{
id: 'memory-usage',
+3 -13
View File
@@ -2217,24 +2217,14 @@ const SETTINGS_SCHEMA = {
'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.',
showInDialog: true,
},
watcher: {
dynamicTools: {
type: 'boolean',
label: 'Watcher Subagent',
label: 'Dynamic Tools Documentation',
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.',
'Enable documentation-injected dynamic tools experiment. This hides native tools and exposes a single execute() function.',
showInDialog: true,
},
},
+1 -2
View File
@@ -1428,13 +1428,12 @@ describe('startInteractiveUI', () => {
vi.mock('./ui/utils/updateCheck.js', () => ({
checkForUpdates: vi.fn(() => Promise.resolve(null)),
}));
vi.mock('./utils/cleanup.js', () => ({
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
registerCleanup: vi.fn(),
removeCleanup: vi.fn(),
runExitCleanup: vi.fn(),
registerSyncCleanup: vi.fn(),
removeSyncCleanup: vi.fn(),
registerTelemetryConfig: vi.fn(),
setupSignalHandlers: vi.fn(),
setupTtyCheck: vi.fn(() => vi.fn()),
-2
View File
@@ -142,9 +142,7 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
...actual,
cleanupCheckpoints: vi.fn().mockResolvedValue(undefined),
registerCleanup: vi.fn(),
removeCleanup: vi.fn(),
registerSyncCleanup: vi.fn(),
removeSyncCleanup: vi.fn(),
registerTelemetryConfig: vi.fn(),
runExitCleanup: vi.fn().mockResolvedValue(undefined),
};
+7 -49
View File
@@ -9,11 +9,7 @@ import { render } from 'ink';
import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import {
registerCleanup,
removeCleanup,
setupTtyCheck,
} from './utils/cleanup.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import {
type StartupWarning,
type Config,
@@ -93,6 +89,7 @@ export async function startInteractiveUI(
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
@@ -170,11 +167,11 @@ export async function startInteractiveUI(
},
);
let cleanupLineWrapping: (() => void) | undefined;
if (useAlternateBuffer) {
disableLineWrapping();
cleanupLineWrapping = () => enableLineWrapping();
registerCleanup(cleanupLineWrapping);
registerCleanup(() => {
enableLineWrapping();
});
}
checkForUpdates(settings)
@@ -188,48 +185,9 @@ export async function startInteractiveUI(
}
});
const cleanupUnmount = () => instance.unmount();
registerCleanup(cleanupUnmount);
registerCleanup(() => instance.unmount());
const cleanupTtyCheck = setupTtyCheck();
registerCleanup(cleanupTtyCheck);
const cleanupConsolePatcher = () => consolePatcher.cleanup();
registerCleanup(cleanupConsolePatcher);
try {
await instance.waitUntilExit();
} finally {
try {
removeCleanup(cleanupConsolePatcher);
cleanupConsolePatcher();
} catch (e: unknown) {
debugLogger.error('Error cleaning up console patcher:', e);
}
try {
removeCleanup(cleanupUnmount);
instance.unmount();
} catch (e: unknown) {
debugLogger.error('Error unmounting Ink instance:', e);
}
try {
removeCleanup(cleanupTtyCheck);
cleanupTtyCheck();
} catch (e: unknown) {
debugLogger.error('Error in TTY cleanup:', e);
}
if (cleanupLineWrapping) {
try {
removeCleanup(cleanupLineWrapping);
cleanupLineWrapping();
} catch (e: unknown) {
debugLogger.error('Error restoring line wrapping:', e);
}
}
}
registerCleanup(setupTtyCheck());
}
function setWindowTitle(title: string, settings: LoadedSettings) {
+3 -15
View File
@@ -136,11 +136,7 @@ import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import {
registerCleanup,
removeCleanup,
runExitCleanup,
} from '../utils/cleanup.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { relaunchApp } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
@@ -523,7 +519,7 @@ export const AppContainer = (props: AppContainerProps) => {
debugLogger.warn('Background summary generation failed:', e);
});
})();
const cleanupFn = async () => {
registerCleanup(async () => {
// Turn off mouse scroll.
disableMouseEvents();
@@ -539,15 +535,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Fire SessionEnd hook on cleanup (only if hooks are enabled)
await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
};
registerCleanup(cleanupFn);
return () => {
removeCleanup(cleanupFn);
cleanupFn().catch((e: unknown) =>
debugLogger.error('Error during cleanup:', e),
);
};
});
// Disable the dependencies check here. historyManager gets flagged
// but we don't want to react to changes to it because each new history
// item, including the ones from the start session hook will cause a
@@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = {
description: 'View and edit Gemini CLI settings',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'settings',
@@ -281,7 +281,7 @@ describe('<Footer />', () => {
},
},
});
expect(lastFrame()).toContain('85% used');
expect(lastFrame()).toContain('85%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -306,7 +306,7 @@ describe('<Footer />', () => {
},
},
});
expect(normalizeFrame(lastFrame())).toContain('15% used');
expect(normalizeFrame(lastFrame())).not.toContain('used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
+3 -1
View File
@@ -351,11 +351,13 @@ export const Footer: React.FC = () => {
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
forceShow={true}
lowercase={true}
/>
),
9, // "100% used" is 9 chars
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
);
}
break;
@@ -256,7 +256,7 @@ describe('<FooterConfigDialog />', () => {
expect(nextLine).toContain('·');
expect(nextLine).toContain('~/project/path');
expect(nextLine).toContain('docker');
expect(nextLine).toContain('42% used');
expect(nextLine).toContain('97%');
});
await expect(renderResult).toMatchSvgSnapshot();
@@ -242,7 +242,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
'context-used': (
<Text color={getColor('context-used', itemColor)}>85% used</Text>
),
quota: <Text color={getColor('quota', itemColor)}>42% used</Text>,
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
'memory-usage': (
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
),
@@ -1,14 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" workspace (/directory) sandbox /model quota
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;
@@ -39,7 +39,7 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
@@ -50,7 +50,7 @@
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
@@ -132,10 +132,10 @@
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="288" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="396" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="504" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="684" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<text x="297" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="405" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="513" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="693" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<rect x="801" y="629" width="36" height="17" fill="#001a00" />
<text x="801" y="631" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="837" y="629" width="18" height="17" fill="#001a00" />
@@ -144,10 +144,10 @@
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="288" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="396" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="504" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="684" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="297" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="405" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="513" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="693" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<rect x="801" y="646" width="27" height="17" fill="#001a00" />
<text x="801" y="648" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="828" y="646" width="9" height="17" fill="#001a00" />

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

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

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

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

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -16,7 +16,7 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Percentage of daily limit used (not shown when unavailable) │
Remaining usage on daily limit (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -38,8 +38,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model quota diff │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used +12 -4 │ │
│ │ workspace (/directory) branch sandbox /model /stats diff │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -61,7 +61,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Percentage of daily limit used (not shown when unavailable) │
Remaining usage on daily limit (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -83,8 +83,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model quota │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -107,7 +107,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Percentage of daily limit used (not shown when unavailable) │
Remaining usage on daily limit (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -129,8 +129,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model quota │ │
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -152,7 +152,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
Percentage of daily limit used (not shown when unavailable) │
Remaining usage on daily limit (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
@@ -174,7 +174,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ ~/project/path · main · docker · gemini-2.5-pro · 42% used │ │
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -22,7 +22,8 @@ import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import {
isVisibleInToolGroup,
shouldHideToolCall,
CoreToolCallStatus,
Kind,
EDIT_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
@@ -35,7 +36,6 @@ import {
READ_MANY_FILES_DISPLAY_NAME,
isFileDiff,
} from '@google/gemini-cli-core';
import { buildToolVisibilityContextFromDisplay } from '../../utils/historyUtils.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
@@ -125,13 +125,40 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const visibleToolCalls = useMemo(
() =>
allToolCalls.filter((t) =>
// Use the unified visibility utility
isVisibleInToolGroup(
buildToolVisibilityContextFromDisplay(t),
isLowErrorVerbosity ? 'low' : 'full',
),
),
allToolCalls.filter((t) => {
// Hide internal errors unless full verbosity
if (
isLowErrorVerbosity &&
t.status === CoreToolCallStatus.Error &&
!t.isClientInitiated
) {
return false;
}
// Standard hiding logic (e.g. Plan Mode internal edits)
if (
shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
})
) {
return false;
}
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
// Only show tools that are actually running or finished.
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[allToolCalls, isLowErrorVerbosity],
);
@@ -21,12 +21,6 @@ exports[`ToolResultDisplay > renders file diff result 1`] = `
"
`;
exports[`ToolResultDisplay > renders file diff result 2`] = `
"
No changes detected.
"
`;
exports[`ToolResultDisplay > renders nothing for todos result 1`] = `""`;
exports[`ToolResultDisplay > renders string result as markdown by default 1`] = `
@@ -533,7 +533,7 @@ async function readLocalFiles(
let invocation: AnyToolInvocation | undefined = undefined;
try {
invocation = readManyFilesTool.build(toolArgs);
const result = await invocation.execute({ abortSignal: signal });
const result = await invocation.execute(signal);
const display: IndividualToolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
@@ -133,7 +133,6 @@ describe('useExecutionLifecycle', () => {
mockConfig = {
getTargetDir: () => '/test/dir',
getEnableInteractiveShell: () => false,
getSessionId: () => 'test-session-id',
getShellExecutionConfig: () => ({
terminalHeight: 20,
terminalWidth: 80,
@@ -247,32 +246,11 @@ describe('useExecutionLifecycle', () => {
expect.any(Function),
expect.any(Object),
false,
expect.objectContaining({
sessionId: 'test-session-id',
}),
expect.any(Object),
);
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
});
it('should pass the config sessionId into shell execution config', async () => {
const { result } = await renderProcessorHook();
await act(async () => {
result.current.handleShellCommand('top', new AbortController().signal);
});
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.any(String),
'/test/dir',
expect.any(Function),
expect.any(Object),
false,
expect.objectContaining({
sessionId: 'test-session-id',
}),
);
});
it('should handle successful execution and update history correctly', async () => {
const { result } = await renderProcessorHook();
@@ -409,7 +409,6 @@ export const useExecutionLifecycle = (
const activeTheme = themeManager.getActiveTheme();
const shellExecutionConfig = {
...config.getShellExecutionConfig(),
sessionId: config.getSessionId(),
terminalWidth,
terminalHeight,
defaultFg: activeTheme.colors.Foreground,
+23 -3
View File
@@ -39,8 +39,7 @@ import {
isBackgroundExecutionData,
Kind,
ACTIVATE_SKILL_TOOL_NAME,
isRenderedInHistory,
buildToolVisibilityContext,
shouldHideToolCall,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
} from '@google/gemini-cli-core';
@@ -648,8 +647,29 @@ export const useGeminiStream = (
toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));
const isToolVisible = (tc: TrackedToolCall) => {
const displayName = tc.tool?.displayName ?? tc.request.name;
let hasResultDisplay = false;
if (
tc.status === CoreToolCallStatus.Success ||
tc.status === CoreToolCallStatus.Error ||
tc.status === CoreToolCallStatus.Cancelled
) {
hasResultDisplay = !!tc.response?.resultDisplay;
} else if (tc.status === CoreToolCallStatus.Executing) {
hasResultDisplay = !!tc.liveOutput;
}
// AskUser tools and Plan Mode write/edit are handled by this logic
if (!isRenderedInHistory(buildToolVisibilityContext(tc))) {
if (
shouldHideToolCall({
displayName,
status: tc.status,
approvalMode: tc.approvalMode,
hasResultDisplay,
parentCallId: tc.request.parentCallId,
})
) {
return false;
}
+1 -2
View File
@@ -637,8 +637,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
// App Controls
[Command.SHOW_ERROR_DETAILS]:
'Toggle the debug console for detailed error information.',
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
+4 -14
View File
@@ -4,18 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
CoreToolCallStatus,
belongsInConfirmationQueue,
} from '@google/gemini-cli-core';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import {
type HistoryItemWithoutId,
type IndividualToolCallDisplay,
} from '../types.js';
import {
getAllToolCalls,
buildToolVisibilityContextFromDisplay,
} from './historyUtils.js';
import { getAllToolCalls } from './historyUtils.js';
export interface ConfirmingToolState {
tool: IndividualToolCallDisplay;
@@ -39,18 +33,14 @@ export function getConfirmingToolState(
return null;
}
const actionablePendingTools = allPendingTools.filter((tool) =>
belongsInConfirmationQueue(buildToolVisibilityContextFromDisplay(tool)),
);
const head = confirmingTools[0];
const headIndexInFullList = actionablePendingTools.findIndex(
const headIndexInFullList = allPendingTools.findIndex(
(tool) => tool.callId === head.callId,
);
return {
tool: head,
index: headIndexInFullList + 1,
total: actionablePendingTools.length,
total: allPendingTools.length,
};
}
-18
View File
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ToolVisibilityContext } from '@google/gemini-cli-core';
import { CoreToolCallStatus } from '../types.js';
import type {
HistoryItem,
@@ -13,23 +12,6 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
/**
* Maps an IndividualToolCallDisplay from the CLI to a ToolVisibilityContext for core logic.
*/
export function buildToolVisibilityContextFromDisplay(
tool: IndividualToolCallDisplay,
): ToolVisibilityContext {
return {
name: tool.originalRequestName ?? tool.name,
displayName: tool.name, // In CLI, 'name' is usually the resolved display name
status: tool.status,
hasResult: !!tool.resultDisplay,
approvalMode: tool.approvalMode,
isClientInitiated: tool.isClientInitiated,
parentCallId: tool.parentCallId,
};
}
export function getLastTurnToolCallIds(
history: HistoryItem[],
pendingHistoryItems: HistoryItemWithoutId[],
@@ -266,29 +266,6 @@ describe('textUtils', () => {
// 0xA0 is non-breaking space, should be preserved
expect(stripUnsafeCharacters('hello\xA0world')).toBe('hello\xA0world');
});
it('should not lose text after DCS (0x90) — regression for data loss', () => {
// 0x90 (DCS) starts a Device Control String that stripVTControlCharacters
// treats as an unterminated sequence, swallowing all subsequent text.
// Stripping C1 chars before VT processing prevents this data loss.
expect(stripUnsafeCharacters('important\x90data after DCS')).toBe(
'importantdata after DCS',
);
});
it('should fully strip 8-bit CSI (0x9B) sequences', () => {
// 0x9B (CSI) is equivalent to ESC[. stripAnsi should handle the
// whole sequence including parameters.
expect(stripUnsafeCharacters('keep\x9B42mthis text')).toBe(
'keepthis text',
);
});
it('should not lose text when multiple C1 chars precede valid content', () => {
expect(stripUnsafeCharacters('start\x90\x9B\x85middle\x80end')).toBe(
'startmiddleend',
);
});
});
describe('ANSI escape sequence stripping', () => {
+9 -19
View File
@@ -98,16 +98,8 @@ export function cpSlice(str: string, start: number, end?: number): string {
/**
* Strip characters that can break terminal rendering.
*
* This is a strict sanitization function intended for general display
* contexts. It strips all C1 control characters (0x80-0x9F) and VT
* control sequences. For list display contexts where a more lenient
* approach is needed (preserving C1 characters and only stripping ANSI
* codes and newlines/tabs), use a separate function instead.
*
* Processing order:
* 1. stripAnsi removes ANSI escape sequences (including 8-bit CSI 0x9B)
* 2. Regex strips C0, C1, BiDi, and zero-width control characters
* 3. stripVTControlCharacters removes any remaining VT sequences
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
@@ -127,20 +119,18 @@ export function cpSlice(str: string, start: number, end?: number): string {
*/
export function stripUnsafeCharacters(str: string): string {
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
// Strip C0, C1, and other unsafe characters via regex first.
// This is more efficient than multiple replaces and crucially removes C1
// characters (e.g., 0x90 DCS) before they can be misinterpreted by
// stripVTControlCharacters, which could otherwise cause data loss.
const strippedWithRegex = strippedAnsi.replace(
// Use a regex to strip remaining unsafe control characters
// C0: 0x00-0x1F except 0x09 (TAB), 0x0A (LF), 0x0D (CR)
// C1: 0x80-0x9F
// BiDi: U+200E (LRM), U+200F (RLM), U+202A-U+202E, U+2066-U+2069
// Zero-width: U+200B (ZWSP), U+FEFF (BOM)
return strippedVT.replace(
// eslint-disable-next-line no-control-regex
/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF]/g,
'',
);
// Finally, use stripVTControlCharacters for any remaining VT sequences
// that the regex might not cover.
return stripVTControlCharacters(strippedWithRegex);
}
/**
-14
View File
@@ -24,24 +24,10 @@ export function registerCleanup(fn: (() => void) | (() => Promise<void>)) {
cleanupFunctions.push(fn);
}
export function removeCleanup(fn: (() => void) | (() => Promise<void>)) {
const index = cleanupFunctions.indexOf(fn);
if (index !== -1) {
cleanupFunctions.splice(index, 1);
}
}
export function registerSyncCleanup(fn: () => void) {
syncCleanupFunctions.push(fn);
}
export function removeSyncCleanup(fn: () => void) {
const index = syncCleanupFunctions.indexOf(fn);
if (index !== -1) {
syncCleanupFunctions.splice(index, 1);
}
}
/**
* Resets the internal cleanup state for testing purposes.
* This allows tests to run in isolation without vi.resetModules().
@@ -294,7 +294,7 @@ describe('validateNonInterActiveAuth', () => {
expect(processExitSpy).not.toHaveBeenCalled();
});
it('succeeds if effectiveAuthType matches enforcedType', async () => {
it('succeeds if effectiveAuthType matches enforcedAuthType', async () => {
mockSettings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createLocalMockConfig({});
@@ -308,7 +308,7 @@ describe('validateNonInterActiveAuth', () => {
expect(debugLoggerErrorSpy).not.toHaveBeenCalled();
});
it('exits if configuredAuthType does not match enforcedType', async () => {
it('exits if configuredAuthType does not match enforcedAuthType', async () => {
mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE;
const nonInteractiveConfig = createLocalMockConfig({
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
@@ -334,7 +334,7 @@ describe('validateNonInterActiveAuth', () => {
);
});
it('exits if auth from env var does not match enforcedType', async () => {
it('exits if auth from env var does not match enforcedAuthType', async () => {
mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE;
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createLocalMockConfig({
View File
View File
+1 -1
View File
@@ -91,13 +91,13 @@
"zod-to-json-schema": "^3.25.1"
},
"optionalDependencies": {
"@github/keytar": "^7.10.6",
"@lydell/node-pty": "1.1.0",
"@lydell/node-pty-darwin-arm64": "1.1.0",
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
"devDependencies": {
+6 -7
View File
@@ -11,7 +11,7 @@ import {
type ToolResult,
BaseToolInvocation,
type ToolCallConfirmationDetails,
type ExecuteOptions,
type ToolLiveOutput,
} from '../tools/tools.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -185,8 +185,10 @@ class DelegateInvocation extends BaseToolInvocation<
return invocation.shouldConfirmExecute(abortSignal);
}
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
async execute(
signal: AbortSignal,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
const hintedParams = this.withUserHints(this.mappedInputs);
const invocation = this.buildChildInvocation(hintedParams);
@@ -202,10 +204,7 @@ class DelegateInvocation extends BaseToolInvocation<
},
async ({ metadata }) => {
metadata.input = this.params;
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
const result = await invocation.execute(signal, updateOutput);
metadata.output = result;
return result;
},
@@ -99,9 +99,7 @@ describe('analyzeScreenshot', () => {
const invocation = tool.build({
instruction: 'Find the blue submit button',
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
// Verify screenshot was captured
expect(browserManager.callTool).toHaveBeenCalledWith(
@@ -167,7 +165,7 @@ describe('analyzeScreenshot', () => {
const invocation = tool.build({
instruction: 'Find the search bar',
});
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
const contentGenerator = config.getContentGenerator();
expect(contentGenerator.generateContent).toHaveBeenCalledWith(
@@ -196,9 +194,7 @@ describe('analyzeScreenshot', () => {
const invocation = tool.build({
instruction: 'Find the button',
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('Failed to capture screenshot');
@@ -221,9 +217,7 @@ describe('analyzeScreenshot', () => {
const invocation = tool.build({
instruction: 'Check the layout',
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('Visual model returned no analysis');
@@ -244,9 +238,7 @@ describe('analyzeScreenshot', () => {
const invocation = tool.build({
instruction: 'Find the red error',
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain(
@@ -269,9 +261,7 @@ describe('analyzeScreenshot', () => {
const invocation = tool.build({
instruction: 'Identify the element',
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain(
@@ -291,9 +281,7 @@ describe('analyzeScreenshot', () => {
const invocation = tool.build({
instruction: 'Find something',
});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('Visual analysis failed');
@@ -23,7 +23,6 @@ import {
Kind,
type ToolResult,
type ToolInvocation,
type ExecuteOptions,
} from '../../tools/tools.js';
import { Environment } from '@google/genai';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
@@ -81,7 +80,7 @@ class AnalyzeScreenshotInvocation extends BaseToolInvocation<
return `Visual analysis: "${instruction}"`;
}
async execute({ abortSignal: signal }: ExecuteOptions): Promise<ToolResult> {
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
const instruction = String(this.params['instruction'] ?? '');
@@ -223,10 +223,7 @@ describe('BrowserAgentInvocation', () => {
const controller = new AbortController();
const updateOutput: (output: ToolLiveOutput) => void = vi.fn();
const result = await invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
const result = await invocation.execute(controller.signal, updateOutput);
expect(Array.isArray(result.llmContent)).toBe(true);
expect((result.llmContent as Array<{ text: string }>)[0].text).toContain(
@@ -245,7 +242,7 @@ describe('BrowserAgentInvocation', () => {
const controller = new AbortController();
// Should not throw even with no updateOutput
await expect(
invocation.execute({ abortSignal: controller.signal }),
invocation.execute(controller.signal),
).resolves.toBeDefined();
});
@@ -259,9 +256,7 @@ describe('BrowserAgentInvocation', () => {
);
const controller = new AbortController();
const result = await invocation.execute({
abortSignal: controller.signal,
});
const result = await invocation.execute(controller.signal);
expect(result.error).toBeDefined();
expect(removeInputBlocker).toHaveBeenCalled();
@@ -303,10 +298,7 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
await invocation.execute(new AbortController().signal, updateOutput);
const firstCall = updateOutput.mock.calls[0]?.[0] as SubagentProgress;
expect(firstCall.isSubagentProgress).toBe(true);
@@ -323,10 +315,7 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
await invocation.execute(new AbortController().signal, updateOutput);
const lastCall = updateOutput.mock.calls[
updateOutput.mock.calls.length - 1
@@ -345,10 +334,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
// Allow createBrowserAgentDefinition to resolve and onActivity to be registered
await Promise.resolve();
@@ -388,10 +377,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
// Allow createBrowserAgentDefinition to resolve and onActivity to be registered
await Promise.resolve();
@@ -435,10 +424,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
await Promise.resolve();
await Promise.resolve();
@@ -486,10 +475,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
await Promise.resolve();
await Promise.resolve();
@@ -530,10 +519,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
await Promise.resolve();
await Promise.resolve();
@@ -575,10 +564,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
await Promise.resolve();
await Promise.resolve();
@@ -615,10 +604,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
await Promise.resolve();
await Promise.resolve();
@@ -658,10 +647,10 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: new AbortController().signal,
const executePromise = invocation.execute(
new AbortController().signal,
updateOutput,
});
);
await Promise.resolve();
await Promise.resolve();
@@ -714,10 +703,7 @@ describe('BrowserAgentInvocation', () => {
mockParams,
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput: vi.fn(),
});
await invocation.execute(new AbortController().signal, vi.fn());
expect(recordBrowserAgentTaskOutcome).toHaveBeenCalledWith(
mockConfig,
@@ -745,10 +731,7 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
await invocation.execute(new AbortController().signal, updateOutput);
expect(recordBrowserAgentTaskOutcome).toHaveBeenCalledWith(
mockConfig,
@@ -768,10 +751,7 @@ describe('BrowserAgentInvocation', () => {
mockParams,
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput: vi.fn(),
});
await invocation.execute(new AbortController().signal, vi.fn());
expect(cleanupBrowserAgent).not.toHaveBeenCalled();
});
@@ -827,7 +807,7 @@ describe('BrowserAgentInvocation', () => {
mockMessageBus,
);
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
// Verify list_pages was called
expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
@@ -22,7 +22,7 @@ import { LocalAgentExecutor } from '../local-executor.js';
import {
BaseToolInvocation,
type ToolResult,
type ExecuteOptions,
type ToolLiveOutput,
} from '../../tools/tools.js';
import { ToolErrorType } from '../../tools/tool-error.js';
import {
@@ -107,8 +107,10 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
* 3. Runs the agent via LocalAgentExecutor
* 4. Cleans up browser resources
*/
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
async execute(
signal: AbortSignal,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
const invocationStartMs = Date.now();
let browserManager;
let recentActivity: SubagentActivityItem[] = [];
@@ -139,7 +139,7 @@ describe('mcpToolWrapper', () => {
);
const invocation = tools[1].build({ uid: 'elem-123' });
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
'click',
@@ -158,9 +158,7 @@ describe('mcpToolWrapper', () => {
);
const invocation = tools[0].build({ verbose: true });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toBe('Tool result');
expect(result.error).toBeUndefined();
@@ -179,9 +177,7 @@ describe('mcpToolWrapper', () => {
);
const invocation = tools[1].build({ uid: 'invalid' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.error?.message).toBe('Element not found');
@@ -199,9 +195,7 @@ describe('mcpToolWrapper', () => {
);
const invocation = tools[0].build({});
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.error?.message).toBe('Connection lost');
@@ -218,7 +212,7 @@ describe('mcpToolWrapper', () => {
const clickTool = tools.find((t) => t.name === 'click')!;
const invocation = clickTool.build({ uid: 'elem-42' });
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
// callTool: suspend blocker + click + resume blocker
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(3);
@@ -263,7 +257,7 @@ describe('mcpToolWrapper', () => {
const snapshotTool = tools.find((t) => t.name === 'take_snapshot')!;
const invocation = snapshotTool.build({});
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
// callTool should only be called once for take_snapshot — no suspend/resume
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(1);
@@ -283,7 +277,7 @@ describe('mcpToolWrapper', () => {
const clickTool = tools.find((t) => t.name === 'click')!;
const invocation = clickTool.build({ uid: 'elem-42' });
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
// callTool should only be called once for click — no suspend/resume
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(1);
@@ -303,9 +297,7 @@ describe('mcpToolWrapper', () => {
const clickTool = tools.find((t) => t.name === 'click')!;
const invocation = clickTool.build({ uid: 'bad-elem' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
// Should return error, not throw
expect(result.error).toBeDefined();
@@ -336,9 +328,7 @@ describe('mcpToolWrapper', () => {
const uploadTool = tools.find((t) => t.name === 'upload_file')!;
const invocation = uploadTool.build({ path: 'test.txt' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('File uploads are blocked');
@@ -355,9 +345,7 @@ describe('mcpToolWrapper', () => {
const uploadTool = tools.find((t) => t.name === 'upload_file')!;
const invocation = uploadTool.build({ path: 'test.txt' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toBe('Tool result');
@@ -26,7 +26,6 @@ import {
type ToolInvocation,
type ToolCallConfirmationDetails,
type PolicyUpdateOptions,
type ExecuteOptions,
} from '../../tools/tools.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
import {
@@ -118,7 +117,7 @@ class McpToolInvocation extends BaseToolInvocation<
return this.shouldDisableInput && INTERACTIVE_TOOLS.has(this.toolName);
}
async execute({ abortSignal: signal }: ExecuteOptions): Promise<ToolResult> {
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
// Hard block for file uploads if configured
if (this.blockFileUploads && this.toolName === 'upload_file') {
@@ -110,8 +110,8 @@ export const CodebaseInvestigatorAgent = (
},
runConfig: {
maxTimeMinutes: 10,
maxTurns: 50,
maxTimeMinutes: 3,
maxTurns: 10,
},
toolConfig: {
+75 -6
View File
@@ -83,6 +83,12 @@ import {
COMPLETE_TASK_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
} from '../tools/definitions/base-declarations.js';
import {
getDynamicToolsDocumentation,
EXECUTE_FUNCTION_DECLARATION,
unwrapExecuteArgs,
wrapAsExecute,
} from '../utils/dynamicToolsUtils.js';
/** A callback function to report on agent activity. */
export type ActivityCallback = (activity: SubagentActivityEvent) => void;
@@ -114,7 +120,7 @@ export function createUnauthorizedToolError(toolName: string): string {
export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
readonly definition: LocalAgentDefinition<TOutput>;
readonly agentId: string;
private readonly agentId: string;
private readonly toolRegistry: ToolRegistry;
private readonly promptRegistry: PromptRegistry;
private readonly resourceRegistry: ResourceRegistry;
@@ -1075,7 +1081,58 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
for (const [index, functionCall] of functionCalls.entries()) {
const callId = functionCall.id ?? `${promptId}-${index}`;
const { args, error: parseError } = this.parseToolArguments(functionCall);
let { args, error: parseError } = this.parseToolArguments(functionCall);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
let effectiveToolName = functionCall.name as string;
let effectiveArgs = args;
let originalRequestName: string | undefined;
let originalRequestArgs: Record<string, unknown> | undefined;
// Handle experimental dynamic tools
if (
!parseError &&
this.context.config.getExperimentalDynamicTools()
) {
if (functionCall.name === 'execute') {
const unwrapped = unwrapExecuteArgs(functionCall.args);
if (unwrapped) {
debugLogger.log(
`[LocalAgentExecutor] Unwrapping execute call for tool: ${unwrapped.name}`,
);
effectiveToolName = unwrapped.name;
effectiveArgs = unwrapped.args;
args = unwrapped.args;
originalRequestName = 'execute';
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
originalRequestArgs = functionCall.args as Record<string, unknown>;
} else {
parseError =
'Invalid execute arguments. Expected {name: string, args: object}';
}
} else if (functionCall.name) {
// Model called a tool directly despite documentation.
// Recovery: Wrap it as an execute call internally.
debugLogger.log(
`[LocalAgentExecutor] Model called tool '${functionCall.name}' directly. Wrapping as 'execute' for experiment compatibility.`,
);
const wrapped = wrapAsExecute(functionCall.name, args);
const unwrapped = unwrapExecuteArgs(wrapped.args); // Still unwrap to get resilient parameter mapping
if (unwrapped) {
// IMPORTANT: Rewrite the original functionCall so history recording sees it as "execute"
functionCall.name = wrapped.name;
functionCall.args = wrapped.args;
effectiveToolName = unwrapped.name;
effectiveArgs = unwrapped.args;
args = unwrapped.args;
originalRequestName = 'execute';
originalRequestArgs = wrapped.args as Record<string, unknown>;
}
}
}
if (parseError) {
debugLogger.warn(`[LocalAgentExecutor] ${parseError}`);
@@ -1098,8 +1155,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolName = functionCall.name as string;
const toolName = effectiveToolName;
let displayName = toolName;
let description: string | undefined = undefined;
@@ -1130,7 +1186,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
syncResults.set(callId, {
functionResponse: {
name: toolName,
name: functionCall.name,
id: callId,
response: { error },
},
@@ -1150,7 +1206,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
toolRequests.push({
callId,
name: toolName,
args,
args: effectiveArgs,
originalRequestName,
originalRequestArgs,
isClientInitiated: false, // These are coming from the subagent (the "model")
prompt_id: promptId,
});
@@ -1286,6 +1344,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
* Prepares the list of tool function declarations to be sent to the model.
*/
private prepareToolsList(): FunctionDeclaration[] {
if (this.context.config.getExperimentalDynamicTools()) {
return [EXECUTE_FUNCTION_DECLARATION];
}
const toolsList: FunctionDeclaration[] = [];
const { toolConfig } = this.definition;
@@ -1342,6 +1403,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const dirContext = await getDirectoryContextString(this.context.config);
finalPrompt += `\n\n# Environment Context\n${dirContext}`;
if (this.context.config.getExperimentalDynamicTools()) {
const toolDeclarations = this.toolRegistry.getFunctionDeclarations(
this.definition.modelConfig.model,
);
const toolDocumentation = getDynamicToolsDocumentation(toolDeclarations);
finalPrompt += `\n\n${toolDocumentation}`;
}
// Append standard rules for non-interactive execution.
finalPrompt += `
Important Rules:
@@ -79,7 +79,6 @@ describe('LocalSubagentInvocation', () => {
mockExecutorInstance = {
run: vi.fn(),
definition: testDefinition,
agentId: 'test-agent-id',
} as unknown as Mocked<LocalAgentExecutor<z.ZodUnknown>>;
MockLocalAgentExecutor.create.mockResolvedValue(
@@ -187,10 +186,7 @@ describe('LocalSubagentInvocation', () => {
};
mockExecutorInstance.run.mockResolvedValue(mockOutput);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
const result = await invocation.execute(signal, updateOutput);
expect(MockLocalAgentExecutor.create).toHaveBeenCalledWith(
testDefinition,
@@ -227,10 +223,7 @@ describe('LocalSubagentInvocation', () => {
};
mockExecutorInstance.run.mockResolvedValue(mockOutput);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
const result = await invocation.execute(signal, updateOutput);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
@@ -260,7 +253,7 @@ describe('LocalSubagentInvocation', () => {
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
});
await invocation.execute({ abortSignal: signal, updateOutput });
await invocation.execute(signal, updateOutput);
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
@@ -299,7 +292,7 @@ describe('LocalSubagentInvocation', () => {
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
});
await invocation.execute({ abortSignal: signal, updateOutput });
await invocation.execute(signal, updateOutput);
const calls = updateOutput.mock.calls;
const lastCall = calls[calls.length - 1][0] as SubagentProgress;
@@ -332,7 +325,7 @@ describe('LocalSubagentInvocation', () => {
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
});
await invocation.execute({ abortSignal: signal, updateOutput });
await invocation.execute(signal, updateOutput);
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
@@ -366,7 +359,7 @@ describe('LocalSubagentInvocation', () => {
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
});
await invocation.execute({ abortSignal: signal, updateOutput });
await invocation.execute(signal, updateOutput);
expect(updateOutput).toHaveBeenCalled();
const lastCall = updateOutput.mock.calls[
@@ -410,7 +403,7 @@ describe('LocalSubagentInvocation', () => {
};
});
await invocation.execute({ abortSignal: signal, updateOutput });
await invocation.execute(signal, updateOutput);
expect(updateOutput).toHaveBeenCalledTimes(4);
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
@@ -439,7 +432,7 @@ describe('LocalSubagentInvocation', () => {
});
// Execute without the optional callback
const result = await invocation.execute({ abortSignal: signal });
const result = await invocation.execute(signal);
expect(result.error).toBeUndefined();
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
@@ -451,10 +444,7 @@ describe('LocalSubagentInvocation', () => {
const error = new Error('Model failed during execution.');
mockExecutorInstance.run.mockRejectedValue(error);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
const result = await invocation.execute(signal, updateOutput);
expect(result.error).toBeUndefined();
expect(result.llmContent).toBe(
@@ -475,10 +465,7 @@ describe('LocalSubagentInvocation', () => {
const creationError = new Error('Failed to initialize tools.');
MockLocalAgentExecutor.create.mockRejectedValue(creationError);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
const result = await invocation.execute(signal, updateOutput);
expect(mockExecutorInstance.run).not.toHaveBeenCalled();
expect(result.error).toBeUndefined();
@@ -499,10 +486,10 @@ describe('LocalSubagentInvocation', () => {
mockExecutorInstance.run.mockRejectedValue(abortError);
const controller = new AbortController();
const executePromise = invocation.execute({
abortSignal: controller.signal,
const executePromise = invocation.execute(
controller.signal,
updateOutput,
});
);
controller.abort();
await expect(executePromise).rejects.toThrow('Aborted');
@@ -519,9 +506,9 @@ describe('LocalSubagentInvocation', () => {
};
mockExecutorInstance.run.mockResolvedValue(mockOutput);
await expect(
invocation.execute({ abortSignal: signal, updateOutput }),
).rejects.toThrow('Operation cancelled by user');
await expect(invocation.execute(signal, updateOutput)).rejects.toThrow(
'Operation cancelled by user',
);
});
it('should publish SUBAGENT_ACTIVITY events to the MessageBus', async () => {
@@ -541,7 +528,7 @@ describe('LocalSubagentInvocation', () => {
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
});
await invocation.execute({ abortSignal: signal, updateOutput });
await invocation.execute(signal, updateOutput);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
+6 -11
View File
@@ -10,7 +10,7 @@ import { LocalAgentExecutor } from './local-executor.js';
import {
BaseToolInvocation,
type ToolResult,
type ExecuteOptions,
type ToolLiveOutput,
} from '../tools/tools.js';
import {
type LocalAgentDefinition,
@@ -25,14 +25,12 @@ import {
isToolActivityError,
} from './types.js';
import { randomUUID } from 'node:crypto';
import type { z } from 'zod';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import {
sanitizeThoughtContent,
sanitizeToolArgs,
sanitizeErrorMessage,
} from '../utils/agent-sanitization-utils.js';
import { debugLogger } from '../utils/debugLogger.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
@@ -105,10 +103,11 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
* agent's thoughts, to the user interface.
* @returns A `Promise` that resolves with the final `ToolResult`.
*/
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
async execute(
signal: AbortSignal,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
const recentActivity: SubagentActivityItem[] = [];
let executor: LocalAgentExecutor<z.ZodUnknown> | undefined;
try {
if (updateOutput) {
@@ -274,7 +273,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
}
};
executor = await LocalAgentExecutor.create(
const executor = await LocalAgentExecutor.create(
this.definition,
this.context,
onActivity,
@@ -320,14 +319,11 @@ ${output.result}`;
return {
llmContent: [{ text: resultContent }],
returnDisplay: progress,
data: { agentId: executor.agentId },
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
debugLogger.error(`Subagent '${this.definition.name}' failed:`, error);
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
@@ -373,7 +369,6 @@ ${output.result}`;
return {
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
returnDisplay: progress,
data: executor ? { agentId: executor.agentId } : undefined,
// We omit the 'error' property so that the UI renders our rich returnDisplay
// instead of the raw error message. The llmContent still informs the agent of the failure.
};
-2
View File
@@ -14,7 +14,6 @@ import { loadAgentsFromDirectory } from './agentLoader.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { CliHelpAgent } from './cli-help-agent.js';
import { GeneralistAgent } from './generalist-agent.js';
import { WatcherAgent } from './watcher-agent.js';
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
import { MemoryManagerAgent } from './memory-manager-agent.js';
import { AgentTool } from './agent-tool.js';
@@ -267,7 +266,6 @@ export class AgentRegistry {
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
this.registerLocalAgent(CliHelpAgent(this.config));
this.registerLocalAgent(GeneralistAgent(this.config));
this.registerLocalAgent(WatcherAgent(this.config));
// Register the browser agent if enabled in settings.
// Tools are configured dynamically at invocation time via browserAgentFactory.
@@ -142,7 +142,7 @@ describe('RemoteAgentInvocation', () => {
{},
mockMessageBus,
);
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
expect(mockClientManager.sendMessageStream).toHaveBeenCalledWith(
'test-agent',
@@ -185,7 +185,7 @@ describe('RemoteAgentInvocation', () => {
},
mockMessageBus,
);
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent',
@@ -230,7 +230,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
authConfig: mockAuth,
@@ -264,9 +264,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
@@ -295,7 +293,7 @@ describe('RemoteAgentInvocation', () => {
},
mockMessageBus,
);
await invocation.execute({ abortSignal: new AbortController().signal });
await invocation.execute(new AbortController().signal);
expect(mockClientManager.loadAgent).not.toHaveBeenCalled();
});
@@ -327,9 +325,7 @@ describe('RemoteAgentInvocation', () => {
);
// Execute first time
const result1 = await invocation1.execute({
abortSignal: new AbortController().signal,
});
const result1 = await invocation1.execute(new AbortController().signal);
expect(result1.returnDisplay).toMatchObject({
result: 'Response 1',
});
@@ -361,9 +357,7 @@ describe('RemoteAgentInvocation', () => {
},
mockMessageBus,
);
const result2 = await invocation2.execute({
abortSignal: new AbortController().signal,
});
const result2 = await invocation2.execute(new AbortController().signal);
expect((result2.returnDisplay as SubagentProgress).result).toBe(
'Response 2',
);
@@ -396,7 +390,7 @@ describe('RemoteAgentInvocation', () => {
},
mockMessageBus,
);
await invocation3.execute({ abortSignal: new AbortController().signal });
await invocation3.execute(new AbortController().signal);
// Fourth call: Should start new task (taskId undefined)
mockClientManager.sendMessageStream.mockImplementationOnce(
@@ -418,7 +412,7 @@ describe('RemoteAgentInvocation', () => {
},
mockMessageBus,
);
await invocation4.execute({ abortSignal: new AbortController().signal });
await invocation4.execute(new AbortController().signal);
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
'test-agent',
@@ -453,10 +447,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
await invocation.execute(new AbortController().signal, updateOutput);
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
@@ -504,9 +495,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: controller.signal,
});
const result = await invocation.execute(controller.signal);
expect(result.returnDisplay).toMatchObject({ state: 'error' });
});
@@ -528,9 +517,7 @@ describe('RemoteAgentInvocation', () => {
},
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toMatchObject({
state: 'error',
@@ -563,9 +550,7 @@ describe('RemoteAgentInvocation', () => {
},
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
// Just check that text is present, exact formatting depends on helper
expect((result.returnDisplay as SubagentProgress).result).toContain(
@@ -608,10 +593,10 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
const result = await invocation.execute(
new AbortController().signal,
updateOutput,
});
);
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
@@ -685,10 +670,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
await invocation.execute(new AbortController().signal, updateOutput);
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
@@ -756,9 +738,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
@@ -778,9 +758,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
@@ -809,9 +787,7 @@ describe('RemoteAgentInvocation', () => {
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toMatchObject({ state: 'error' });
// Should contain both the partial output and the error message
@@ -9,7 +9,6 @@ import {
type ToolConfirmationOutcome,
type ToolResult,
type ToolCallConfirmationDetails,
type ExecuteOptions,
} from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
@@ -29,6 +28,7 @@ import type {
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import { A2AAgentError } from './a2a-errors.js';
@@ -126,8 +126,10 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
};
}
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: _signal, updateOutput } = options;
async execute(
_signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput | SubagentProgress) => void,
): Promise<ToolResult> {
// 1. Ensure the agent is loaded (cached by manager)
// We assume the user has provided an access token via some mechanism (TODO),
// or we rely on ADC.
-7
View File
@@ -105,13 +105,6 @@ export interface SubagentProgress {
terminateReason?: AgentTerminateMode;
}
export interface WatcherProgress {
primaryUserGoal: string;
progressSummary: string;
evaluation: string;
feedback?: string;
}
export function isSubagentProgress(obj: unknown): obj is SubagentProgress {
return (
typeof obj === 'object' &&
@@ -1,50 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WatcherAgent } from './watcher-agent.js';
import { makeFakeConfig } from '../test-utils/config.js';
import * as path from 'node:path';
describe('WatcherAgent', () => {
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should create a valid watcher agent definition', () => {
const config = makeFakeConfig();
const projectTempDir = '/tmp/project';
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
projectTempDir,
);
Object.defineProperty(config, 'config', {
get() {
return this;
},
});
const agent = WatcherAgent(config);
expect(agent.name).toBe('watcher');
expect(agent.kind).toBe('local');
expect(agent.description).toContain('monitors the progress');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((agent.inputConfig.inputSchema as any).properties).toHaveProperty(
'recentHistory',
);
expect(agent.outputConfig?.outputName).toBe('report');
const statusFilePath = path.join(projectTempDir, 'watcher_status.md');
expect(agent.promptConfig.systemPrompt).toContain(statusFilePath);
expect(agent.promptConfig.query).toContain(statusFilePath);
});
});
-155
View File
@@ -1,155 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { LocalAgentDefinition } from './types.js';
import { READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js';
import * as path from 'node:path';
export const WatcherReportSchema = z.object({
primaryUserGoal: z
.string()
.describe(
'High level user directions/redirections and any change of plans.',
),
progressSummary: z
.string()
.describe('Concise summary of the progress made by the agent.'),
evaluation: z
.string()
.describe(
'Evaluation of whether the agent is going in the right direction.',
),
feedback: z
.string()
.optional()
.describe('Feedback to the main agent if necessary.'),
});
/**
* Watcher subagent specialized in monitoring the main agent's progress and direction.
*/
export const WatcherAgent = (
context: AgentLoopContext,
): LocalAgentDefinition<typeof WatcherReportSchema> => {
const projectTempDir = context.config.storage.getProjectTempDir();
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
return {
name: 'watcher',
kind: 'local',
displayName: 'Watcher Agent',
description:
'Specialized agent that monitors the progress and direction of the main agent.',
inputConfig: {
inputSchema: {
type: 'object',
properties: {
recentHistory: {
type: 'string',
description:
'The transcript of the most recent turns of the conversation.',
},
},
required: ['recentHistory'],
},
},
outputConfig: {
outputName: 'report',
description: 'The progress report and evaluation.',
schema: WatcherReportSchema,
},
processOutput: (output) => JSON.stringify(output, null, 2),
modelConfig: {
model: GEMINI_MODEL_ALIAS_FLASH,
generateContentConfig: {
temperature: 1.0,
topP: 0.95,
},
},
runConfig: {
maxTimeMinutes: 2,
maxTurns: 5,
},
toolConfig: {
tools: [READ_FILE_TOOL_NAME],
},
promptConfig: {
query: `Analyze the recent conversation history and update the progress status.
Status file path: ${statusFilePath}
<recent_history>
\${recentHistory}
</recent_history>`,
systemPrompt: `You are **Watcher**, a highly analytical, objective overseer sub-agent in a coding agent harness. Your main purpose is to
* ensure the main execution agent stays rigidly focused on the user's overarching goal,
* avoids cognitive loops,
* learns from failed strategies during complex, multi-step tasks,
* maintains the correct scope; and
* prevents unbounded token waste by "failing fast" when stuck.
You do not write code. You monitor, evaluate, and course-correct.
### Core Directives:
#### 1. Horizon Detection & Context Awareness (Triage)
Not every interaction requires oversight, but you must be deeply aware of the current context before wiping anything in the status file.
* **Standalone Short Requests:** If the user starts a session with a simple, isolated question (e.g., "How do I reverse a string in Python?", "Fix the typo on line 42") and there is *no active long-horizon task*, this tracking paradigm is unnecessary. Set the status file to empty.
* **Tactical Asks within a Macro Task (DO NOT PURGE):** **CRITICAL:** If a long-horizon task is *already underway* (i.e., a status file exists with an active North Star), do NOT wipe the file just because the user asks a quick tactical question (e.g., "Why did that command fail?", "Wait, print that variable for me"). These are micro-steps within the macro-task. You must maintain the file and keep tracking the main goal.
#### 2. The North Star & Strategic Intent
Maintain the definitive statement of the user's ultimate goal and *how* they want to achieve it.
* **Determine Intent (Bias Towards Action):** Understand what the user wants to accomplish. Guage whether the user wants brainstorm a design, fix a bug or implement something. Depending on the established intent, provide feedback to the main agent if it's going off-track.
* **Strategic vs. Tactical:** ONLY update the main goal if the user issues a *strategic pivot* within the current task (e.g., "Let's use Python instead of Rust"). Ignore tactical chatter.
* **Task Transitions & Abandonment:** You must only **PURGE** the status file and start fresh IF the user explicitly moves on to a completely new macro-task (e.g., "Great, the API is done. Now let's write a deployment script") OR explicitly aborts the current task (e.g., "Actually, forget about this feature entirely, let's do something else").
#### 3. The Map (Progress & Dead Ends)
For long-horizon tasks, maintain a living snapshot of the project state.
* **Completed Milestones:** What features/fixes are verifiably complete?
* **Failed Strategies (Crucial):** Explicitly track approaches that have *failed*. If the main agent tried a specific library, regex, or architectural pattern and it caused errors, record it so the agent doesn't repeat the mistake. **Monitor the length of this list closely.**
#### 4. The Compass (Evaluation & Intervention)
Analyze the recent history against the North Star. Actively look for anti-patterns:
* **Exhaustion / Token Churn (Fail Fast):** The agent has tried multiple distinct strategies (listed in Dead Ends) and all have failed. It is churning through tokens without progress. It must stop.
* **Strategic Deviation & Scope Creep:**
* *Premature Implementation:* The user explicitly requested a plan/discussion, but the agent is modifying files.
* *Destructive Debugging:* The goal is to fix a bug or pass a test, but the agent is blindly rewriting entire functions or altering core business logic instead of adding logs to find the root cause.
* **Cognitive Looping:** Repeatedly applying the same fix, reverting, or trying the exact same logic that just failed.
* **Rabbit-Holing / Hyper-fixation:** Spending excessive turns fixing irrelevant tests or deep dependencies instead of the primary task.
* **Goal Amnesia:** The agent finished a sub-task but forgot the overarching goal, idling or doing unprompted work.
### Standard Operating Procedure:
1. **READ & TRIAGE:** Read the user's prompt, recent history, and the existing memory file at \`${statusFilePath}\`. Determine the state: Standalone Short-Horizon, Active Long-Horizon, or Task Transition.
2. **ANALYZE:**
* *If Standalone Short-Horizon (No active macro-task):* Note that the status should be empty.
* *If Task Transition / Abort:* Purge old data, initialize a fresh state for the new task, or leave empty if no new task is given.
* *If Active Long-Horizon (Even if current turn is a tactical question):* Compare history against the file, update progress, log dead ends, and track trajectory.
3. **REPORT:** Call the \`complete_task\` tool with the updated state and sharp, direct feedback to snap the main agent out of loops, or stay silent if things are on track.
---
### Output JSON Format (Provide this to \`complete_task\`):
*(Note: If this is a Standalone Short-Horizon task with no ongoing goal, just set all fields to "EMPTY" or "N/A" and omit feedback.)*
* \`primaryUserGoal\`: A concise, 1-2 sentence strictly HIGH LEVEL GOAL. Any parsed _strategic_ changes, or note if the user transitioned/aborted tasks.
* \`progressSummary\`: Brief text of what was achieved, or "N/A" for short-horizon. Use bullet points.
* \`evaluation\`: "ON_TRACK", "DEVIATING", "STUCK", "LOOPING", or "NOT_APPLICABLE".
* \`feedback\`: **CRITICAL: THIS IS THE ONLY FIELD INJECTED INTO THE AGENT'S CHAT HISTORY.**
* **If Short-Horizon or ON_TRACK**: You MUST leave this field empty.
* **If DEVIATING/STUCK/LOOPING**: Provide a strong, authoritative directive to the main agent. (e.g., _"WARNING: You are hyper fixating on fixing test_utils.py and looping as a result. The original goal is to build the API endpoint. Revert your last change, ignore the test warning for now, and return to the API endpoint."_). If the agent is truly stuck, provide the directive to stop unless the agent can get new information.
You MUST call \`complete_task\` with a JSON report containing \`primaryUserGoal\`, \`progressSummary\`, \`evaluation\`, and optional \`feedback\`.`,
},
};
};
-119
View File
@@ -304,53 +304,6 @@ describe('Server Config (config.ts)', () => {
});
});
describe('setShellExecutionConfig', () => {
it('should preserve existing shell execution fields that are not being updated', () => {
const config = new Config({
...baseParams,
sandbox: {
enabled: true,
command: 'windows-native',
networkAccess: false,
},
shellBackgroundCompletionBehavior: 'notify',
});
expect(config.getShellExecutionConfig()).toEqual(
expect.objectContaining({
sandboxConfig: expect.objectContaining({
enabled: true,
command: 'windows-native',
networkAccess: false,
}),
backgroundCompletionBehavior: 'notify',
}),
);
config.setShellExecutionConfig({
terminalWidth: 123,
terminalHeight: 45,
showColor: true,
pager: 'cat',
sanitizationConfig: config.sanitizationConfig,
sandboxManager: config.sandboxManager,
});
expect(config.getShellExecutionConfig()).toEqual(
expect.objectContaining({
terminalWidth: 123,
terminalHeight: 45,
sandboxConfig: expect.objectContaining({
enabled: true,
command: 'windows-native',
networkAccess: false,
}),
backgroundCompletionBehavior: 'notify',
}),
);
});
});
beforeEach(() => {
// Reset mocks if necessary
vi.clearAllMocks();
@@ -3006,78 +2959,6 @@ describe('Config Quota & Preview Model Access', () => {
// Never set => stays null (unknown); getter returns true so UI shows preview
expect(config.getHasAccessToPreviewModel()).toBe(true);
});
it('should derive quota from remainingFraction when remainingAmount is missing', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3-flash-preview',
remainingFraction: 0.96,
},
],
});
config.setModel('gemini-3-flash-preview');
mockCoreEvents.emitQuotaChanged.mockClear();
await config.refreshUserQuota();
// Normalized: limit=100, remaining=96
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
96,
100,
undefined,
);
expect(config.getQuotaRemaining()).toBe(96);
expect(config.getQuotaLimit()).toBe(100);
});
it('should store quota from remainingFraction when remainingFraction is 0', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3-pro-preview',
remainingFraction: 0,
},
],
});
config.setModel('gemini-3-pro-preview');
mockCoreEvents.emitQuotaChanged.mockClear();
await config.refreshUserQuota();
// remaining=0, limit=100 but limit>0 check still passes
// however remaining=0 means 0% remaining = 100% used
expect(config.getQuotaRemaining()).toBe(0);
expect(config.getQuotaLimit()).toBe(100);
});
it('should emit QuotaChanged when model is switched via setModel', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-2.5-pro',
remainingAmount: '10',
remainingFraction: 0.2,
},
{
modelId: 'gemini-2.5-flash',
remainingAmount: '80',
remainingFraction: 0.8,
},
],
});
config.setModel('auto-gemini-2.5');
await config.refreshUserQuota();
mockCoreEvents.emitQuotaChanged.mockClear();
// Switch to a specific model — should re-emit quota for that model
config.setModel('gemini-2.5-pro');
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
10,
50,
undefined,
);
});
});
describe('refreshUserQuotaIfStale', () => {
+34 -52
View File
@@ -44,7 +44,6 @@ import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool } from '../tools/topicTool.js';
import { TopicState } from './topicState.js';
import { AgentTool } from '../agents/agent-tool.js';
import { WatcherTool } from '../tools/watcherTool.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import {
@@ -700,12 +699,11 @@ export interface ConfigParameters {
experimentalJitContext?: boolean;
autoDistillation?: boolean;
experimentalMemoryManager?: boolean;
experimentalDynamicTools?: boolean;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
experimentalAgentHistorySummarization?: boolean;
experimentalWatcher?: boolean;
experimentalWatcherInterval?: number;
memoryBoundaryMarkers?: string[];
topicUpdateNarration?: boolean;
@@ -835,16 +833,18 @@ export class Config implements McpContext, AgentLoopContext {
private lastEmittedQuotaLimit: number | undefined;
private emitQuotaChangedEvent(): void {
const remaining = this.getQuotaRemaining();
const limit = this.getQuotaLimit();
const resetTime = this.getQuotaResetTime();
const pooled = this.getPooledQuota();
if (
this.lastEmittedQuotaRemaining !== remaining ||
this.lastEmittedQuotaLimit !== limit
this.lastEmittedQuotaRemaining !== pooled.remaining ||
this.lastEmittedQuotaLimit !== pooled.limit
) {
this.lastEmittedQuotaRemaining = remaining;
this.lastEmittedQuotaLimit = limit;
coreEvents.emitQuotaChanged(remaining, limit, resetTime);
this.lastEmittedQuotaRemaining = pooled.remaining;
this.lastEmittedQuotaLimit = pooled.limit;
coreEvents.emitQuotaChanged(
pooled.remaining,
pooled.limit,
pooled.resetTime,
);
}
}
@@ -942,8 +942,7 @@ 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 experimentalDynamicTools: boolean;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean;
private readonly disableLLMCorrection: boolean;
@@ -1155,8 +1154,12 @@ 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.experimentalDynamicTools =
params.experimentalDynamicTools === true ||
process.env['GEMINI_CLI_EXP_DYNAMIC_TOOLS'] === 'true';
if (this.experimentalDynamicTools) {
debugLogger.log('[Config] Experimental Dynamic Tools enabled.');
}
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
this.contextManagement = {
enabled: params.contextManagement?.enabled ?? false,
@@ -1824,9 +1827,6 @@ export class Config implements McpContext, AgentLoopContext {
// When the user explicitly sets a model, that becomes the active model.
this._activeModel = newModel;
coreEvents.emitModelChanged(newModel);
this.lastEmittedQuotaRemaining = undefined;
this.lastEmittedQuotaLimit = undefined;
this.emitQuotaChangedEvent();
}
if (this.onModelChange && !isTemporary) {
this.onModelChange(newModel);
@@ -2120,31 +2120,24 @@ export class Config implements McpContext, AgentLoopContext {
this.lastQuotaFetchTime = Date.now();
for (const bucket of quota.buckets) {
if (!bucket.modelId || bucket.remainingFraction == null) {
continue;
}
let remaining: number;
let limit: number;
if (bucket.remainingAmount) {
remaining = parseInt(bucket.remainingAmount, 10);
limit =
if (
bucket.modelId &&
bucket.remainingAmount &&
bucket.remainingFraction != null
) {
const remaining = parseInt(bucket.remainingAmount, 10);
const limit =
bucket.remainingFraction > 0
? Math.round(remaining / bucket.remainingFraction)
: (this.modelQuotas.get(bucket.modelId)?.limit ?? 0);
} else {
// Server only sent remainingFraction — use a normalized scale.
limit = 100;
remaining = Math.round(bucket.remainingFraction * limit);
}
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
}
}
}
this.emitQuotaChangedEvent();
@@ -2441,12 +2434,8 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalMemoryManager;
}
isExperimentalWatcherEnabled(): boolean {
return this.experimentalWatcher;
}
getExperimentalWatcherInterval(): number {
return this.experimentalWatcherInterval;
getExperimentalDynamicTools(): boolean {
return this.experimentalDynamicTools;
}
getContextManagementConfig(): ContextManagementConfig {
@@ -3357,7 +3346,6 @@ export class Config implements McpContext, AgentLoopContext {
setShellExecutionConfig(config: ShellExecutionConfig): void {
this.shellExecutionConfig = {
...this.shellExecutionConfig,
terminalWidth:
config.terminalWidth ?? this.shellExecutionConfig.terminalWidth,
terminalHeight:
@@ -3651,12 +3639,6 @@ export class Config implements McpContext, AgentLoopContext {
registry.registerTool(new AgentTool(this, this.messageBus)),
);
if (this.isExperimentalWatcherEnabled()) {
maybeRegister(WatcherTool, () =>
registry.registerTool(new WatcherTool(this, this.messageBus)),
);
}
await registry.discoverAllTools();
registry.sortTools();
return registry;
@@ -762,11 +762,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -913,11 +908,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -1046,11 +1036,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -1703,11 +1688,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -1867,11 +1847,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -2035,11 +2010,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -2203,11 +2173,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -2367,11 +2332,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -2533,11 +2493,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -2658,11 +2613,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -2821,11 +2771,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -3112,11 +3057,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -3533,11 +3473,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -3697,11 +3632,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -3975,11 +3905,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -4139,11 +4064,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
+16 -190
View File
@@ -12,8 +12,6 @@ import {
type Tool,
type GenerateContentResponse,
} from '@google/genai';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { partListUnionToString } from './geminiRequest.js';
import {
getDirectoryContextString,
@@ -29,6 +27,7 @@ import {
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { getCoreSystemPrompt } from './prompts.js';
import { EXECUTE_FUNCTION_DECLARATION } from '../utils/dynamicToolsUtils.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
@@ -47,9 +46,6 @@ import type { ContentGenerator } from './contentGenerator.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ChatCompressionService } from '../context/chatCompressionService.js';
import { AgentHistoryProvider } from '../context/agentHistoryProvider.js';
import { isSubagentProgress, type WatcherProgress } from '../agents/types.js';
import { extractAndParseJson } from '../utils/jsonUtils.js';
import { WatcherReportSchema } from '../agents/watcher-agent.js';
import { ideContextStore } from '../ide/ideContext.js';
import {
logContentRetryFailure,
@@ -306,12 +302,20 @@ export class GeminiClient {
}
this.lastUsedModelId = modelId;
const toolRegistry = this.context.toolRegistry;
const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId);
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
const tools = this.getChatTools(modelId);
this.getChat().setTools(tools);
}
private getChatTools(modelId?: string): Tool[] {
const toolRegistry = this.context.toolRegistry;
if (this.config.getExperimentalDynamicTools()) {
debugLogger.log('[GeminiClient] Experimental Dynamic Tools enabled.');
return [{ functionDeclarations: [EXECUTE_FUNCTION_DECLARATION] }];
}
const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId);
return [{ functionDeclarations: toolDeclarations }];
}
async resetChat(): Promise<void> {
this.chat = await this.startChat();
this.updateTelemetryTokenCount();
@@ -323,20 +327,6 @@ export class GeminiClient {
dispose() {
coreEvents.off(CoreEvent.ModelChanged, this.handleModelChanged);
coreEvents.off(CoreEvent.MemoryChanged, this.handleMemoryChanged);
// Clean up Watcher status file
try {
const projectTempDir = this.config.storage.getProjectTempDir();
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
if (fs.existsSync(statusFilePath)) {
fs.unlinkSync(statusFilePath);
}
} catch (e) {
debugLogger.warn(
'Failed to clean up watcher status file during dispose',
e,
);
}
}
async resumeChat(
@@ -388,9 +378,7 @@ export class GeminiClient {
this.hasFailedCompressionAttempt = false;
this.lastUsedModelId = undefined;
const toolRegistry = this.context.toolRegistry;
const toolDeclarations = toolRegistry.getFunctionDeclarations();
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
const tools = this.getChatTools();
const history = await getInitialChatHistory(this.config, extraHistory);
@@ -405,10 +393,7 @@ export class GeminiClient {
resumedSessionData,
async (modelId: string) => {
this.lastUsedModelId = modelId;
const toolRegistry = this.context.toolRegistry;
const toolDeclarations =
toolRegistry.getFunctionDeclarations(modelId);
return [{ functionDeclarations: toolDeclarations }];
return this.getChatTools(modelId);
},
);
await chat.initialize(resumedSessionData, 'main');
@@ -617,8 +602,10 @@ export class GeminiClient {
isInvalidStreamRetry: boolean,
displayContent?: PartListUnion,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
// Re-initialize turn (it was empty before if in loop, or new instance)
let turn = new Turn(this.getChat(), prompt_id);
this.sessionTurnCount++;
if (
this.config.getMaxSessionTurns() > 0 &&
this.sessionTurnCount > this.config.getMaxSessionTurns()
@@ -894,7 +881,6 @@ export class GeminiClient {
}
}
}
return turn;
}
@@ -909,7 +895,6 @@ export class GeminiClient {
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
if (!isInvalidStreamRetry) {
this.config.resetTurn();
this.sessionTurnCount++;
}
const hooksEnabled = this.config.getEnableHooks();
@@ -1052,41 +1037,6 @@ export class GeminiClient {
}
}
}
// Trigger Watcher after the full interaction (including tool recursions) is complete.
// But only if we are at the top-level sendMessageStream (not a continuation).
if (!continuationHandled && !isInvalidStreamRetry && !stopHookActive) {
const watcherInterval = this.config.getExperimentalWatcherInterval();
const currentTurn = this.sessionTurnCount;
debugLogger.log(
`[Watcher] Checking if trigger is needed at turn ${currentTurn}`,
);
if (
this.config.isExperimentalWatcherEnabled() &&
currentTurn > 0 &&
(currentTurn === 1 || currentTurn % watcherInterval === 0)
) {
debugLogger.log(
`[Watcher] Triggering subagent at turn ${currentTurn}`,
);
const watcherResult = await this.tryRunWatcher(prompt_id, signal);
if (watcherResult?.feedback) {
debugLogger.log(
`[Watcher] Feedback provided: ${watcherResult.feedback}`,
);
const feedback = watcherResult.feedback;
const feedbackRequest = [
{
text: `System: EXTREMELY IMPORTANT Feedback from Watcher Sub Agent based on recent progress (Review of last ${watcherInterval} turns):\n\n${feedback}`,
},
];
// Inject feedback into the conversation for the NEXT turn
this.getChat().addHistory(createUserContent(feedbackRequest));
} else {
debugLogger.log('[Watcher] No feedback provided.');
}
}
}
}
return turn;
@@ -1333,128 +1283,4 @@ export class GeminiClient {
displayContent,
);
}
private async tryRunWatcher(
prompt_id: string,
signal: AbortSignal,
): Promise<WatcherProgress | undefined> {
const watcherTool = this.context.toolRegistry.getTool('watcher');
if (!watcherTool) {
return undefined;
}
const interval = this.config.getExperimentalWatcherInterval();
const projectTempDir = this.config.storage.getProjectTempDir();
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
// Ensure the file exists before the subagent tries to read it
if (!fs.existsSync(statusFilePath)) {
try {
if (!fs.existsSync(projectTempDir)) {
fs.mkdirSync(projectTempDir, { recursive: true });
}
fs.writeFileSync(statusFilePath, 'EMPTY', 'utf-8');
} catch (e) {
debugLogger.warn('Failed to initialize watcher status file', e);
}
}
const history = this.getHistory();
// Get last N turns (approx)
const recentHistory = history
.slice(-interval * 2)
.map((m) => {
const role = m.role ?? 'unknown';
const parts =
m.parts
?.map((p) => {
if (typeof p === 'string') return p;
if (p && typeof p === 'object') {
if ('text' in p && typeof p.text === 'string') return p.text;
if (
'functionCall' in p &&
p.functionCall &&
typeof p.functionCall === 'object' &&
'name' in p.functionCall &&
'args' in p.functionCall
) {
return `[CALL: ${String(p.functionCall.name)}(${JSON.stringify(p.functionCall.args)})]`;
}
if (
'functionResponse' in p &&
p.functionResponse &&
typeof p.functionResponse === 'object' &&
'name' in p.functionResponse &&
'response' in p.functionResponse
) {
return `[RESULT: ${String(p.functionResponse.name)} -> ${JSON.stringify(p.functionResponse.response)}]`;
}
}
return partToString(p, { verbose: true });
})
.join('\n') ?? '';
return `[${role.toUpperCase()}]: ${parts}`;
})
.join('\n\n');
try {
debugLogger.log('[Watcher] Executing subagent...');
const invocation = watcherTool.build({ recentHistory });
const result = await invocation.execute({ abortSignal: signal });
if (
isSubagentProgress(result.returnDisplay) &&
result.returnDisplay.result
) {
try {
const rawOutput = result.returnDisplay.result;
debugLogger.log(`[Watcher] Raw content response: ${rawOutput}`);
const parsed = WatcherReportSchema.parse(
extractAndParseJson(rawOutput),
);
// Internally write the status report to avoid requiring user permission
const projectTempDir = this.config.storage.getProjectTempDir();
const statusFilePath = path.join(
projectTempDir,
'.sys_state_cache.log',
);
debugLogger.log(
`[Watcher] Writing status report to ${statusFilePath}`,
);
const reportLines = [
'# Watcher Memory State',
'',
'## 1. Primary User Goal',
parsed.primaryUserGoal,
'',
'## 2. Progress Summary',
parsed.progressSummary,
'',
'## 3. Current Trajectory Evaluation',
`State: ${parsed.evaluation}`,
'',
'## 4. Feedback',
parsed.feedback ?? 'N/A',
];
fs.writeFileSync(statusFilePath, reportLines.join('\n'), 'utf-8');
debugLogger.log('[Watcher] Subagent execution complete.');
return parsed as WatcherProgress;
} catch (e) {
debugLogger.warn('Failed to parse watcher output', e);
return undefined;
}
} else {
debugLogger.warn(
'[Watcher] Subagent did not return structured result in returnDisplay',
);
}
} catch (e) {
debugLogger.warn('Error running watcher subagent', e);
}
return undefined;
}
}
@@ -1,362 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GeminiClient } from './client.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ApprovalMode } from '../policy/types.js';
import type { Config } from '../config/config.js';
describe('GeminiClient Watcher Integration', () => {
let config: Config;
let client: GeminiClient;
let mockContentGenerator: {
countTokens: ReturnType<typeof vi.fn>;
generateContentStream: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
config = makeFakeConfig();
mockContentGenerator = {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 10 }),
generateContentStream: vi.fn().mockReturnValue({
stream: (async function* () {
yield {
response: {
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
},
};
})(),
}),
};
vi.spyOn(config, 'getContentGenerator').mockReturnValue(
mockContentGenerator as unknown as ReturnType<
typeof config.getContentGenerator
>,
);
client = new GeminiClient(config as unknown as AgentLoopContext);
});
afterEach(() => {
vi.unstubAllEnvs();
const projectTempDir = config.storage.getProjectTempDir();
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
if (fs.existsSync(statusFilePath)) {
fs.unlinkSync(statusFilePath);
}
});
const createMockWatcherTool = (resultData: unknown) => ({
build: vi.fn().mockReturnValue({
execute: vi.fn().mockResolvedValue({
llmContent: [{ text: 'Subagent finished' }],
returnDisplay: {
isSubagentProgress: true,
agentName: 'watcher',
recentActivity: [],
state: 'completed',
result: resultData ? JSON.stringify(resultData) : undefined,
},
}),
}),
name: 'watcher',
displayName: 'Watcher',
description: 'Watcher tool',
inputConfig: {
inputSchema: {},
},
outputConfig: {
outputName: 'report',
schema: {},
},
});
it('should trigger watcher periodically when enabled', async () => {
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(2);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
const mockWatcherTool = createMockWatcherTool({
primaryUserGoal: 'Keep testing',
progressSummary: 'Test in progress',
evaluation: 'Good',
feedback: 'Keep going',
});
const mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getTool: vi.fn().mockImplementation((name) => {
if (name === 'watcher') return mockWatcherTool;
return undefined;
}),
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
sortTools: vi.fn(),
discoverAllTools: vi.fn(),
};
const clientAccess = client as unknown as {
context: AgentLoopContext;
};
Object.defineProperty(clientAccess.context, 'toolRegistry', {
get: () => mockToolRegistry,
configurable: true,
});
(
clientAccess.context as unknown as { agentRegistry: unknown }
).agentRegistry = {
getAllDefinitions: vi.fn().mockReturnValue([]),
getDefinition: vi.fn().mockReturnValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
};
await config.storage.initialize();
await client.initialize();
const promptId = 'test-prompt';
const signal = new AbortController().signal;
const generator = client.sendMessageStream(
[{ text: 'test' }],
signal,
promptId,
);
for await (const _ of generator) {
// consume
}
expect(mockWatcherTool.build).toHaveBeenCalled();
});
it('should NOT trigger watcher when NOT enabled', async () => {
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(false);
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(2);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
const mockWatcherTool = {
build: vi.fn(),
name: 'watcher',
};
const mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getTool: vi.fn().mockImplementation((name) => {
if (name === 'watcher') return mockWatcherTool;
return undefined;
}),
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
sortTools: vi.fn(),
discoverAllTools: vi.fn(),
};
const clientAccess = client as unknown as {
context: AgentLoopContext;
};
Object.defineProperty(clientAccess.context, 'toolRegistry', {
get: () => mockToolRegistry,
configurable: true,
});
(
clientAccess.context as unknown as { agentRegistry: unknown }
).agentRegistry = {
getAllDefinitions: vi.fn().mockReturnValue([]),
getDefinition: vi.fn().mockReturnValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
};
await config.storage.initialize();
await client.initialize();
const promptId = 'test-prompt';
const signal = new AbortController().signal;
const generator = client.sendMessageStream(
[{ text: 'test' }],
signal,
promptId,
);
for await (const _ of generator) {
// consume
}
expect(mockWatcherTool.build).not.toHaveBeenCalled();
});
it('should trigger watcher multiple times in a long conversation and update status file', async () => {
const interval = 5;
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(
interval,
);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
const mockWatcherTool = createMockWatcherTool({
primaryUserGoal: 'Keep testing',
progressSummary: 'Test in progress',
evaluation: 'Good',
feedback: 'Keep going',
});
const mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getTool: vi.fn().mockImplementation((name) => {
if (name === 'watcher') return mockWatcherTool;
return undefined;
}),
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
sortTools: vi.fn(),
discoverAllTools: vi.fn(),
};
const clientAccess = client as unknown as {
context: AgentLoopContext;
};
Object.defineProperty(clientAccess.context, 'toolRegistry', {
get: () => mockToolRegistry,
configurable: true,
});
(
clientAccess.context as unknown as { agentRegistry: unknown }
).agentRegistry = {
getAllDefinitions: vi.fn().mockReturnValue([]),
getDefinition: vi.fn().mockReturnValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
};
await config.storage.initialize();
await client.initialize();
const signal = new AbortController().signal;
// Simulate 11 turns
for (let i = 1; i <= 11; i++) {
const promptId = `test-prompt-${i}`;
const generator = client.sendMessageStream(
[{ text: `turn ${i}` }],
signal,
promptId,
);
for await (const _ of generator) {
// consume
}
}
// With interval 5, it should trigger at turn 1, turn 5 and turn 10
expect(mockWatcherTool.build).toHaveBeenCalledTimes(3);
// Verify the status file exists (written by GeminiClient internally)
const projectTempDir = config.storage.getProjectTempDir();
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
expect(fs.existsSync(statusFilePath)).toBe(true);
const content = fs.readFileSync(statusFilePath, 'utf-8');
expect(content).toContain('# Watcher Memory State');
expect(content).toContain('Keep testing');
// Verify cleanup in dispose
client.dispose();
expect(fs.existsSync(statusFilePath)).toBe(false);
});
it('should robustly handle messy subagent output with conversational filler and markdown', async () => {
vi.spyOn(config, 'isExperimentalWatcherEnabled').mockReturnValue(true);
vi.spyOn(config, 'getExperimentalWatcherInterval').mockReturnValue(1);
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.DEFAULT);
const reportData = {
primaryUserGoal: 'Messy test direction',
progressSummary: 'Messy progress',
evaluation: 'ON_TRACK',
};
const messyOutput = `
Subagent "watcher" finished with result:
\`\`\`json
${JSON.stringify(reportData, null, 2)}
\`\`\`
I hope this status update is helpful!
`;
const mockWatcherTool = {
build: vi.fn().mockReturnValue({
execute: vi.fn().mockResolvedValue({
llmContent: [{ text: 'Subagent finished' }],
returnDisplay: {
isSubagentProgress: true,
agentName: 'watcher',
recentActivity: [],
state: 'completed',
result: messyOutput,
},
}),
}),
name: 'watcher',
displayName: 'Watcher',
description: 'Watcher tool',
inputConfig: { inputSchema: {} },
outputConfig: { outputName: 'report', schema: {} },
};
const mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getTool: vi.fn().mockImplementation((name) => {
if (name === 'watcher') return mockWatcherTool;
return undefined;
}),
getAllToolNames: vi.fn().mockReturnValue(['watcher']),
sortTools: vi.fn(),
discoverAllTools: vi.fn(),
};
const clientAccess = client as unknown as {
context: AgentLoopContext;
};
Object.defineProperty(clientAccess.context, 'toolRegistry', {
get: () => mockToolRegistry,
configurable: true,
});
(
clientAccess.context as unknown as { agentRegistry: unknown }
).agentRegistry = {
getAllDefinitions: vi.fn().mockReturnValue([]),
getDefinition: vi.fn().mockReturnValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
};
await config.storage.initialize();
await client.initialize();
const signal = new AbortController().signal;
const generator = client.sendMessageStream(
[{ text: 'test turn' }],
signal,
'test-prompt',
);
for await (const _ of generator) {
/* consume */
}
const projectTempDir = config.storage.getProjectTempDir();
const statusFilePath = path.join(projectTempDir, '.sys_state_cache.log');
expect(fs.existsSync(statusFilePath)).toBe(true);
const content = fs.readFileSync(statusFilePath, 'utf-8');
expect(content).toContain('Messy test direction');
expect(content).toContain('Messy progress');
});
});
@@ -11,7 +11,7 @@ import {
BaseToolInvocation,
type ToolResult,
type AnyDeclarativeTool,
type ExecuteOptions,
type ToolLiveOutput,
} from '../tools/tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { HookSystem } from '../hooks/hookSystem.js';
@@ -46,7 +46,11 @@ class MockBackgroundableInvocation extends BaseToolInvocation<
getDescription() {
return 'mock-pid';
}
async execute(options: ExecuteOptions) {
async execute(
_signal: AbortSignal,
_updateOutput?: (output: ToolLiveOutput) => void,
options?: { setExecutionIdCallback?: (executionId: number) => void },
) {
options?.setExecutionIdCallback?.(4242);
return {
llmContent: 'pid',
@@ -71,7 +71,7 @@ export async function executeToolWithHooks(
signal: AbortSignal,
tool: AnyDeclarativeTool,
liveOutputCallback?: (outputChunk: ToolLiveOutput) => void,
options?: Omit<ExecuteOptions, 'abortSignal' | 'updateOutput'>,
options?: ExecuteOptions,
config?: Config,
originalRequestName?: string,
skipBeforeHook?: boolean,
@@ -154,11 +154,11 @@ export async function executeToolWithHooks(
// Execute the actual tool. Tools that support backgrounding can optionally
// surface an execution ID via the callback.
const toolResult: ToolResult = await invocation.execute({
...options,
abortSignal: signal,
updateOutput: liveOutputCallback,
});
const toolResult: ToolResult = await invocation.execute(
signal,
liveOutputCallback,
options,
);
// Append notification if parameters were modified
if (inputWasModified) {
+27 -8
View File
@@ -49,12 +49,14 @@ import { isFunctionResponse } from '../utils/messageInspectors.js';
import { partListUnionToString } from './geminiRequest.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
import { wrapAsExecute } from '../utils/dynamicToolsUtils.js';
import {
applyModelSelection,
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { coreEvents } from '../utils/events.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { debugLogger } from '../utils/debugLogger.js';
export enum StreamEventType {
/** A regular content chunk from the API. */
@@ -897,11 +899,30 @@ export class GeminiChat {
if (isValidResponse(chunk)) {
const content = chunk.candidates?.[0]?.content;
if (content?.parts) {
if (content.parts.some((part) => part.thought)) {
// Record thoughts
hasThoughts = true;
this.recordThoughtFromContent(content);
for (const part of content.parts) {
if (part.thought) {
// Record thoughts
hasThoughts = true;
this.recordThoughtFromContent(content);
}
if (
part.functionCall &&
part.functionCall.name &&
part.functionCall.name !== 'execute' &&
this.context.config.getExperimentalDynamicTools()
) {
debugLogger.log(
`[GeminiChat] Rewriting hallucinated tool call '${part.functionCall.name}' to 'execute' wrapper.`,
);
const wrapped = wrapAsExecute(
part.functionCall.name,
part.functionCall.args,
);
part.functionCall.name = wrapped.name;
part.functionCall.args = wrapped.args;
}
}
if (content.parts.some((part) => part.functionCall)) {
hasToolCall = true;
}
@@ -1050,13 +1071,11 @@ export class GeminiChat {
result: call.response?.responseParts || null,
status: call.status,
timestamp: new Date().toISOString(),
agentId:
typeof call.response?.data?.['agentId'] === 'string'
? call.response.data['agentId']
: undefined,
resultDisplay,
description:
'invocation' in call ? call.invocation?.getDescription() : undefined,
originalRequestName: call.request.originalRequestName,
originalRequestArgs: call.request.originalRequestArgs,
};
});
@@ -61,7 +61,6 @@ describe('Core System Prompt Substitution', () => {
}),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
isTrackerEnabled: vi.fn().mockReturnValue(false),
isModelSteeringEnabled: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
-2
View File
@@ -103,7 +103,6 @@ describe('Core System Prompt (prompts.ts)', () => {
isInteractive: vi.fn().mockReturnValue(true),
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
isAgentsEnabled: vi.fn().mockReturnValue(false),
getPreviewFeatures: vi.fn().mockReturnValue(true),
@@ -458,7 +457,6 @@ describe('Core System Prompt (prompts.ts)', () => {
isInteractive: vi.fn().mockReturnValue(false),
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
isAgentsEnabled: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue('auto'),
+78 -10
View File
@@ -29,6 +29,11 @@ import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { getCitations } from '../utils/generateContentResponseUtilities.js';
import { LlmRole } from '../telemetry/types.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
unwrapExecuteArgs,
wrapAsExecute,
} from '../utils/dynamicToolsUtils.js';
import {
type ToolCallRequestInfo,
@@ -237,6 +242,7 @@ export type ServerGeminiStreamEvent =
// A turn manages the agentic loop turn within the server context.
export class Turn {
private callCounter = 0;
private handledCallIds = new Set<string>();
readonly pendingToolCalls: ToolCallRequestInfo[] = [];
private debugResponses: GenerateContentResponse[] = [];
@@ -409,7 +415,74 @@ export class Turn {
): ServerGeminiStreamEvent | null {
const name = fnCall.name || 'undefined_tool_name';
const args = fnCall.args || {};
const callId = fnCall.id ?? `${name}_${Date.now()}_${this.callCounter++}`;
const callId = fnCall.id || `${name}_${Date.now()}_${this.callCounter++}`;
if (this.handledCallIds.has(callId)) {
return null;
}
this.handledCallIds.add(callId);
// Handle experimental dynamic tools
if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(this.chat as any).context.config.getExperimentalDynamicTools()
) {
if (name === 'execute') {
const unwrapped = unwrapExecuteArgs(fnCall.args);
if (unwrapped) {
debugLogger.log(
`[Turn] Unwrapping execute call for tool: ${unwrapped.name}`,
);
const toolCallRequest: ToolCallRequestInfo = {
callId,
name: unwrapped.name,
args: unwrapped.args,
originalRequestName: 'execute',
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
originalRequestArgs: fnCall.args as any,
isClientInitiated: false,
prompt_id: this.prompt_id,
traceId,
};
this.pendingToolCalls.push(toolCallRequest);
return {
type: GeminiEventType.ToolCallRequest,
value: toolCallRequest,
};
}
} else if (name !== 'undefined_tool_name') {
// Model called a tool directly despite documentation.
// It should have been wrapped by GeminiChat.makeApiCallAndProcessStream,
// but as a failsafe we wrap it here too.
debugLogger.log(
`[Turn] Model called tool '${name}' directly. Recovery wrapping as 'execute'.`,
);
const unwrapped = unwrapExecuteArgs(wrapAsExecute(name, args).args);
if (unwrapped) {
const toolCallRequest: ToolCallRequestInfo = {
callId,
name: unwrapped.name,
args: unwrapped.args,
originalRequestName: 'execute',
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
originalRequestArgs: wrapAsExecute(name, args).args as any,
isClientInitiated: false,
prompt_id: this.prompt_id,
traceId,
};
this.pendingToolCalls.push(toolCallRequest);
return {
type: GeminiEventType.ToolCallRequest,
value: toolCallRequest,
};
}
}
}
const toolCallRequest: ToolCallRequestInfo = {
callId,
@@ -426,15 +499,6 @@ export class Turn {
return { type: GeminiEventType.ToolCallRequest, value: toolCallRequest };
}
getDebugResponses(): GenerateContentResponse[] {
return this.debugResponses;
}
/**
* Get the concatenated response text from all responses in this turn.
* This extracts and joins all text content from the model's responses.
* The result is cached since this is called multiple times per turn.
*/
getResponseText(): string {
if (this.cachedResponseText === undefined) {
this.cachedResponseText = this.debugResponses
@@ -444,4 +508,8 @@ export class Turn {
}
return this.cachedResponseText;
}
getDebugResponses(): GenerateContentResponse[] {
return this.debugResponses;
}
}
-1
View File
@@ -100,7 +100,6 @@ export {
PRIORITY_YOLO_ALLOW_ALL,
} from './policy/types.js';
export * from './utils/tool-utils.js';
export * from './utils/tool-visibility.js';
export * from './utils/terminalSerializer.js';
export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
@@ -71,7 +71,6 @@ describe('PromptProvider', () => {
isInteractive: vi.fn().mockReturnValue(true),
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isExperimentalWatcherEnabled: vi.fn().mockReturnValue(false),
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
getSkillManager: vi.fn().mockReturnValue({
getSkills: vi.fn().mockReturnValue([]),
+12 -1
View File
@@ -32,6 +32,9 @@ import { resolveModel, supportsModernFeatures } from '../config/models.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import {
getDynamicToolsDocumentation,
} from '../utils/dynamicToolsUtils.js';
/**
* Orchestrates prompt generation by gathering context and building options.
@@ -96,6 +99,13 @@ export class PromptProvider {
.join('\n');
}
let dynamicToolsDocumentation: string | undefined;
if (context.config.getExperimentalDynamicTools()) {
dynamicToolsDocumentation = getDynamicToolsDocumentation(
context.toolRegistry.getFunctionDeclarations(),
);
}
let basePrompt: string;
// --- Template File Override ---
@@ -140,8 +150,8 @@ export class PromptProvider {
hasHierarchicalMemory,
contextFilenames,
topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(),
watcherEnabled: context.config.isExperimentalWatcherEnabled(),
})),
dynamicToolsDocumentation,
subAgents: this.withSection(
'agentContexts',
() =>
@@ -283,6 +293,7 @@ export class PromptProvider {
);
const isModernModel = supportsModernFeatures(desiredModel);
const activeSnippets = isModernModel ? snippets : legacySnippets;
return activeSnippets.getCompressionPrompt(
context.config.getApprovedPlanPath(),
);
+1 -7
View File
@@ -56,7 +56,6 @@ export interface CoreMandatesOptions {
hasSkills: boolean;
hasHierarchicalMemory: boolean;
topicUpdateNarration?: boolean;
watcherEnabled: boolean;
}
export interface PrimaryWorkflowsOptions {
@@ -170,11 +169,6 @@ export function renderPreamble(options?: PreambleOptions): string {
: 'You are a non-interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.';
}
function mandateWatcher(watcherEnabled: boolean): string {
if (!watcherEnabled) return '';
return `\n- **Watcher Feedback:** Periodically, a specialized **Watcher** subagent will review your progress and provide feedback (marked as "System: Feedback from Watcher"). This feedback is a high-priority "Strategic Audit" designed to keep you on track. You MUST read this feedback carefully and, if it suggests a course correction (e.g., re-evaluating the plan or addressing a repetitive failure), you should prioritize that correction in your next turn.`;
}
export function renderCoreMandates(options?: CoreMandatesOptions): string {
if (!options) return '';
return `
@@ -188,7 +182,7 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string {
- **Design Patterns:** Prioritize explicit composition and delegation (e.g.: wrapper classes, proxies, or factory functions) over complex inheritance or prototype-based cloning. When extending or modifying existing classes, prefer patterns that are easily traceable and type-safe.
- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments.
- **Proactiveness:** Fulfill the user's request thoroughly. When adding features or fixing bugs, this includes adding tests to ensure quality. Consider all created files, especially tests, to be permanent artifacts unless the user says otherwise.${mandateConflictResolution(options.hasHierarchicalMemory)}
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.${mandateWatcher(options.watcherEnabled)}
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
- ${mandateConfirm(options.interactive)}
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${
+4 -12
View File
@@ -43,6 +43,7 @@ import { DEFAULT_CONTEXT_FILENAME } from '../tools/memoryTool.js';
export interface SystemPromptOptions {
preamble?: PreambleOptions;
coreMandates?: CoreMandatesOptions;
dynamicToolsDocumentation?: string;
subAgents?: SubAgentOptions[];
agentSkills?: AgentSkillOptions[];
hookContext?: boolean;
@@ -65,7 +66,6 @@ export interface CoreMandatesOptions {
hasHierarchicalMemory: boolean;
contextFilenames?: string[];
topicUpdateNarration: boolean;
watcherEnabled: boolean;
}
export interface PrimaryWorkflowsOptions {
@@ -128,6 +128,8 @@ ${renderPreamble(options.preamble)}
${renderCoreMandates(options.coreMandates)}
${options.dynamicToolsDocumentation ? `\n<tools>\n${options.dynamicToolsDocumentation}\n</tools>\n` : ''}
${renderSubAgents(options.subAgents)}
${renderAgentSkills(options.agentSkills)}
@@ -176,11 +178,6 @@ export function renderPreamble(options?: PreambleOptions): string {
: 'You are Gemini CLI, an autonomous CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and effectively.';
}
function mandateWatcher(watcherEnabled: boolean): string {
if (!watcherEnabled) return '';
return `\n- **Watcher Feedback:** Periodically, a specialized **Watcher** subagent will review your progress and provide feedback (marked as "System: EXTREMELY IMPORTANT Feedback from Watcher"). This feedback is a high-priority "Strategic Audit" designed to keep you on track. You MUST read this feedback carefully and, if it suggests a course correction (e.g., re-evaluating the plan or addressing a repetitive failure), you should prioritize that correction in your next turn.`;
}
export function renderCoreMandates(options?: CoreMandatesOptions): string {
if (!options) return '';
const filenames = options.contextFilenames ?? [DEFAULT_CONTEXT_FILENAME];
@@ -243,7 +240,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.${mandateConflictResolution(options.hasHierarchicalMemory)}
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.${mandateWatcher(options.watcherEnabled)}
- **User Hints:** During execution, the user may provide real-time hints (marked as "User hint:" or "User hints:"). Treat these as high-priority but scope-preserving course corrections: apply the minimal plan change needed, keep unaffected user tasks active, and never cancel/skip tasks unless cancellation is explicit for those tasks. Hints may add new tasks, modify one or more tasks, cancel specific tasks, or provide extra context only. If scope is ambiguous, ask for clarification before dropping work.
- ${mandateConfirm(options.interactive)}${
options.topicUpdateNarration
? mandateTopicUpdateModel()
@@ -353,11 +350,6 @@ ${workflowStepStrategy(options)}
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
1. Stop and remind yourself of the original task description.
2. List your current assumptions and identify which ones might be wrong.
3. Propose a different architectural approach rather than continuing to patch the current one.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
@@ -20,20 +20,12 @@ using System.Text;
* It also supports internal commands for safe file I/O within the sandbox.
*/
public class GeminiSandbox {
// --- P/Invoke Constants and Structures ---
// P/Invoke constants and structures
private const int JobObjectExtendedLimitInformation = 9;
private const int JobObjectNetRateControlInformation = 32;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
private const uint JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION = 0x00000400;
private const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
private const int TokenIntegrityLevel = 25;
private const uint SE_GROUP_INTEGRITY = 0x00000020;
private const uint TOKEN_ALL_ACCESS = 0xF01FF;
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
private const int SE_FILE_OBJECT = 1;
private const uint LABEL_SECURITY_INFORMATION = 0x00000010;
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
@@ -75,6 +67,39 @@ public class GeminiSandbox {
public byte DscpTag;
}
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint ResumeThread(IntPtr hThread);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, uint ImpersonationLevel, uint TokenType, out IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetStdHandle(int nStdHandle);
[StructLayout(LayoutKind.Sequential)]
struct STARTUPINFO {
public uint cb;
@@ -105,6 +130,21 @@ public class GeminiSandbox {
public uint dwThreadId;
}
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool ImpersonateLoggedOnUser(IntPtr hToken);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool RevertToSelf();
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern uint GetLongPathName(string lpszShortPath, [Out] StringBuilder lpszLongPath, uint cchBuffer);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool ConvertStringSidToSid(string StringSid, out IntPtr ptrSid);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength);
[StructLayout(LayoutKind.Sequential)]
struct SID_AND_ATTRIBUTES {
public IntPtr Sid;
@@ -116,81 +156,14 @@ public class GeminiSandbox {
public SID_AND_ATTRIBUTES Label;
}
// --- Kernel32 P/Invokes ---
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
private const int TokenIntegrityLevel = 25;
private const uint SE_GROUP_INTEGRITY = 0x00000020;
private const uint TOKEN_ALL_ACCESS = 0xF01FF;
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern uint GetLongPathName(string lpszShortPath, [Out] StringBuilder lpszLongPath, uint cchBuffer);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr LocalFree(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
// --- Advapi32 P/Invokes ---
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, uint ImpersonationLevel, uint TokenType, out IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool ImpersonateLoggedOnUser(IntPtr hToken);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool RevertToSelf();
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool ConvertStringSidToSid(string StringSid, out IntPtr ptrSid);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string StringSecurityDescriptor, uint StringSDRevision, out IntPtr SecurityDescriptor, out uint SecurityDescriptorSize);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern uint SetNamedSecurityInfo(string pObjectName, int ObjectType, uint SecurityInfo, IntPtr psidOwner, IntPtr psidGroup, IntPtr pDacl, IntPtr pSacl);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool GetSecurityDescriptorSacl(IntPtr pSecurityDescriptor, out bool lpbSaclPresent, out IntPtr pSacl, out bool lpbSaclDefaulted);
// --- Main Entry Point ---
static int Main(string[] args) {
if (args.Length < 3) {
Console.Error.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> [--forbidden-manifest <path>] [--allowed-manifest <path>] <command> [args...]");
Console.Error.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> [--forbidden-manifest <path>] <command> [args...]");
Console.Error.WriteLine("Internal commands: __read <path>, __write <path>");
return 1;
}
@@ -198,32 +171,21 @@ public class GeminiSandbox {
bool networkAccess = args[0] == "1";
string cwd = args[1];
HashSet<string> forbiddenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
HashSet<string> allowedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int argIndex = 2;
// 1. Parse Command Line Arguments & Manifests
while (argIndex < args.Length) {
if (args[argIndex] == "--forbidden-manifest") {
if (argIndex + 1 < args.Length) {
ParseManifest(args[argIndex + 1], forbiddenPaths);
argIndex += 2;
} else {
break;
if (argIndex < args.Length && args[argIndex] == "--forbidden-manifest") {
if (argIndex + 1 < args.Length) {
string manifestPath = args[argIndex + 1];
if (File.Exists(manifestPath)) {
foreach (string line in File.ReadAllLines(manifestPath)) {
if (!string.IsNullOrWhiteSpace(line)) {
forbiddenPaths.Add(GetNormalizedPath(line.Trim()));
}
}
}
} else if (args[argIndex] == "--allowed-manifest") {
if (argIndex + 1 < args.Length) {
ParseManifest(args[argIndex + 1], allowedPaths);
argIndex += 2;
} else {
break;
}
} else {
break;
argIndex += 2;
}
}
// 2. Apply Bulk ACLs
ApplyBulkAcls(allowedPaths, forbiddenPaths);
if (argIndex >= args.Length) {
Console.Error.WriteLine("Error: Missing command");
@@ -238,18 +200,20 @@ public class GeminiSandbox {
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
try {
// 3. Duplicate Primary Token and Create Restricted Token
// 1. Duplicate Primary Token
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out hToken)) {
Console.Error.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
// Create a restricted token to strip administrative privileges
if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
Console.Error.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
// 4. Lower Integrity Level to "Low" (S-1-16-4096)
// 2. Lower Integrity Level to Low
// S-1-16-4096 is the SID for "Low Mandatory Level"
IntPtr lowIntegritySid = IntPtr.Zero;
if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) {
TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL();
@@ -268,7 +232,7 @@ public class GeminiSandbox {
}
}
// 5. Setup Job Object
// 3. Setup Job Object for cleanup
hJob = CreateJobObject(IntPtr.Zero, null);
if (hJob == IntPtr.Zero) {
Console.Error.WriteLine("Error: CreateJobObject failed (" + Marshal.GetLastWin32Error() + ")");
@@ -299,6 +263,7 @@ public class GeminiSandbox {
try {
Marshal.StructureToPtr(netLimits, lpNetLimits, false);
if (!SetInformationJobObject(hJob, JobObjectNetRateControlInformation, lpNetLimits, (uint)Marshal.SizeOf(netLimits))) {
// Some versions of Windows might not support network rate control, but we should know if it fails.
Console.Error.WriteLine("Warning: SetInformationJobObject(NetRate) failed (" + Marshal.GetLastWin32Error() + "). Network might not be throttled.");
}
} finally {
@@ -306,7 +271,7 @@ public class GeminiSandbox {
}
}
// 6. Handle Internal Commands or External Process
// 4. Handle Internal Commands or External Process
if (command == "__read") {
if (argIndex + 1 >= args.Length) {
Console.Error.WriteLine("Error: Missing path for __read");
@@ -336,6 +301,7 @@ public class GeminiSandbox {
try {
using (MemoryStream ms = new MemoryStream()) {
// Buffer stdin before impersonation (as restricted token can't read the inherited pipe).
using (Stream stdin = Console.OpenStandardInput()) {
stdin.CopyTo(ms);
}
@@ -354,7 +320,7 @@ public class GeminiSandbox {
}
}
// 7. Execute External Process
// External Process
STARTUPINFO si = new STARTUPINFO();
si.cb = (uint)Marshal.SizeOf(si);
si.dwFlags = 0x00000100; // STARTF_USESTDHANDLES
@@ -408,89 +374,14 @@ public class GeminiSandbox {
}
}
// --- Helper Methods ---
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
private static void ParseManifest(string manifestPath, HashSet<string> paths) {
if (!File.Exists(manifestPath)) return;
foreach (string line in File.ReadAllLines(manifestPath, Encoding.UTF8)) {
if (!string.IsNullOrWhiteSpace(line)) {
paths.Add(GetNormalizedPath(line.Trim()));
}
}
}
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
private static void ApplyBulkAcls(HashSet<string> allowedPaths, HashSet<string> forbiddenPaths) {
SecurityIdentifier lowSid = new SecurityIdentifier("S-1-16-4096");
// 1. Apply Deny Rules
foreach (string path in forbiddenPaths) {
try {
if (File.Exists(path)) {
FileSecurity fs = File.GetAccessControl(path);
fs.AddAccessRule(new FileSystemAccessRule(lowSid, FileSystemRights.FullControl, AccessControlType.Deny));
File.SetAccessControl(path, fs);
} else if (Directory.Exists(path)) {
DirectorySecurity ds = Directory.GetAccessControl(path);
ds.AddAccessRule(new FileSystemAccessRule(lowSid, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Deny));
Directory.SetAccessControl(path, ds);
}
} catch (Exception e) {
Console.Error.WriteLine("Warning: Failed to apply deny ACL to " + path + ": " + e.Message);
}
}
// 2. Pre-calculate Security Descriptors for Allow Rules
IntPtr pSdDir = IntPtr.Zero;
IntPtr pSdFile = IntPtr.Zero;
IntPtr pSaclDir = IntPtr.Zero;
IntPtr pSaclFile = IntPtr.Zero;
uint sdSize = 0;
bool saclPresent = false;
bool saclDefaulted = false;
if (ConvertStringSecurityDescriptorToSecurityDescriptor("S:(ML;OICI;NW;;;LW)", 1, out pSdDir, out sdSize)) {
GetSecurityDescriptorSacl(pSdDir, out saclPresent, out pSaclDir, out saclDefaulted);
}
if (ConvertStringSecurityDescriptorToSecurityDescriptor("S:(ML;;NW;;;LW)", 1, out pSdFile, out sdSize)) {
GetSecurityDescriptorSacl(pSdFile, out saclPresent, out pSaclFile, out saclDefaulted);
}
// 3. Apply Allow Rules
foreach (string path in allowedPaths) {
try {
bool isDir = Directory.Exists(path);
if (isDir) {
DirectorySecurity ds = Directory.GetAccessControl(path);
ds.AddAccessRule(new FileSystemAccessRule(lowSid, FileSystemRights.Modify, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
Directory.SetAccessControl(path, ds);
} else if (File.Exists(path)) {
FileSecurity fs = File.GetAccessControl(path);
fs.AddAccessRule(new FileSystemAccessRule(lowSid, FileSystemRights.Modify, AccessControlType.Allow));
File.SetAccessControl(path, fs);
} else {
continue;
}
// Ensure we use the 8.3 long-name equivalent for robust security checks per guidelines
StringBuilder sb = new StringBuilder(1024);
GetLongPathName(path, sb, 1024);
string longPath = sb.ToString();
IntPtr pSacl = isDir ? pSaclDir : pSaclFile;
if (pSacl != IntPtr.Zero) {
uint result = SetNamedSecurityInfo(longPath, SE_FILE_OBJECT, LABEL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, pSacl);
if (result != 0) {
Console.Error.WriteLine("Warning: SetNamedSecurityInfo failed for " + longPath + " with error " + result);
}
}
} catch (Exception e) {
Console.Error.WriteLine("Warning: Failed to apply allow ACL to " + path + ": " + e.Message);
}
}
if (pSdDir != IntPtr.Zero) LocalFree(pSdDir);
if (pSdFile != IntPtr.Zero) LocalFree(pSdFile);
}
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
private static int RunInImpersonation(IntPtr hToken, Func<int> action) {
if (!ImpersonateLoggedOnUser(hToken)) {
@@ -565,4 +456,4 @@ public class GeminiSandbox {
sb.Append('\"');
return sb.ToString();
}
}
}
@@ -12,6 +12,7 @@ import { WindowsSandboxManager } from './WindowsSandboxManager.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import * as paths from '../../utils/paths.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
@@ -42,26 +43,6 @@ describe('WindowsSandboxManager', () => {
WindowsSandboxManager.HELPER_EXE,
);
/**
* Helper to read manifests from sandbox args
*/
function getManifestPaths(args: string[]): {
forbidden: string[];
allowed: string[];
} {
const forbiddenPath = args[3];
const allowedPath = args[5];
const forbidden = fs
.readFileSync(forbiddenPath, 'utf8')
.split('\n')
.filter(Boolean);
const allowed = fs
.readFileSync(allowedPath, 'utf8')
.split('\n')
.filter(Boolean);
return { forbidden, allowed };
}
beforeEach(() => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
vi.spyOn(paths, 'resolveToRealPath').mockImplementation((p) => p);
@@ -109,9 +90,7 @@ describe('WindowsSandboxManager', () => {
'0',
testCwd,
'--forbidden-manifest',
expect.stringMatching(/forbidden\.txt$/),
'--allowed-manifest',
expect.stringMatching(/allowed\.txt$/),
expect.stringMatching(/manifest\.txt$/),
'whoami',
'/groups',
]);
@@ -146,12 +125,19 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await manager.prepareCommand(req);
const { allowed } = getManifestPaths(result.args);
await manager.prepareCommand(req);
// Should NOT have drive roots (C:\, D:\, etc.) in the allowed manifest
const driveRoots = allowed.filter((p) => /^[A-Z]:\\$/.test(p));
expect(driveRoots).toHaveLength(0);
// Verify spawnAsync was called for icacls
const icaclsCalls = vi
.mocked(spawnAsync)
.mock.calls.filter((call) => call[0] === 'icacls');
// Should NOT have called icacls for C:\, D:\, etc.
const driveRootCalls = icaclsCalls.filter(
(call) =>
typeof call[1]?.[0] === 'string' && /^[A-Z]:\\$/.test(call[1][0]),
);
expect(driveRootCalls).toHaveLength(0);
});
it('should handle network access from additionalPermissions', async () => {
@@ -219,8 +205,18 @@ describe('WindowsSandboxManager', () => {
const result = await managerWithPolicy.prepareCommand(req);
expect(result.args[0]).toBe('1'); // Network allowed by persistent policy
const { allowed } = getManifestPaths(result.args);
expect(allowed).toContain(persistentPath);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
persistentPath,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
});
it('should sanitize environment variables', async () => {
@@ -262,7 +258,7 @@ describe('WindowsSandboxManager', () => {
expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true);
});
it('should include the workspace and allowed paths in the allowed manifest', async () => {
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
const allowedPath = createTempDir('allowed');
try {
const req: SandboxRequest = {
@@ -275,17 +271,34 @@ describe('WindowsSandboxManager', () => {
},
};
const result = await manager.prepareCommand(req);
const { allowed } = getManifestPaths(result.args);
await manager.prepareCommand(req);
expect(allowed).toContain(testCwd);
expect(allowed).toContain(allowedPath);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
testCwd,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
allowedPath,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
}
});
it('should exclude git worktree paths from the allowed manifest (enforce read-only)', async () => {
it('should NOT grant Low Integrity access to git worktree paths (enforce read-only)', async () => {
const worktreeGitDir = createTempDir('worktree-git');
const mainGitDir = createTempDir('main-git');
@@ -310,19 +323,36 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await manager.prepareCommand(req);
const { allowed } = getManifestPaths(result.args);
await manager.prepareCommand(req);
// Verify that the git directories are NOT in the allowed manifest
expect(allowed).not.toContain(worktreeGitDir);
expect(allowed).not.toContain(mainGitDir);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
// Verify that no icacls grants were issued for the git directories
expect(icaclsArgs).not.toContainEqual([
worktreeGitDir,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
expect(icaclsArgs).not.toContainEqual([
mainGitDir,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(worktreeGitDir, { recursive: true, force: true });
fs.rmSync(mainGitDir, { recursive: true, force: true });
}
});
it('should include additional write paths in the allowed manifest', async () => {
it('should grant Low Integrity access to additional write paths', async () => {
const extraWritePath = createTempDir('extra-write');
try {
const req: SandboxRequest = {
@@ -339,17 +369,27 @@ describe('WindowsSandboxManager', () => {
},
};
const result = await manager.prepareCommand(req);
const { allowed } = getManifestPaths(result.args);
await manager.prepareCommand(req);
expect(allowed).toContain(extraWritePath);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
extraWritePath,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(extraWritePath, { recursive: true, force: true });
}
});
it.runIf(process.platform === 'win32')(
'should reject UNC paths for allowed access',
'should reject UNC paths in grantLowIntegrityAccess',
async () => {
const uncPath = '\\\\attacker\\share\\malicious.txt';
const req: SandboxRequest = {
@@ -368,11 +408,18 @@ describe('WindowsSandboxManager', () => {
// Rejected because it's an unreachable/invalid UNC path or it doesn't exist
await expect(manager.prepareCommand(req)).rejects.toThrow();
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).not.toContainEqual(expect.arrayContaining([uncPath]));
},
);
it.runIf(process.platform === 'win32')(
'should include extended-length and local device paths in the allowed manifest',
'should allow extended-length and local device paths',
async () => {
// Create actual files for inheritance/existence checks
const longPath = path.join(testCwd, 'very_long_path.txt');
@@ -394,15 +441,31 @@ describe('WindowsSandboxManager', () => {
},
};
const result = await manager.prepareCommand(req);
const { allowed } = getManifestPaths(result.args);
await manager.prepareCommand(req);
expect(allowed).toContain(path.resolve(longPath));
expect(allowed).toContain(path.resolve(devicePath));
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
path.resolve(longPath),
'/grant',
'*S-1-16-4096:(M)',
'/setintegritylevel',
'Low',
]);
expect(icaclsArgs).toContainEqual([
path.resolve(devicePath),
'/grant',
'*S-1-16-4096:(M)',
'/setintegritylevel',
'Low',
]);
},
);
it('includes non-existent forbidden paths in the forbidden manifest', async () => {
it('skips denying access to non-existent forbidden paths to prevent icacls failure', async () => {
const missingPath = path.join(
os.tmpdir(),
'gemini-cli-test-missing',
@@ -426,13 +489,17 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await managerWithForbidden.prepareCommand(req);
const { forbidden } = getManifestPaths(result.args);
await managerWithForbidden.prepareCommand(req);
expect(forbidden).toContain(path.resolve(missingPath));
// Should NOT have called icacls to deny the missing path
expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [
path.resolve(missingPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
});
it('should include forbidden paths in the forbidden manifest', async () => {
it('should deny Low Integrity access to forbidden paths', async () => {
const forbiddenPath = createTempDir('forbidden');
try {
const managerWithForbidden = new WindowsSandboxManager({
@@ -447,16 +514,19 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await managerWithForbidden.prepareCommand(req);
const { forbidden } = getManifestPaths(result.args);
await managerWithForbidden.prepareCommand(req);
expect(forbidden).toContain(forbiddenPath);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
forbiddenPath,
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
} finally {
fs.rmSync(forbiddenPath, { recursive: true, force: true });
}
});
it('should exclude forbidden paths from the allowed manifest if a conflict exists', async () => {
it('should override allowed paths if a path is also in forbidden paths', async () => {
const conflictPath = createTempDir('conflict');
try {
const managerWithForbidden = new WindowsSandboxManager({
@@ -474,12 +544,27 @@ describe('WindowsSandboxManager', () => {
},
};
const result = await managerWithForbidden.prepareCommand(req);
const { forbidden, allowed } = getManifestPaths(result.args);
await managerWithForbidden.prepareCommand(req);
const spawnMock = vi.mocked(spawnAsync);
const allowCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/setintegritylevel') &&
call[0] === 'icacls' &&
call[1][0] === conflictPath,
);
const denyCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/deny') &&
call[0] === 'icacls' &&
call[1][0] === conflictPath,
);
// Conflict should have been filtered out of allow calls
expect(allowed).not.toContain(conflictPath);
expect(forbidden).toContain(conflictPath);
expect(allowCallIndex).toBe(-1);
expect(denyCallIndex).toBeGreaterThan(-1);
} finally {
fs.rmSync(conflictPath, { recursive: true, force: true });
}
@@ -497,12 +582,12 @@ describe('WindowsSandboxManager', () => {
const result = await manager.prepareCommand(req);
// [network, cwd, --forbidden-manifest, fPath, --allowed-manifest, aPath, command, ...args]
expect(result.args[6]).toBe('__write');
expect(result.args[7]).toBe(filePath);
// [network, cwd, --forbidden-manifest, manifestPath, command, ...args]
expect(result.args[4]).toBe('__write');
expect(result.args[5]).toBe(filePath);
});
it('should safely handle special characters in internal command paths', async () => {
it('should safely handle special characters in __write path using environment variables', async () => {
const maliciousPath = path.join(testCwd, 'foo & echo bar; ! .txt');
fs.writeFileSync(maliciousPath, '');
const req: SandboxRequest = {
@@ -515,8 +600,8 @@ describe('WindowsSandboxManager', () => {
const result = await manager.prepareCommand(req);
// Native commands pass arguments directly; the binary handles quoting via QuoteArgument
expect(result.args[6]).toBe('__write');
expect(result.args[7]).toBe(maliciousPath);
expect(result.args[4]).toBe('__write');
expect(result.args[5]).toBe(maliciousPath);
});
it('should pass __read directly to native helper', async () => {
@@ -531,11 +616,11 @@ describe('WindowsSandboxManager', () => {
const result = await manager.prepareCommand(req);
expect(result.args[6]).toBe('__read');
expect(result.args[7]).toBe(filePath);
expect(result.args[4]).toBe('__read');
expect(result.args[5]).toBe(filePath);
});
it('should return a cleanup function that deletes the temporary manifest directory', async () => {
it('should return a cleanup function that deletes the temporary manifest', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
@@ -544,16 +629,13 @@ describe('WindowsSandboxManager', () => {
};
const result = await manager.prepareCommand(req);
const forbiddenManifestPath = result.args[3];
const allowedManifestPath = result.args[5];
const manifestPath = result.args[3];
expect(fs.existsSync(forbiddenManifestPath)).toBe(true);
expect(fs.existsSync(allowedManifestPath)).toBe(true);
expect(fs.existsSync(manifestPath)).toBe(true);
expect(result.cleanup).toBeDefined();
result.cleanup?.();
expect(fs.existsSync(forbiddenManifestPath)).toBe(false);
expect(fs.existsSync(allowedManifestPath)).toBe(false);
expect(fs.existsSync(path.dirname(forbiddenManifestPath))).toBe(false);
expect(fs.existsSync(manifestPath)).toBe(false);
expect(fs.existsSync(path.dirname(manifestPath))).toBe(false);
});
});
@@ -26,6 +26,7 @@ import {
} from '../../services/environmentSanitization.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
import { isNodeError } from '../../utils/errors.js';
import {
isKnownSafeCommand,
isDangerousCommand,
@@ -46,6 +47,13 @@ import {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity)
const LOW_INTEGRITY_SID = '*S-1-16-4096';
// icacls flags: (OI) Object Inherit, (CI) Container Inherits.
// Omit /T (recursive) for performance; (OI)(CI) ensures inheritance for new items.
const DIRECTORY_FLAGS = '(OI)(CI)';
/**
* A SandboxManager implementation for Windows that uses Restricted Tokens,
* Job Objects, and Low Integrity levels for process isolation.
@@ -55,6 +63,8 @@ export class WindowsSandboxManager implements SandboxManager {
static readonly HELPER_EXE = 'GeminiSandbox.exe';
private readonly helperPath: string;
private initialized = false;
private readonly allowedCache = new Set<string>();
private readonly deniedCache = new Set<string>();
private readonly denialCache: SandboxDenialCache = createSandboxDenialCache();
constructor(private readonly options: GlobalSandboxOptions) {
@@ -276,73 +286,11 @@ export class WindowsSandboxManager implements SandboxManager {
mergedAdditional,
);
// 1. Collect all forbidden paths.
// We start with explicitly forbidden paths from the options and request.
const forbiddenManifest = new Set(
resolvedPaths.forbidden.map((p) => resolveToRealPath(p)),
);
// Track all roots where Low Integrity write access has been granted.
// New files created within these roots will inherit the Low label.
const writableRoots: string[] = [];
// On Windows, we explicitly deny access to secret files for Low Integrity processes.
// We scan common search directories (workspace, allowed paths) for secrets.
const searchDirs = new Set([
resolvedPaths.workspace.resolved,
...resolvedPaths.policyAllowed,
...resolvedPaths.globalIncludes,
]);
const secretFilesPromises = Array.from(searchDirs).map(async (dir) => {
try {
// We use maxDepth 3 to catch common nested secrets while keeping performance high.
const secretFiles = await findSecretFiles(dir, 3);
for (const secretFile of secretFiles) {
forbiddenManifest.add(resolveToRealPath(secretFile));
}
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to find secret files in ${dir}`,
e,
);
}
});
await Promise.all(secretFilesPromises);
// 2. Track paths that will be granted write access.
// 'allowedManifest' contains resolved paths for the C# helper to apply ACLs.
// 'inheritanceRoots' contains both original and resolved paths for Node.js sub-path validation.
const allowedManifest = new Set<string>();
const inheritanceRoots = new Set<string>();
const addWritableRoot = (p: string) => {
const resolved = resolveToRealPath(p);
// Track both versions for inheritance checks to be robust against symlinks.
inheritanceRoots.add(p);
inheritanceRoots.add(resolved);
// Never grant access to system directories or explicitly forbidden paths.
if (this.isSystemDirectory(resolved)) return;
if (forbiddenManifest.has(resolved)) return;
// Explicitly reject UNC paths to prevent credential theft/SSRF,
// but allow local extended-length and device paths.
if (
resolved.startsWith('\\\\') &&
!resolved.startsWith('\\\\?\\') &&
!resolved.startsWith('\\\\.\\')
) {
debugLogger.log(
'WindowsSandboxManager: Rejecting UNC path for allowed manifest:',
resolved,
);
return;
}
allowedManifest.add(resolved);
};
// 3. Populate writable roots from various sources.
// A. Workspace access
// 1. Workspace access
const isApproved = allowOverrides
? await isStrictlyApproved(
command,
@@ -354,15 +302,17 @@ export class WindowsSandboxManager implements SandboxManager {
const workspaceWrite = !isReadonlyMode || isApproved || isYolo;
if (workspaceWrite) {
addWritableRoot(resolvedPaths.workspace.resolved);
await this.grantLowIntegrityAccess(resolvedPaths.workspace.resolved);
writableRoots.push(resolvedPaths.workspace.resolved);
}
// B. Globally included directories
// 2. Globally included directories
for (const includeDir of resolvedPaths.globalIncludes) {
addWritableRoot(includeDir);
await this.grantLowIntegrityAccess(includeDir);
writableRoots.push(includeDir);
}
// C. Explicitly allowed paths from the request policy
// 3. Explicitly allowed paths from the request policy
for (const allowedPath of resolvedPaths.policyAllowed) {
try {
await fs.promises.access(allowedPath, fs.constants.F_OK);
@@ -372,18 +322,19 @@ export class WindowsSandboxManager implements SandboxManager {
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
);
}
addWritableRoot(allowedPath);
await this.grantLowIntegrityAccess(allowedPath);
writableRoots.push(allowedPath);
}
// D. Additional write paths (e.g. from internal __write command)
// 4. Additional write paths (e.g. from internal __write command)
for (const writePath of resolvedPaths.policyWrite) {
try {
await fs.promises.access(writePath, fs.constants.F_OK);
addWritableRoot(writePath);
await this.grantLowIntegrityAccess(writePath);
continue;
} catch {
// If the file doesn't exist, it's only allowed if it resides within a granted root.
const isInherited = Array.from(inheritanceRoots).some((root) =>
const isInherited = writableRoots.some((root) =>
isSubpath(root, writePath),
);
@@ -397,46 +348,88 @@ export class WindowsSandboxManager implements SandboxManager {
}
// Support git worktrees/submodules; read-only to prevent malicious hook/config modification (RCE).
// Read access is inherited; skip addWritableRoot to ensure write protection.
// Read access is inherited; skip grantLowIntegrityAccess to ensure write protection.
if (resolvedPaths.gitWorktree) {
// No-op for read access on Windows.
// No-op for read access.
}
// 4. Protected governance files
// 2. Collect secret files and apply protective ACLs
// On Windows, we explicitly deny access to secret files for Low Integrity
// processes to ensure they cannot be read or written.
const secretsToBlock: string[] = [];
const searchDirs = new Set([
resolvedPaths.workspace.resolved,
...resolvedPaths.policyAllowed,
...resolvedPaths.globalIncludes,
]);
for (const dir of searchDirs) {
try {
// We use maxDepth 3 to catch common nested secrets while keeping performance high.
const secretFiles = await findSecretFiles(dir, 3);
for (const secretFile of secretFiles) {
try {
secretsToBlock.push(secretFile);
await this.denyLowIntegrityAccess(secretFile);
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to secure secret file ${secretFile}`,
e,
);
}
}
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to find secret files in ${dir}`,
e,
);
}
}
// Denies access to forbiddenPaths for Low Integrity processes.
// Note: Denying access to arbitrary paths (like system files) via icacls
// is restricted to avoid host corruption. External commands rely on
// Low Integrity read/write restrictions, while internal commands
// use the manifest for enforcement.
for (const forbiddenPath of resolvedPaths.forbidden) {
try {
await this.denyLowIntegrityAccess(forbiddenPath);
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to secure forbidden path ${forbiddenPath}`,
e,
);
}
}
// 3. Protected governance files
// These must exist on the host before running the sandbox to prevent
// the sandboxed process from creating them with Low integrity.
// By being created as Medium integrity, they are write-protected from Low processes.
for (const file of GOVERNANCE_FILES) {
const filePath = path.join(resolvedPaths.workspace.resolved, file.path);
this.touch(filePath, file.isDirectory);
}
// 5. Generate Manifests
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'gemini-cli-sandbox-'),
// 4. Forbidden paths manifest
// We use a manifest file to avoid command-line length limits.
const allForbidden = Array.from(
new Set([...secretsToBlock, ...resolvedPaths.forbidden]),
);
const forbiddenManifestPath = path.join(tempDir, 'forbidden.txt');
await fs.promises.writeFile(
forbiddenManifestPath,
Array.from(forbiddenManifest).join('\n'),
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-forbidden-'),
);
const manifestPath = path.join(tempDir, 'manifest.txt');
fs.writeFileSync(manifestPath, allForbidden.join('\n'));
const allowedManifestPath = path.join(tempDir, 'allowed.txt');
await fs.promises.writeFile(
allowedManifestPath,
Array.from(allowedManifest).join('\n'),
);
// 6. Construct the helper command
// 5. Construct the helper command
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
const program = this.helperPath;
const finalArgs = [
networkAccess ? '1' : '0',
req.cwd,
'--forbidden-manifest',
forbiddenManifestPath,
'--allowed-manifest',
allowedManifestPath,
manifestPath,
command,
...args,
];
@@ -458,6 +451,111 @@ export class WindowsSandboxManager implements SandboxManager {
};
}
/**
* Grants "Low Mandatory Level" access to a path using icacls.
*/
private async grantLowIntegrityAccess(targetPath: string): Promise<void> {
if (os.platform() !== 'win32') {
return;
}
const resolvedPath = resolveToRealPath(targetPath);
if (this.allowedCache.has(resolvedPath)) {
return;
}
// Explicitly reject UNC paths to prevent credential theft/SSRF,
// but allow local extended-length and device paths.
if (
resolvedPath.startsWith('\\\\') &&
!resolvedPath.startsWith('\\\\?\\') &&
!resolvedPath.startsWith('\\\\.\\')
) {
debugLogger.log(
'WindowsSandboxManager: Rejecting UNC path for Low Integrity grant:',
resolvedPath,
);
return;
}
if (this.isSystemDirectory(resolvedPath)) {
return;
}
try {
const stats = await fs.promises.stat(resolvedPath);
const isDirectory = stats.isDirectory();
const flags = isDirectory ? DIRECTORY_FLAGS : '';
// 1. Grant explicit Modify access to the Low Integrity SID
// 2. Set the Mandatory Label to Low to allow "Write Up" from Low processes
await spawnAsync('icacls', [
resolvedPath,
'/grant',
`${LOW_INTEGRITY_SID}:${flags}(M)`,
'/setintegritylevel',
`${flags}Low`,
]);
this.allowedCache.add(resolvedPath);
} catch (e) {
debugLogger.log(
'WindowsSandboxManager: icacls failed for',
resolvedPath,
e,
);
}
}
/**
* Explicitly denies access to a path for Low Integrity processes using icacls.
*/
private async denyLowIntegrityAccess(targetPath: string): Promise<void> {
if (os.platform() !== 'win32') {
return;
}
const resolvedPath = resolveToRealPath(targetPath);
if (this.deniedCache.has(resolvedPath)) {
return;
}
// Never modify ACEs for system directories
if (this.isSystemDirectory(resolvedPath)) {
return;
}
// icacls fails on non-existent paths, so we cannot explicitly deny
// paths that do not yet exist (unlike macOS/Linux).
// Skip to prevent sandbox initialization failure.
let isDirectory = false;
try {
const stats = await fs.promises.stat(resolvedPath);
isDirectory = stats.isDirectory();
} catch (e: unknown) {
if (isNodeError(e) && e.code === 'ENOENT') {
return;
}
throw e;
}
const flags = isDirectory ? DIRECTORY_FLAGS : '';
try {
await spawnAsync('icacls', [
resolvedPath,
'/deny',
`${LOW_INTEGRITY_SID}:${flags}(F)`,
]);
this.deniedCache.add(resolvedPath);
} catch (e) {
throw new Error(
`Failed to deny access to forbidden path: ${resolvedPath}. ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
private isSystemDirectory(resolvedPath: string): boolean {
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files';
@@ -536,34 +536,6 @@ describe('ChatRecordingService', () => {
.toolCalls,
).toHaveLength(1);
});
it('should record agentId when provided', async () => {
chatRecordingService.recordMessage({
type: 'gemini',
content: '',
model: 'gemini-pro',
});
const toolCall: ToolCallRecord = {
id: 'tool-1',
name: 'testTool',
args: {},
status: CoreToolCallStatus.Success,
timestamp: new Date().toISOString(),
agentId: 'test-agent-id',
};
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
const sessionFile = chatRecordingService.getConversationFilePath()!;
const conversation = (await loadConversationRecord(
sessionFile,
)) as ConversationRecord;
const geminiMsg = conversation.messages[0] as MessageRecord & {
type: 'gemini';
};
expect(geminiMsg.toolCalls).toHaveLength(1);
expect(geminiMsg.toolCalls![0].agentId).toBe('test-agent-id');
});
});
describe('deleteSession', () => {
@@ -229,6 +229,7 @@ export async function loadConversationRecord(
summary: metadata.summary,
directories: metadata.directories,
kind: metadata.kind,
experimentalDynamicTools: metadata.experimentalDynamicTools,
messages: Array.from(messagesMap.values()),
messageCount: options?.metadataOnly
? messageIds.length
@@ -292,6 +293,7 @@ export class ChatRecordingService {
kind: this.cachedConversation.kind,
directories: this.cachedConversation.directories,
summary: this.cachedConversation.summary,
experimentalDynamicTools: this.cachedConversation.experimentalDynamicTools,
};
this.appendRecord(initialMetadata);
for (const msg of this.cachedConversation.messages) {
@@ -358,18 +360,21 @@ export class ChatRecordingService {
]
: undefined;
const initialMetadata = {
const initialMetadata: PartialMetadataRecord = {
sessionId: this.sessionId,
projectHash: this.projectHash,
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
kind: this.kind,
directories,
experimentalDynamicTools: this.context.config.getExperimentalDynamicTools(),
};
this.appendRecord(initialMetadata);
this.cachedConversation = {
...initialMetadata,
startTime: initialMetadata.startTime!,
lastUpdated: initialMetadata.lastUpdated!,
messages: [],
};
}
@@ -45,12 +45,15 @@ export interface ToolCallRecord {
result?: PartListUnion | null;
status: Status;
timestamp: string;
agentId?: string;
// UI-specific fields for display purposes
displayName?: string;
description?: string;
resultDisplay?: ToolResultDisplay;
renderOutputAsMarkdown?: boolean;
/** Experimental: original name of the tool requested by the model before unwrapping */
originalRequestName?: string;
/** Experimental: original arguments of the tool requested by the model before unwrapping */
originalRequestArgs?: Record<string, unknown>;
}
/**
@@ -87,6 +90,8 @@ export interface ConversationRecord {
directories?: string[];
/** The kind of conversation (main agent or subagent) */
kind?: 'main' | 'subagent';
/** Experimental: Whether dynamic tools documentation-injection was enabled */
experimentalDynamicTools?: boolean;
}
/**
@@ -122,4 +127,6 @@ export interface PartialMetadataRecord {
summary?: string;
directories?: string[];
kind?: 'main' | 'subagent';
/** Experimental: Whether dynamic tools documentation-injection was enabled */
experimentalDynamicTools?: boolean;
}
@@ -42,7 +42,7 @@ const mockFileKeychain: MockKeychain = {
findCredentials: vi.fn(),
};
vi.mock('@github/keytar', () => ({ default: mockKeytar }));
vi.mock('keytar', () => ({ default: mockKeytar }));
vi.mock('./fileKeychain.js', () => ({
FileKeychain: vi.fn(() => mockFileKeychain),
@@ -22,7 +22,7 @@ import { FileKeychain } from './fileKeychain.js';
export const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
/**
* Service for interacting with OS-level secure storage (e.g. @github/keytar).
* Service for interacting with OS-level secure storage (e.g. keytar).
*/
export class KeychainService {
// Track an ongoing initialization attempt to avoid race conditions.
@@ -119,7 +119,7 @@ export class KeychainService {
}
/**
* Attempts to load and verify the native keychain module (@github/keytar).
* Attempts to load and verify the native keychain module (keytar).
*/
private async getNativeKeychain(): Promise<Keychain | null> {
try {
@@ -152,7 +152,7 @@ export class KeychainService {
// Low-level dynamic loading and structural validation.
private async loadKeychainModule(): Promise<Keychain | null> {
const moduleName = '@github/keytar';
const moduleName = 'keytar';
const module: unknown = await import(moduleName);
const potential = (isRecord(module) && module['default']) || module;
@@ -189,7 +189,7 @@ export class KeychainService {
*/
private isMacOSKeychainAvailable(): boolean {
// Probing via the `security` CLI avoids a blocking OS-level popup that
// occurs when calling @github/keytar without a configured keychain.
// occurs when calling keytar without a configured keychain.
const result = spawnSync('security', ['default-keychain'], {
encoding: 'utf8',
// We pipe stdout to read the path, but ignore stderr to suppress
+1 -1
View File
@@ -8,7 +8,7 @@ import { z } from 'zod';
/**
* Interface for OS-level secure storage operations.
* Note: Method names must match the underlying library (e.g. @github/keytar)
* Note: Method names must match the underlying library (e.g. keytar)
* to support correct dynamic loading and schema validation.
*/
export interface Keychain {
@@ -75,7 +75,6 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
return {
...actual,
resolveExecutable: mockResolveExecutable,
spawnAsync: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
};
});
vi.mock('node:child_process', async (importOriginal) => {
@@ -209,7 +208,6 @@ describe('ShellExecutionService', () => {
beforeEach(() => {
vi.clearAllMocks();
ExecutionLifecycleService.resetForTest();
ShellExecutionService.resetForTest();
mockSerializeTerminalToObject.mockReturnValue([]);
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
@@ -696,7 +694,7 @@ describe('ShellExecutionService', () => {
);
expect(sigtermCallIndex).toBe(0);
expect(sigkillCallIndex).toBeGreaterThan(0);
expect(sigkillCallIndex).toBe(1);
expect(sigtermCallIndex).toBeLessThan(sigkillCallIndex);
expect(result.signal).toBe(9);
@@ -1249,8 +1247,6 @@ describe('ShellExecutionService child_process fallback', () => {
beforeEach(() => {
vi.clearAllMocks();
ExecutionLifecycleService.resetForTest();
ShellExecutionService.resetForTest();
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
@@ -1477,11 +1473,8 @@ describe('ShellExecutionService child_process fallback', () => {
const { result } = await simulateExecution(
'sleep 10',
async (cp, abortController) => {
(cp, abortController) => {
abortController.abort();
await new Promise(process.nextTick);
await new Promise(process.nextTick);
await new Promise(process.nextTick);
if (expectedExit.signal) {
cp.emit('exit', null, expectedExit.signal);
cp.emit('close', null, expectedExit.signal);
@@ -1501,14 +1494,11 @@ describe('ShellExecutionService child_process fallback', () => {
expectedSignal,
);
} else {
// Taskkill is spawned via spawnAsync which is mocked
const { spawnAsync } = await import('../utils/shell-utils.js');
expect(spawnAsync).toHaveBeenCalledWith(expectedCommand, [
'/pid',
String(mockChildProcess.pid),
'/f',
'/t',
]);
expect(mockCpSpawn).toHaveBeenCalledWith(
expectedCommand,
['/pid', String(mockChildProcess.pid), '/f', '/t'],
expect.anything(),
);
}
});
},
@@ -1538,7 +1528,6 @@ describe('ShellExecutionService child_process fallback', () => {
);
abortController.abort();
await vi.advanceTimersByTimeAsync(0);
// Check the first kill signal
expect(mockProcessKill).toHaveBeenCalledWith(
@@ -1618,22 +1607,6 @@ describe('ShellExecutionService child_process fallback', () => {
'exit',
]);
});
it('should correctly measure sniffedBytes with >20 small chunks to prevent OOM (regression #22170)', async () => {
mockIsBinary.mockReturnValue(false);
await simulateExecution('cat lots_of_chunks', (cp) => {
for (let i = 0; i < 25; i++) {
cp.stdout?.emit('data', Buffer.alloc(10, 'a'));
}
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
const lastCallBuffer =
mockIsBinary.mock.calls[mockIsBinary.mock.calls.length - 1][0];
expect(lastCallBuffer.length).toBe(250);
});
});
describe('Platform-Specific Behavior', () => {
@@ -1689,8 +1662,6 @@ describe('ShellExecutionService execution method selection', () => {
beforeEach(() => {
vi.clearAllMocks();
ExecutionLifecycleService.resetForTest();
ShellExecutionService.resetForTest();
onOutputEventMock = vi.fn();
// Mock for pty
@@ -1741,12 +1712,10 @@ describe('ShellExecutionService execution method selection', () => {
);
// Simulate exit to allow promise to resolve
if (!mockPtyProcess.onExit.mock.calls[0]) {
const res = await handle.result;
throw new Error(`Failed early in executeWithPty: ${res.error}`);
}
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
const result = await handle.result;
expect(mockGetPty).toHaveBeenCalled();
expect(mockPtySpawn).toHaveBeenCalled();
expect(mockCpSpawn).not.toHaveBeenCalled();
expect(result.executionMethod).toBe('mock-pty');
@@ -1817,8 +1786,6 @@ describe('ShellExecutionService environment variables', () => {
beforeEach(() => {
vi.clearAllMocks();
ExecutionLifecycleService.resetForTest();
ShellExecutionService.resetForTest();
vi.resetModules(); // Reset modules to ensure process.env changes are fresh
// Mock for pty
@@ -112,10 +112,8 @@ export interface ShellExecutionConfig {
*/
export type ShellOutputEvent = ExecutionOutputEvent;
export type DestroyablePty = IPty & { destroy?: () => void };
interface ActivePty {
ptyProcess: DestroyablePty;
ptyProcess: IPty;
headlessTerminal: pkg.Terminal;
maxSerializedLines?: number;
command: string;
@@ -632,7 +630,7 @@ export class ShellExecutionService {
}
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(state.sniffChunks);
const sniffBuffer = Buffer.concat(state.sniffChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
@@ -706,10 +704,7 @@ export class ShellExecutionService {
const finalStrippedOutput = stripAnsi(combinedOutput).trim();
const exitCode = code;
const exitSignal =
signal && os.constants.signals
? (os.constants.signals[signal] ?? null)
: null;
const exitSignal = signal ? os.constants.signals[signal] : null;
const resultPayload: ShellExecutionResult = {
rawOutput: Buffer.from(''),
@@ -835,42 +830,6 @@ export class ShellExecutionService {
};
}
}
/**
* Destroys a PTY process to release its file descriptors.
* This is critical to prevent system-wide PTY exhaustion (see #15945).
*/
private static destroyPtyProcess(ptyProcess: DestroyablePty): void {
try {
if (typeof ptyProcess?.destroy === 'function') {
ptyProcess.destroy();
} else if (typeof ptyProcess?.kill === 'function') {
// Fallback: if destroy() is unavailable, kill() may still close FDs
ptyProcess.kill();
}
} catch {
// Ignore errors during PTY cleanup — process may already be dead
}
}
/**
* Cleans up all resources associated with a PTY entry:
* the PTY process (file descriptors) and the headless terminal (memory buffers).
*/
private static cleanupPtyEntry(pid: number): void {
const entry = this.activePtys.get(pid);
if (!entry) return;
this.destroyPtyProcess(entry.ptyProcess);
try {
entry.headlessTerminal.dispose();
} catch {
// Ignore errors during terminal cleanup
}
this.activePtys.delete(pid);
}
private static async executeWithPty(
commandToExecute: string,
cwd: string,
@@ -883,7 +842,7 @@ export class ShellExecutionService {
// This should not happen, but as a safeguard...
throw new Error('PTY implementation not found');
}
let spawnedPty: DestroyablePty | undefined;
let spawnedPty: IPty | undefined;
let cmdCleanup: (() => void) | undefined;
try {
@@ -916,7 +875,7 @@ export class ShellExecutionService {
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
spawnedPty = ptyProcess as DestroyablePty;
spawnedPty = ptyProcess as IPty;
const ptyPid = Number(ptyProcess.pid);
const headlessTerminal = new Terminal({
@@ -950,6 +909,13 @@ export class ShellExecutionService {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
pty: ptyProcess,
}).catch(() => {});
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(ptyProcess as IPty & { destroy?: () => void }).destroy?.();
} catch {
// Ignore errors during cleanup
}
this.activePtys.delete(ptyPid);
},
isActive: () => {
try {
@@ -1125,7 +1091,7 @@ export class ShellExecutionService {
}
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(sniffChunks);
const sniffBuffer = Buffer.concat(sniffChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
@@ -1177,11 +1143,13 @@ export class ShellExecutionService {
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
// Immediately destroy the PTY to release its master FD.
// The headless terminal is kept alive until finalize() extracts
// its buffer contents, then disposed to free memory.
ShellExecutionService.destroyPtyProcess(ptyProcess);
// Attempt to destroy the PTY to ensure FD is closed
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(ptyProcess as IPty & { destroy?: () => void }).destroy?.();
} catch {
// Ignore errors during cleanup
}
const finalize = () => {
render(true);
@@ -1205,6 +1173,11 @@ export class ShellExecutionService {
}
onOutputEvent(event);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
ShellExecutionService.cleanupLogStream(ptyPid).then(() => {
ShellExecutionService.activePtys.delete(ptyPid);
});
const endLine = headlessTerminal.buffer.active.length;
const startLine = Math.max(
0,
@@ -1215,24 +1188,10 @@ export class ShellExecutionService {
startLine,
endLine,
);
const finalOutput = getFullBufferText(headlessTerminal);
// Dispose the headless terminal to free scrollback buffers.
// This must happen after getFullBufferText() extracts the output.
try {
headlessTerminal.dispose();
} catch {
// Ignore errors during terminal cleanup
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
ShellExecutionService.cleanupLogStream(ptyPid).then(() => {
ShellExecutionService.activePtys.delete(ptyPid);
});
ExecutionLifecycleService.completeWithResult(ptyPid, {
rawOutput: Buffer.from(''),
output: finalOutput,
output: getFullBufferText(headlessTerminal),
ansiOutput: ansiOutputSnapshot,
exitCode,
signal: signal ?? null,
@@ -1287,10 +1246,14 @@ export class ShellExecutionService {
cmdCleanup?.();
if (spawnedPty) {
ShellExecutionService.destroyPtyProcess(spawnedPty);
try {
(spawnedPty as IPty & { destroy?: () => void }).destroy?.();
} catch {
// Ignore errors during cleanup
}
}
if (error?.message?.includes('posix_spawnp failed')) {
if (error.message.includes('posix_spawnp failed')) {
onOutputEvent({
type: 'data',
chunk:
@@ -1350,9 +1313,9 @@ export class ShellExecutionService {
*/
static async kill(pid: number): Promise<void> {
await this.cleanupLogStream(pid);
this.activePtys.delete(pid);
this.activeChildProcesses.delete(pid);
ExecutionLifecycleService.kill(pid);
this.cleanupPtyEntry(pid);
}
/**
@@ -1540,16 +1503,4 @@ export class ShellExecutionService {
signal: info.signal,
}));
}
/**
* Resets the internal state of the ShellExecutionService.
* This is intended for use in tests to ensure isolation.
*/
static resetForTest(): void {
this.activePtys.clear();
this.activeChildProcesses.clear();
this.backgroundLogPids.clear();
this.backgroundLogStreams.clear();
this.backgroundProcessHistory.clear();
}
}
+9 -53
View File
@@ -4,37 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { diag, SpanStatusCode, trace } from '@opentelemetry/api';
import type { Tracer } from '@opentelemetry/api';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { trace, SpanStatusCode, diag, type Tracer } from '@opentelemetry/api';
import { runInDevTraceSpan, truncateForTelemetry } from './trace.js';
import {
GeminiCliOperation,
GEN_AI_CONVERSATION_ID,
GEN_AI_AGENT_DESCRIPTION,
GEN_AI_AGENT_NAME,
GEN_AI_CONVERSATION_ID,
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
GEN_AI_OUTPUT_MESSAGES,
GeminiCliOperation,
SERVICE_DESCRIPTION,
SERVICE_NAME,
} from './constants.js';
import {
runInDevTraceSpan,
spanRegistry,
truncateForTelemetry,
} from './trace.js';
vi.mock('@opentelemetry/api', async (importOriginal) => {
const original = await importOriginal();
return Object.assign({}, original, {
const original = await importOriginal<typeof import('@opentelemetry/api')>();
return {
...original,
trace: {
getTracer: vi.fn(),
},
diag: {
error: vi.fn(),
},
});
};
});
vi.mock('../utils/session.js', () => ({
@@ -212,45 +207,6 @@ describe('runInDevTraceSpan', () => {
expect(mockSpan.end).toHaveBeenCalled();
});
it('should register async generators with spanRegistry', async () => {
const spy = vi.spyOn(spanRegistry, 'register');
async function* testStream() {
yield 1;
}
const resultStream = await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
async () => testStream(),
);
expect(spy).toHaveBeenCalledWith(resultStream, expect.any(Function));
});
it('should be idempotent and call span.end only once', async () => {
vi.spyOn(spanRegistry, 'register');
async function* testStream() {
yield 1;
}
const resultStream = await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
async () => testStream(),
);
// Simulate completion
for await (const _ of resultStream) {
// iterate
}
expect(mockSpan.end).toHaveBeenCalledTimes(1);
// Try to end again (simulating registry or double call)
const endSpanFn = vi.mocked(spanRegistry.register).mock
.calls[0][1] as () => void;
endSpanFn();
expect(mockSpan.end).toHaveBeenCalledTimes(1);
});
it('should end span automatically on error in async iterators', async () => {
const error = new Error('streaming error');
async function* errorStream() {
+16 -49
View File
@@ -11,11 +11,9 @@ import {
type AttributeValue,
type SpanOptions,
} from '@opentelemetry/api';
import { debugLogger } from '../utils/debugLogger.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { truncateString } from '../utils/textUtils.js';
import {
type GeminiCliOperation,
GEN_AI_AGENT_DESCRIPTION,
GEN_AI_AGENT_NAME,
GEN_AI_CONVERSATION_ID,
@@ -24,44 +22,23 @@ import {
GEN_AI_OUTPUT_MESSAGES,
SERVICE_DESCRIPTION,
SERVICE_NAME,
type GeminiCliOperation,
} from './constants.js';
import { truncateString } from '../utils/textUtils.js';
const TRACER_NAME = 'gemini-cli';
const TRACER_VERSION = 'v1';
/**
* Registry used to ensure that spans are properly ended when their associated
* async objects are garbage collected.
*/
export const spanRegistry = new FinalizationRegistry((endSpan: () => void) => {
try {
endSpan();
} catch (e) {
debugLogger.warn(
'Error in FinalizationRegistry callback for span cleanup',
e,
);
}
});
/**
* Truncates a value for inclusion in telemetry attributes.
*
* @param value The value to truncate.
* @param maxLength The maximum length of the stringified value.
* @returns The truncated value, or undefined if the value type is not supported.
*/
export function truncateForTelemetry(
value: unknown,
maxLength = 10000,
maxLength: number = 10000,
): AttributeValue | undefined {
if (typeof value === 'string') {
return truncateString(
value,
maxLength,
`...[TRUNCATED: original length ${value.length}]`,
) as AttributeValue;
);
}
if (typeof value === 'object' && value !== null) {
const stringified = safeJsonStringify(value);
@@ -69,10 +46,10 @@ export function truncateForTelemetry(
stringified,
maxLength,
`...[TRUNCATED: original length ${stringified.length}]`,
) as AttributeValue;
);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value as AttributeValue;
return value;
}
return undefined;
}
@@ -105,15 +82,12 @@ export interface SpanMetadata {
*
* @example
* ```typescript
* await runInDevTraceSpan(
* { operation: GeminiCliOperation.LLMCall, sessionId: 'my-session' },
* async ({ metadata }) => {
* metadata.input = { foo: 'bar' };
* // ... do work ...
* metadata.output = { result: 'baz' };
* metadata.attributes['my.custom.attribute'] = 'some-value';
* }
* );
* runInDevTraceSpan({ name: 'my-operation' }, ({ metadata }) => {
* metadata.input = { foo: 'bar' };
* // ... do work ...
* metadata.output = { result: 'baz' };
* metadata.attributes['my.custom.attribute'] = 'some-value';
* });
* ```
*
* @param opts The options for the span.
@@ -141,12 +115,7 @@ export async function runInDevTraceSpan<R>(
[GEN_AI_CONVERSATION_ID]: sessionId,
},
};
let spanEnded = false;
const endSpan = () => {
if (spanEnded) {
return;
}
spanEnded = true;
try {
if (logPrompts !== false) {
if (meta.input !== undefined) {
@@ -200,7 +169,7 @@ export async function runInDevTraceSpan<R>(
const streamWrapper = (async function* () {
try {
yield* result;
} catch (e: unknown) {
} catch (e) {
meta.error = e;
throw e;
} finally {
@@ -208,12 +177,10 @@ export async function runInDevTraceSpan<R>(
}
})();
const finalResult = Object.assign(streamWrapper, result);
spanRegistry.register(finalResult, endSpan);
return finalResult;
return Object.assign(streamWrapper, result);
}
return result;
} catch (e: unknown) {
} catch (e) {
meta.error = e;
throw e;
} finally {
+11 -6
View File
@@ -14,6 +14,7 @@ import {
Kind,
type ToolCallConfirmationDetails,
type ToolInvocation,
type ToolLiveOutput,
type ToolResult,
type ExecuteOptions,
} from '../tools/tools.js';
@@ -53,8 +54,11 @@ class MockToolInvocation extends BaseToolInvocation<
super(params, messageBus, tool.name, tool.displayName);
}
execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
execute(
signal: AbortSignal,
updateOutput?: (output: ToolLiveOutput) => void,
options?: ExecuteOptions,
): Promise<ToolResult> {
return this.tool.execute(
this.params,
signal,
@@ -154,10 +158,11 @@ export class MockModifiableToolInvocation extends BaseToolInvocation<
super(params, messageBus, tool.name, tool.displayName);
}
async execute({
abortSignal: _signal,
updateOutput: _updateOutput,
}: ExecuteOptions): Promise<ToolResult> {
async execute(
_signal: AbortSignal,
_updateOutput?: (output: ToolLiveOutput) => void,
_options?: ExecuteOptions,
): Promise<ToolResult> {
const result = this.tool.executeFn(this.params);
return (
result ?? {
@@ -107,9 +107,7 @@ describe('ActivateSkillTool', () => {
it('should activate a valid skill and return its content in XML tags', async () => {
const params = { name: 'test-skill' };
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(mockConfig.getSkillManager().activateSkill).toHaveBeenCalledWith(
'test-skill',
@@ -138,9 +136,7 @@ describe('ActivateSkillTool', () => {
vi.mocked(mockConfig.getSkillManager().getSkill).mockReturnValue(null);
const params = { name: 'test-skill' };
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Error: Skill "test-skill" not found.');
expect(mockConfig.getSkillManager().activateSkill).not.toHaveBeenCalled();
+1 -2
View File
@@ -15,7 +15,6 @@ import {
type ToolCallConfirmationDetails,
type ToolInvocation,
type ToolConfirmationOutcome,
type ExecuteOptions,
} from './tools.js';
import type { Config } from '../config/config.js';
import { ACTIVATE_SKILL_TOOL_NAME } from './tool-names.js';
@@ -108,7 +107,7 @@ ${folderStructure}`,
return confirmationDetails;
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
async execute(_signal: AbortSignal): Promise<ToolResult> {
const skillName = this.params.name;
const skillManager = this.config.getSkillManager();
const skill = skillManager.getSkill(skillName);
+3 -9
View File
@@ -410,9 +410,7 @@ describe('AskUserTool', () => {
});
}
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toContain('User answered:');
expect(result.returnDisplay).toContain(
' Approach → Quick fix (Recommended)',
@@ -455,9 +453,7 @@ describe('AskUserTool', () => {
});
}
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toBe(
'User submitted without answering questions.',
);
@@ -503,9 +499,7 @@ describe('AskUserTool', () => {
await details.onConfirm(ToolConfirmationOutcome.Cancel);
}
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toBe('User dismissed dialog');
expect(result.llmContent).toBe(
'User dismissed ask_user dialog without answering.',
+1 -2
View File
@@ -12,7 +12,6 @@ import {
type ToolAskUserConfirmationDetails,
type ToolConfirmationPayload,
ToolConfirmationOutcome,
type ExecuteOptions,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -153,7 +152,7 @@ export class AskUserInvocation extends BaseToolInvocation<
return `Asking user: ${this.params.questions.map((q) => q.question).join(', ')}`;
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
async execute(_signal: AbortSignal): Promise<ToolResult> {
const questionTypes = this.params.questions.map((q) => q.type);
if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) {
@@ -63,9 +63,7 @@ describe('CompleteTaskTool', () => {
it('should execute and return correct data', async () => {
const invocation = tool.build({ result: 'Success message' });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.data).toEqual({
taskCompleted: true,
@@ -135,9 +133,7 @@ describe('CompleteTaskTool', () => {
it('should execute and return structured data', async () => {
const outputValue = { report: 'Final findings', score: 42 };
const invocation = tool.build({ my_output: outputValue });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['taskCompleted']).toBe(true);
expect(result.data?.['submittedOutput']).toBe(
@@ -156,9 +152,7 @@ describe('CompleteTaskTool', () => {
const outputValue = { report: 'Final findings', score: 42 };
const invocation = toolWithProcess.build({ my_output: outputValue });
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['submittedOutput']).toBe('Score was 42');
});

Some files were not shown because too many files have changed in this diff Show More