mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 09:10:59 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f1970df81 | |||
| c4fe8f4ff1 | |||
| b36e4eb1eb | |||
| 5f034e58af | |||
| f72359efef |
@@ -63,12 +63,11 @@ You can enter Plan Mode in three ways:
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Plan` -> `Auto-Edit`).
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
|
||||
then call the [`enter_plan_mode`] tool to switch modes.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...".
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
1. **Requirements:** The agent clarifies goals using [`ask_user`].
|
||||
1. **Requirements:** The agent clarifies goals using `ask_user`.
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Design:** The agent proposes alternative approaches with a recommended
|
||||
@@ -84,8 +83,8 @@ You can enter Plan Mode in three ways:
|
||||
To exit Plan Mode:
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized
|
||||
plan for your approval.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
@@ -95,7 +94,7 @@ These are the only allowed tools:
|
||||
|
||||
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
|
||||
- **Search:** [`grep_search`], [`google_web_search`]
|
||||
- **Interaction:** [`ask_user`]
|
||||
- **Interaction:** `ask_user`
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
@@ -181,6 +180,3 @@ Guide].
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /docs/core/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
|
||||
@@ -179,6 +179,9 @@ precedence.
|
||||
|
||||
### Settings
|
||||
|
||||
_Note: This is an experimental feature. We do not yet recommend extension
|
||||
authors introduce settings as part of their core flows._
|
||||
|
||||
Extensions can define settings that the user will be prompted to provide upon
|
||||
installation. This is useful for things like API keys, URLs, or other
|
||||
configuration that the extension needs to function.
|
||||
|
||||
@@ -447,12 +447,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash"
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
@@ -508,7 +502,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -520,7 +514,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -532,25 +526,25 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-pro-preview"
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"extends": "gemini-3-flash-base",
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"chat-compression-3-pro": {
|
||||
@@ -580,7 +574,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-pro-preview"
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Ask User Tool
|
||||
|
||||
The `ask_user` tool allows the agent to ask you one or more questions to gather
|
||||
preferences, clarify requirements, or make decisions. It supports multiple
|
||||
question types including multiple-choice, free-form text, and Yes/No
|
||||
confirmation.
|
||||
|
||||
## `ask_user` (Ask User)
|
||||
|
||||
- **Tool name:** `ask_user`
|
||||
- **Display name:** Ask User
|
||||
- **File:** `ask-user.ts`
|
||||
- **Parameters:**
|
||||
- `questions` (array of objects, required): A list of 1 to 4 questions to ask.
|
||||
Each question object has the following properties:
|
||||
- `question` (string, required): The complete question text.
|
||||
- `header` (string, required): A short label (max 16 chars) displayed as a
|
||||
chip/tag (e.g., "Auth", "Database").
|
||||
- `type` (string, optional): The type of question. Defaults to `'choice'`.
|
||||
- `'choice'`: Multiple-choice with options (supports multi-select).
|
||||
- `'text'`: Free-form text input.
|
||||
- `'yesno'`: Yes/No confirmation.
|
||||
- `options` (array of objects, optional): Required for `'choice'` type. 2-4
|
||||
selectable options.
|
||||
- `label` (string, required): Display text (1-5 words).
|
||||
- `description` (string, required): Brief explanation.
|
||||
- `multiSelect` (boolean, optional): For `'choice'` type, allows selecting
|
||||
multiple options.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
- Presents an interactive dialog to the user with the specified questions.
|
||||
- Pauses execution until the user provides answers or dismisses the dialog.
|
||||
- Returns the user's answers to the model.
|
||||
|
||||
- **Output (`llmContent`):** A JSON string containing the user's answers,
|
||||
indexed by question position (e.g.,
|
||||
`{"answers":{"0": "Option A", "1": "Some text"}}`).
|
||||
|
||||
- **Confirmation:** Yes. The tool inherently involves user interaction.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Multiple Choice Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Database",
|
||||
"question": "Which database would you like to use?",
|
||||
"type": "choice",
|
||||
"options": [
|
||||
{
|
||||
"label": "PostgreSQL",
|
||||
"description": "Powerful, open source object-relational database system."
|
||||
},
|
||||
{
|
||||
"label": "SQLite",
|
||||
"description": "C-library that implements a SQL database engine."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Text Input Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Project Name",
|
||||
"question": "What is the name of your new project?",
|
||||
"type": "text",
|
||||
"placeholder": "e.g., my-awesome-app"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Yes/No Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Deploy",
|
||||
"question": "Do you want to deploy the application now?",
|
||||
"type": "yesno"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -86,9 +86,6 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
|
||||
information across sessions.
|
||||
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
|
||||
requests.
|
||||
- **[Planning Tools](./planning.md):** For entering and exiting Plan Mode.
|
||||
- **[Ask User Tool](./ask-user.md) (`ask_user`):** For gathering user input and
|
||||
making decisions.
|
||||
|
||||
Additionally, these tools incorporate:
|
||||
|
||||
|
||||
@@ -739,10 +739,21 @@ The MCP integration tracks several states:
|
||||
cautiously and only for servers you completely control
|
||||
- **Access tokens:** Be security-aware when configuring environment variables
|
||||
containing API keys or tokens
|
||||
- **Environment variable redaction:** By default, the Gemini CLI redacts
|
||||
sensitive environment variables (such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`,
|
||||
and variables matching patterns like `*TOKEN*`, `*SECRET*`, `*PASSWORD*`) when
|
||||
spawning MCP servers using the `stdio` transport. This prevents unintended
|
||||
exposure of your credentials to third-party servers.
|
||||
- **Explicit environment variables:** If you need to pass a specific environment
|
||||
variable to an MCP server, you should define it explicitly in the `env`
|
||||
property of the server configuration in `settings.json`.
|
||||
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
|
||||
available within the sandbox environment
|
||||
available within the sandbox environment.
|
||||
- **Private data:** Using broadly scoped personal access tokens can lead to
|
||||
information leakage between repositories.
|
||||
- **Untrusted servers:** Be extremely cautious when adding MCP servers from
|
||||
untrusted or third-party sources. Malicious servers could attempt to
|
||||
exfiltrate data or perform unauthorized actions through the tools they expose.
|
||||
|
||||
### Performance and resource management
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# Gemini CLI planning tools
|
||||
|
||||
Planning tools allow the Gemini model to switch into a safe, read-only "Plan
|
||||
Mode" for researching and planning complex changes, and to signal the
|
||||
finalization of a plan to the user.
|
||||
|
||||
## 1. `enter_plan_mode` (EnterPlanMode)
|
||||
|
||||
`enter_plan_mode` switches the CLI to Plan Mode. This tool is typically called
|
||||
by the agent when you ask it to "start a plan" using natural language. In this
|
||||
mode, the agent is restricted to read-only tools to allow for safe exploration
|
||||
and planning.
|
||||
|
||||
- **Tool name:** `enter_plan_mode`
|
||||
- **Display name:** Enter Plan Mode
|
||||
- **File:** `enter-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `reason` (string, optional): A short reason explaining why the agent is
|
||||
entering plan mode (e.g., "Starting a complex feature implementation").
|
||||
- **Behavior:**
|
||||
- Switches the CLI's approval mode to `PLAN`.
|
||||
- Notifies the user that the agent has entered Plan Mode.
|
||||
- **Output (`llmContent`):** A message indicating the switch, e.g.,
|
||||
`Switching to Plan mode.`
|
||||
- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode.
|
||||
|
||||
## 2. `exit_plan_mode` (ExitPlanMode)
|
||||
|
||||
`exit_plan_mode` signals that the planning phase is complete. It presents the
|
||||
finalized plan to the user and requests approval to start the implementation.
|
||||
|
||||
- **Tool name:** `exit_plan_mode`
|
||||
- **Display name:** Exit Plan Mode
|
||||
- **File:** `exit-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `plan_path` (string, required): The path to the finalized Markdown plan
|
||||
file. This file MUST be located within the project's temporary plans
|
||||
directory (e.g., `~/.gemini/tmp/<project>/plans/`).
|
||||
- **Behavior:**
|
||||
- Validates that the `plan_path` is within the allowed directory and that the
|
||||
file exists and has content.
|
||||
- Presents the plan to the user for review.
|
||||
- If the user approves the plan:
|
||||
- Switches the CLI's approval mode to the user's chosen approval mode (
|
||||
`DEFAULT` or `AUTO_EDIT`).
|
||||
- Marks the plan as approved for implementation.
|
||||
- If the user rejects the plan:
|
||||
- Stays in Plan Mode.
|
||||
- Returns user feedback to the model to refine the plan.
|
||||
- **Output (`llmContent`):**
|
||||
- On approval: A message indicating the plan was approved and the new approval
|
||||
mode.
|
||||
- On rejection: A message containing the user's feedback.
|
||||
- **Confirmation:** Yes. Shows the finalized plan and asks for user approval to
|
||||
proceed with implementation.
|
||||
@@ -114,27 +114,6 @@ npm run test:all_evals
|
||||
This command sets the `RUN_EVALS` environment variable to `1`, which enables the
|
||||
`USUALLY_PASSES` tests.
|
||||
|
||||
### All Evals (All Models)
|
||||
|
||||
To run the full evaluation suite across all supported models and generate a
|
||||
local markdown report (mirroring the nightly CI workflow):
|
||||
|
||||
```bash
|
||||
npm run test:all_evals_all_models
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Build the project.
|
||||
2. Run `test:all_evals` for each model in the nightly rotation.
|
||||
3. Collect logs and aggregate them using `scripts/aggregate_evals.js`.
|
||||
4. Generate a `local_evals_summary.md` file with the results.
|
||||
|
||||
You can also filter by test name and specify the number of attempts:
|
||||
|
||||
```bash
|
||||
npm run test:all_evals_all_models -- "my-test-pattern" --attempts 3
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
Results for evaluations are available on GitHub Actions:
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
/**
|
||||
* Evals to verify that the agent uses search tools efficiently (frugally)
|
||||
* by utilizing limiting parameters like `total_max_matches` and `max_matches_per_file`.
|
||||
* This ensures the agent doesn't flood the context window with unnecessary search results.
|
||||
*/
|
||||
describe('Frugal Search', () => {
|
||||
const getGrepParams = (call: any): any => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use targeted search with limit',
|
||||
prompt: 'find me a sample usage of path.resolve() in the codebase',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
main: 'dist/index.js',
|
||||
scripts: {
|
||||
build: 'tsc',
|
||||
test: 'vitest',
|
||||
},
|
||||
dependencies: {
|
||||
typescript: '^5.0.0',
|
||||
'@types/node': '^20.0.0',
|
||||
vitest: '^1.0.0',
|
||||
},
|
||||
}),
|
||||
'src/index.ts': `
|
||||
import { App } from './app.ts';
|
||||
|
||||
const app = new App();
|
||||
app.start();
|
||||
`,
|
||||
'src/app.ts': `
|
||||
import * as path from 'path';
|
||||
import { UserController } from './controllers/user.ts';
|
||||
|
||||
export class App {
|
||||
constructor() {
|
||||
console.log('App initialized');
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
const userController = new UserController();
|
||||
console.log('Static path:', path.resolve(__dirname, '../public'));
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function resolvePath(p: string): string {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
const absolutePath = path.resolve(dirPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
fs.mkdirSync(absolutePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/config.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export const config = {
|
||||
dbPath: path.resolve(process.cwd(), 'data/db.sqlite'),
|
||||
logLevel: 'info',
|
||||
};
|
||||
`,
|
||||
'src/controllers/user.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export class UserController {
|
||||
public getUsers(): any[] {
|
||||
console.log('Loading users from:', path.resolve('data/users.json'));
|
||||
return [{ id: 1, name: 'Alice' }];
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tests/app.test.ts': `
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('App', () => {
|
||||
it('should resolve paths', () => {
|
||||
const p = path.resolve('test');
|
||||
expect(p).toBeDefined();
|
||||
});
|
||||
});
|
||||
`,
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const grepCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'grep_search',
|
||||
);
|
||||
|
||||
expect(grepCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const grepParams = grepCalls.map(getGrepParams);
|
||||
|
||||
const hasTotalMaxLimit = grepParams.some(
|
||||
(p) => p.total_max_matches !== undefined && p.total_max_matches <= 100,
|
||||
);
|
||||
expect(
|
||||
hasTotalMaxLimit,
|
||||
`Expected agent to use a small total_max_matches (<= 100) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.total_max_matches),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
|
||||
const hasMaxMatchesPerFileLimit = grepParams.some(
|
||||
(p) =>
|
||||
p.max_matches_per_file !== undefined && p.max_matches_per_file <= 5,
|
||||
);
|
||||
expect(
|
||||
hasMaxMatchesPerFileLimit,
|
||||
`Expected agent to use a small max_matches_per_file (<= 5) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.max_matches_per_file),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,6 @@
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals_all_models": "node scripts/run_local_evals.js",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
|
||||
@@ -128,6 +128,13 @@ async function addMcpServer(
|
||||
|
||||
settings.setValue(settingsScope, 'mcpServers', mcpServers);
|
||||
|
||||
if (transport === 'stdio') {
|
||||
debugLogger.warn(
|
||||
'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' +
|
||||
'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isExistingServer) {
|
||||
debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`);
|
||||
} else {
|
||||
|
||||
@@ -796,6 +796,8 @@ export async function loadCliConfig(
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
sessionLearnings: settings.general?.sessionLearnings?.enabled,
|
||||
sessionLearningsOutputPath: settings.general?.sessionLearnings?.outputPath,
|
||||
ideMode,
|
||||
disableLoopDetection: settings.model?.disableLoopDetection,
|
||||
compressionThreshold: settings.model?.compressionThreshold,
|
||||
|
||||
@@ -322,6 +322,37 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
sessionLearnings: {
|
||||
type: 'object',
|
||||
label: 'Session Learnings',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for session learning summaries.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Session Learnings',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically generate a session-learnings.md file when the session ends.',
|
||||
showInDialog: true,
|
||||
},
|
||||
outputPath: {
|
||||
type: 'string',
|
||||
label: 'Output Path',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Directory where session-learnings files should be saved. Defaults to project root.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getPlainTextLength } from './InlineMarkdownRenderer.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('getPlainTextLength', () => {
|
||||
it.each([
|
||||
['**Primary Go', 12],
|
||||
['*Primary Go', 11],
|
||||
['**Primary Go**', 10],
|
||||
['*Primary Go*', 10],
|
||||
['**', 2],
|
||||
['*', 1],
|
||||
['compile-time**', 14],
|
||||
])(
|
||||
'should measure markdown text length correctly for "%s"',
|
||||
(input, expected) => {
|
||||
expect(getPlainTextLength(input)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stringWidth from 'string-width';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
@@ -170,3 +171,19 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||
};
|
||||
|
||||
export const RenderInline = React.memo(RenderInlineInternal);
|
||||
|
||||
/**
|
||||
* Utility function to get the plain text length of a string with markdown formatting
|
||||
* This is useful for calculating column widths in tables
|
||||
*/
|
||||
export const getPlainTextLength = (text: string): number => {
|
||||
const cleanText = text
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/_(.*?)_/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/<u>(.*?)<\/u>/g, '$1')
|
||||
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
|
||||
return stringWidth(cleanText);
|
||||
};
|
||||
|
||||
@@ -363,171 +363,4 @@ Hidden`,
|
||||
expect(result.errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote agent auth configuration', () => {
|
||||
it('should parse remote agent with apiKey auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: api-key-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
in: header
|
||||
name: X-Custom-Key
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'api-key-agent',
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$MY_API_KEY',
|
||||
in: 'header',
|
||||
name: 'X-Custom-Key',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse remote agent with http Bearer auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: bearer-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
token: $BEARER_TOKEN
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'bearer-agent',
|
||||
auth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: '$BEARER_TOKEN',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse remote agent with http Basic auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: basic-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: $AUTH_USER
|
||||
password: $AUTH_PASS
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'basic-agent',
|
||||
auth: {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: '$AUTH_USER',
|
||||
password: '$AUTH_PASS',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for Bearer auth without token', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-bearer
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Bearer scheme requires "token"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for Basic auth without credentials', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-basic
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: user
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Basic scheme requires "username" and "password"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for apiKey auth without key', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-apikey
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/auth\.key.*Required/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert auth config in markdownToAgentDefinition', () => {
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'auth-agent',
|
||||
agent_card_url: 'https://example.com/card',
|
||||
auth: {
|
||||
type: 'apiKey' as const,
|
||||
key: '$API_KEY',
|
||||
in: 'header' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(markdown);
|
||||
expect(result).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'auth-agent',
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$API_KEY',
|
||||
location: 'header',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse auth with agent_card_requires_auth flag', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: protected-card-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
agent_card_requires_auth: true
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result[0]).toMatchObject({
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
agent_card_requires_auth: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -40,29 +39,11 @@ interface FrontmatterLocalAgentDefinition
|
||||
timeout_mins?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication configuration for remote agents in frontmatter format.
|
||||
*/
|
||||
interface FrontmatterAuthConfig {
|
||||
type: 'apiKey' | 'http';
|
||||
agent_card_requires_auth?: boolean;
|
||||
// API Key
|
||||
key?: string;
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
name?: string;
|
||||
// HTTP
|
||||
scheme?: 'Bearer' | 'Basic';
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterRemoteAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'remote';
|
||||
description?: string;
|
||||
agent_card_url: string;
|
||||
auth?: FrontmatterAuthConfig;
|
||||
}
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
@@ -114,66 +95,6 @@ const localAgentSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Base fields shared by all auth configs.
|
||||
*/
|
||||
const baseAuthFields = {
|
||||
agent_card_requires_auth: z.boolean().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* API Key auth schema.
|
||||
* Supports sending key in header, query parameter, or cookie.
|
||||
*/
|
||||
const apiKeyAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('apiKey'),
|
||||
key: z.string().min(1, 'API key is required'),
|
||||
in: z.enum(['header', 'query', 'cookie']).optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP auth schema (Bearer or Basic).
|
||||
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
||||
* since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const httpAuthSchemaBase = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('http'),
|
||||
scheme: z.enum(['Bearer', 'Basic']),
|
||||
token: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combined auth schema - discriminated union of all auth types.
|
||||
* Note: We use the base schema for discriminatedUnion, then apply refinements
|
||||
* via superRefine since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const authConfigSchema = z
|
||||
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase])
|
||||
.superRefine((data, ctx) => {
|
||||
// Apply HTTP auth validation after union parsing
|
||||
if (data.type === 'http') {
|
||||
if (data.scheme === 'Bearer' && !data.token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Bearer scheme requires "token"',
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
if (data.scheme === 'Basic' && (!data.username || !data.password)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Basic scheme requires "username" and "password"',
|
||||
path: data.username ? ['password'] : ['username'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const remoteAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
@@ -181,7 +102,6 @@ const remoteAgentSchema = z
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
agent_card_url: z.string().url(),
|
||||
auth: authConfigSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -318,76 +238,6 @@ export async function parseAgentMarkdown(
|
||||
return [agentDef];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts frontmatter auth config to the internal A2AAuthConfig type.
|
||||
* This handles the mapping from snake_case YAML to the internal type structure.
|
||||
*/
|
||||
function convertFrontmatterAuthToConfig(
|
||||
frontmatter: FrontmatterAuthConfig,
|
||||
): A2AAuthConfig {
|
||||
const base = {
|
||||
agent_card_requires_auth: frontmatter.agent_card_requires_auth,
|
||||
};
|
||||
|
||||
switch (frontmatter.type) {
|
||||
case 'apiKey':
|
||||
if (!frontmatter.key) {
|
||||
throw new Error('Internal error: API key missing after validation.');
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'apiKey',
|
||||
key: frontmatter.key,
|
||||
location: frontmatter.in,
|
||||
name: frontmatter.name,
|
||||
};
|
||||
|
||||
case 'http': {
|
||||
if (!frontmatter.scheme) {
|
||||
throw new Error(
|
||||
'Internal error: HTTP scheme missing after validation.',
|
||||
);
|
||||
}
|
||||
switch (frontmatter.scheme) {
|
||||
case 'Bearer':
|
||||
if (!frontmatter.token) {
|
||||
throw new Error(
|
||||
'Internal error: Bearer token missing after validation.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: frontmatter.token,
|
||||
};
|
||||
case 'Basic':
|
||||
if (!frontmatter.username || !frontmatter.password) {
|
||||
throw new Error(
|
||||
'Internal error: Basic auth credentials missing after validation.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: frontmatter.username,
|
||||
password: frontmatter.password,
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.scheme;
|
||||
throw new Error(`Unknown HTTP scheme: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.type;
|
||||
throw new Error(`Unknown auth type: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||
*
|
||||
@@ -420,9 +270,6 @@ export function markdownToAgentDefinition(
|
||||
description: markdown.description || '(Loading description...)',
|
||||
displayName: markdown.display_name,
|
||||
agentCardUrl: markdown.agent_card_url,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -9,33 +9,17 @@ import type { A2AAuthProvider, A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for A2A authentication providers.
|
||||
* Provides default implementations for optional methods.
|
||||
*/
|
||||
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
/**
|
||||
* The type of authentication provider.
|
||||
*/
|
||||
abstract readonly type: A2AAuthProviderType;
|
||||
|
||||
/**
|
||||
* Get the HTTP headers to include in requests.
|
||||
* Subclasses must implement this method.
|
||||
*/
|
||||
abstract headers(): Promise<HttpHeaders>;
|
||||
|
||||
private static readonly MAX_AUTH_RETRIES = 2;
|
||||
private authRetryCount = 0;
|
||||
|
||||
/**
|
||||
* Check if a request should be retried with new headers.
|
||||
*
|
||||
* The default implementation checks for 401/403 status codes and
|
||||
* returns fresh headers for retry. Subclasses can override for
|
||||
* custom retry logic.
|
||||
*
|
||||
* @param _req The original request init
|
||||
* @param res The response from the server
|
||||
* @returns New headers for retry, or undefined if no retry should be made
|
||||
* Default: retry on 401/403 with fresh headers.
|
||||
* Subclasses with cached tokens must override to force-refresh to avoid infinite retries.
|
||||
*/
|
||||
async shouldRetryWithHeaders(
|
||||
_req: RequestInit,
|
||||
@@ -48,15 +32,10 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
this.authRetryCount++;
|
||||
return this.headers();
|
||||
}
|
||||
// Reset count if not an auth error
|
||||
// Reset on success
|
||||
this.authRetryCount = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the provider. Override in subclasses that need async setup.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Default: no-op
|
||||
}
|
||||
async initialize(): Promise<void> {}
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
resolveAuthValue,
|
||||
needsResolution,
|
||||
maskSensitiveValue,
|
||||
} from './value-resolver.js';
|
||||
|
||||
describe('value-resolver', () => {
|
||||
describe('resolveAuthValue', () => {
|
||||
describe('environment variables', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should resolve environment variable with $ prefix', async () => {
|
||||
vi.stubEnv('TEST_API_KEY', 'secret-key-123');
|
||||
const result = await resolveAuthValue('$TEST_API_KEY');
|
||||
expect(result).toBe('secret-key-123');
|
||||
});
|
||||
|
||||
it('should throw error for unset environment variable', async () => {
|
||||
await expect(resolveAuthValue('$UNSET_VAR_12345')).rejects.toThrow(
|
||||
"Environment variable 'UNSET_VAR_12345' is not set or is empty",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty environment variable', async () => {
|
||||
vi.stubEnv('EMPTY_VAR', '');
|
||||
await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow(
|
||||
"Environment variable 'EMPTY_VAR' is not set or is empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell commands', () => {
|
||||
it('should execute shell command with ! prefix', async () => {
|
||||
const result = await resolveAuthValue('!echo hello');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('should trim whitespace from command output', async () => {
|
||||
const result = await resolveAuthValue('!echo " hello "');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('should throw error for empty command', async () => {
|
||||
await expect(resolveAuthValue('!')).rejects.toThrow(
|
||||
'Empty command in auth value',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for command that returns empty output', async () => {
|
||||
await expect(resolveAuthValue('!echo -n ""')).rejects.toThrow(
|
||||
'returned empty output',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for failed command', async () => {
|
||||
await expect(
|
||||
resolveAuthValue('!nonexistent-command-12345'),
|
||||
).rejects.toThrow(/Command.*failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('literal values', () => {
|
||||
it('should return literal value as-is', async () => {
|
||||
const result = await resolveAuthValue('literal-api-key');
|
||||
expect(result).toBe('literal-api-key');
|
||||
});
|
||||
|
||||
it('should return empty string as-is', async () => {
|
||||
const result = await resolveAuthValue('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should not treat values starting with other characters as special', async () => {
|
||||
const result = await resolveAuthValue('api-key-123');
|
||||
expect(result).toBe('api-key-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped literals', () => {
|
||||
it('should return $ literal when value starts with $$', async () => {
|
||||
const result = await resolveAuthValue('$$LITERAL');
|
||||
expect(result).toBe('$LITERAL');
|
||||
});
|
||||
|
||||
it('should return ! literal when value starts with !!', async () => {
|
||||
const result = await resolveAuthValue('!!not-a-command');
|
||||
expect(result).toBe('!not-a-command');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsResolution', () => {
|
||||
it('should return true for environment variable reference', () => {
|
||||
expect(needsResolution('$ENV_VAR')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for command reference', () => {
|
||||
expect(needsResolution('!command')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for literal value', () => {
|
||||
expect(needsResolution('literal')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(needsResolution('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskSensitiveValue', () => {
|
||||
it('should mask value longer than 12 characters', () => {
|
||||
expect(maskSensitiveValue('1234567890abcd')).toBe('12****cd');
|
||||
});
|
||||
|
||||
it('should return **** for short values', () => {
|
||||
expect(maskSensitiveValue('short')).toBe('****');
|
||||
});
|
||||
|
||||
it('should return **** for exactly 12 characters', () => {
|
||||
expect(maskSensitiveValue('123456789012')).toBe('****');
|
||||
});
|
||||
|
||||
it('should return **** for empty string', () => {
|
||||
expect(maskSensitiveValue('')).toBe('****');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { getShellConfiguration, spawnAsync } from '../../utils/shell-utils.js';
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolves a value that may be an environment variable reference,
|
||||
* a shell command, or a literal value.
|
||||
*
|
||||
* Supported formats:
|
||||
* - `$ENV_VAR`: Read from environment variable
|
||||
* - `!command`: Execute shell command and use output (trimmed)
|
||||
* - `$$` or `!!`: Escape prefix, returns rest as literal
|
||||
* - Any other string: Use as literal value
|
||||
*
|
||||
* @param value The value to resolve
|
||||
* @returns The resolved value
|
||||
* @throws Error if environment variable is not set or command fails
|
||||
*/
|
||||
export async function resolveAuthValue(value: string): Promise<string> {
|
||||
// Support escaping with double prefix (e.g. $$ or !!).
|
||||
// Strips one prefix char: $$FOO → $FOO, !!cmd → !cmd (literal, not resolved).
|
||||
if (value.startsWith('$$') || value.startsWith('!!')) {
|
||||
return value.slice(1);
|
||||
}
|
||||
|
||||
// Environment variable: $MY_VAR
|
||||
if (value.startsWith('$')) {
|
||||
const envVar = value.slice(1);
|
||||
const resolved = process.env[envVar];
|
||||
if (resolved === undefined || resolved === '') {
|
||||
throw new Error(
|
||||
`Environment variable '${envVar}' is not set or is empty. ` +
|
||||
`Please set it before using this agent.`,
|
||||
);
|
||||
}
|
||||
debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Shell command: !command arg1 arg2
|
||||
if (value.startsWith('!')) {
|
||||
const command = value.slice(1).trim();
|
||||
if (!command) {
|
||||
throw new Error('Empty command in auth value. Expected format: !command');
|
||||
}
|
||||
|
||||
debugLogger.debug(`[AuthValueResolver] Executing command for auth value`);
|
||||
|
||||
const shellConfig = getShellConfiguration();
|
||||
try {
|
||||
const { stdout } = await spawnAsync(
|
||||
shellConfig.executable,
|
||||
[...shellConfig.argsPrefix, command],
|
||||
{
|
||||
signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS),
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Command '${command}' returned empty output`);
|
||||
}
|
||||
return trimmed;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`Command '${command}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Literal value - return as-is
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value needs resolution (is an env var or command reference).
|
||||
*/
|
||||
export function needsResolution(value: string): boolean {
|
||||
return value.startsWith('$') || value.startsWith('!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a sensitive value for logging purposes.
|
||||
* Shows the first and last 2 characters with asterisks in between.
|
||||
*/
|
||||
export function maskSensitiveValue(value: string): string {
|
||||
if (value.length <= 12) {
|
||||
return '****';
|
||||
}
|
||||
return `${value.slice(0, 2)}****${value.slice(-2)}`;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ describe('Fallback Integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback for Gemini 3 models even if config is NOT in AUTO mode', () => {
|
||||
it('should NOT fallback if config is NOT in AUTO mode', () => {
|
||||
// 1. Config is explicitly set to Pro, not Auto
|
||||
vi.spyOn(config, 'getModel').mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('Fallback Integration', () => {
|
||||
// 4. Apply model selection
|
||||
const result = applyModelSelection(config, { model: requestedModel });
|
||||
|
||||
// 5. Expect it to fallback to Flash (because Gemini 3 uses PREVIEW_CHAIN)
|
||||
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
// 5. Expect it to stay on Pro (because single model chain)
|
||||
expect(result.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,19 +115,6 @@ describe('policyHelpers', () => {
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('proactively returns Gemini 2.5 chain if Gemini 3 requested but user lacks access', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'auto-gemini-3',
|
||||
getHasAccessToPreviewModel: () => false,
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
|
||||
// Should downgrade to [Pro 2.5, Flash 2.5]
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-pro');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFallbackPolicyContext', () => {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
isAutoModel,
|
||||
isGemini3Model,
|
||||
resolveModel,
|
||||
} from '../config/models.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
@@ -47,32 +46,17 @@ export function resolvePolicyChain(
|
||||
const resolvedModel = resolveModel(modelFromConfig);
|
||||
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel);
|
||||
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
// to the stable Gemini 2.5 chain.
|
||||
return getModelPolicyChain({
|
||||
previewEnabled: false,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
}
|
||||
} else if (isAutoPreferred || isAutoConfigured) {
|
||||
const previewEnabled =
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
|
||||
@@ -477,6 +477,8 @@ export interface ConfigParameters {
|
||||
experimentalJitContext?: boolean;
|
||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||
disableLLMCorrection?: boolean;
|
||||
sessionLearnings?: boolean;
|
||||
sessionLearningsOutputPath?: string;
|
||||
plan?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
mcpEnabled?: boolean;
|
||||
@@ -662,6 +664,8 @@ export class Config {
|
||||
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly sessionLearnings: boolean;
|
||||
private readonly sessionLearningsOutputPath: string | undefined;
|
||||
private readonly planEnabled: boolean;
|
||||
private contextManager?: ContextManager;
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
@@ -750,6 +754,8 @@ export class Config {
|
||||
this.enableAgents = params.enableAgents ?? false;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.sessionLearnings = params.sessionLearnings ?? false;
|
||||
this.sessionLearningsOutputPath = params.sessionLearningsOutputPath;
|
||||
this.planEnabled = params.plan ?? false;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
@@ -1953,6 +1959,14 @@ export class Config {
|
||||
return this.disableLLMCorrection;
|
||||
}
|
||||
|
||||
isSessionLearningsEnabled(): boolean {
|
||||
return this.sessionLearnings;
|
||||
}
|
||||
|
||||
getSessionLearningsOutputPath(): string | undefined {
|
||||
return this.sessionLearningsOutputPath;
|
||||
}
|
||||
|
||||
isPlanEnabled(): boolean {
|
||||
return this.planEnabled;
|
||||
}
|
||||
|
||||
@@ -96,12 +96,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-2.5-flash',
|
||||
},
|
||||
},
|
||||
'gemini-3-flash-base': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
classifier: {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
@@ -157,7 +151,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
'web-search': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
modelConfig: {
|
||||
generateContentConfig: {
|
||||
tools: [{ googleSearch: {} }],
|
||||
@@ -165,7 +159,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
'web-fetch': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
modelConfig: {
|
||||
generateContentConfig: {
|
||||
tools: [{ urlContext: {} }],
|
||||
@@ -174,25 +168,25 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
// TODO(joshualitt): During cleanup, make modelConfig optional.
|
||||
'web-fetch-fallback': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'loop-detection': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'loop-detection-double-check': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-3-pro-preview',
|
||||
model: 'gemini-2.5-pro',
|
||||
},
|
||||
},
|
||||
'llm-edit-fixer': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'next-speaker-checker': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'chat-compression-3-pro': {
|
||||
@@ -222,7 +216,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
'chat-compression-default': {
|
||||
modelConfig: {
|
||||
model: 'gemini-3-pro-preview',
|
||||
model: 'gemini-2.5-pro',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -519,10 +519,6 @@ exports[`Core System Prompt (prompts.ts) > should append userMemory with separat
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -650,10 +646,6 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -746,10 +738,6 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1311,10 +1299,6 @@ exports[`Core System Prompt (prompts.ts) > should include available_skills with
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1438,10 +1422,6 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1556,10 +1536,6 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1674,10 +1650,6 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1788,10 +1760,6 @@ exports[`Core System Prompt (prompts.ts) > should include planning phase suggest
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1902,10 +1870,6 @@ exports[`Core System Prompt (prompts.ts) > should include sub-agents in XML for
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2255,10 +2219,6 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2369,10 +2329,6 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2594,10 +2550,6 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2708,10 +2660,6 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('HookRegistry', () => {
|
||||
getDisabledHooks: vi.fn().mockReturnValue([]),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/project'),
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
hookRegistry = new HookRegistry(mockConfig);
|
||||
@@ -279,6 +280,21 @@ describe('HookRegistry', () => {
|
||||
hookRegistry.getHooksForEvent(HookEventName.BeforeTool),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should register builtin session-learnings hook when enabled', async () => {
|
||||
vi.mocked(mockConfig.isSessionLearningsEnabled).mockReturnValue(true);
|
||||
|
||||
await hookRegistry.initialize();
|
||||
|
||||
const hooks = hookRegistry.getHooksForEvent(HookEventName.SessionEnd);
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].config.type).toBe(HookType.Builtin);
|
||||
|
||||
expect((hooks[0].config as BuiltinHookConfig).builtin_id).toBe(
|
||||
'session-learnings',
|
||||
);
|
||||
expect(hooks[0].source).toBe(ConfigSource.System);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHooksForEvent', () => {
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { HookDefinition, HookConfig } from './types.js';
|
||||
import { HookEventName, ConfigSource, HOOKS_CONFIG_FIELDS } from './types.js';
|
||||
import {
|
||||
HookEventName,
|
||||
ConfigSource,
|
||||
HOOKS_CONFIG_FIELDS,
|
||||
HookType,
|
||||
} from './types.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { TrustedHooksManager } from './trustedHooks.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
@@ -137,6 +142,8 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
||||
this.checkProjectHooksTrust();
|
||||
}
|
||||
|
||||
this.registerBuiltinHooks();
|
||||
|
||||
// Get hooks from the main config (this comes from the merged settings)
|
||||
const configHooks = this.config.getHooks();
|
||||
if (configHooks) {
|
||||
@@ -161,6 +168,27 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register system-level builtin hooks
|
||||
*/
|
||||
private registerBuiltinHooks(): void {
|
||||
if (this.config.isSessionLearningsEnabled()) {
|
||||
debugLogger.debug('Registering builtin session-learnings hook');
|
||||
this.entries.push({
|
||||
config: {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
name: 'session-learnings',
|
||||
description: 'Automatically generate session learning summaries',
|
||||
source: ConfigSource.System,
|
||||
},
|
||||
source: ConfigSource.System,
|
||||
eventName: HookEventName.SessionEnd,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process hooks configuration and add entries
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookRunner } from './hookRunner.js';
|
||||
import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
SessionEndReason,
|
||||
type BuiltinHookConfig,
|
||||
type SessionEndInput,
|
||||
} from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
describe('HookRunner (Builtin Hooks)', () => {
|
||||
let hookRunner: HookRunner;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockConfig = {
|
||||
sanitizationConfig: {},
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
getConversation: vi.fn().mockReturnValue({ messages: [] }),
|
||||
getConversationFilePath: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/transcript.json'),
|
||||
}),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work'),
|
||||
} as unknown as Config;
|
||||
|
||||
hookRunner = new HookRunner(mockConfig);
|
||||
});
|
||||
|
||||
it('should execute session-learnings builtin hook on SessionEnd', async () => {
|
||||
const hookConfig: BuiltinHookConfig = {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
name: 'test-learnings',
|
||||
};
|
||||
|
||||
const input: SessionEndInput = {
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/tmp/transcript.json',
|
||||
cwd: '/work',
|
||||
hook_event_name: HookEventName.SessionEnd,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: SessionEndReason.Exit,
|
||||
};
|
||||
|
||||
// Spy on the service
|
||||
const serviceSpy = vi
|
||||
.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookRunner as any).sessionLearningsService,
|
||||
'generateAndSaveLearnings',
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await hookRunner.executeHook(
|
||||
hookConfig,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(serviceSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not execute session-learnings if reason is not exit/logout', async () => {
|
||||
const hookConfig: BuiltinHookConfig = {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
};
|
||||
|
||||
const input: SessionEndInput = {
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/tmp/transcript.json',
|
||||
cwd: '/work',
|
||||
hook_event_name: HookEventName.SessionEnd,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: SessionEndReason.Clear,
|
||||
};
|
||||
|
||||
const serviceSpy = vi.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookRunner as any).sessionLearningsService,
|
||||
'generateAndSaveLearnings',
|
||||
);
|
||||
|
||||
const result = await hookRunner.executeHook(
|
||||
hookConfig,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(serviceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { HookConfig } from './types.js';
|
||||
import { HookEventName, ConfigSource } from './types.js';
|
||||
import { HookEventName, ConfigSource, HookType } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type {
|
||||
HookInput,
|
||||
@@ -16,7 +16,9 @@ import type {
|
||||
BeforeModelInput,
|
||||
BeforeModelOutput,
|
||||
BeforeToolInput,
|
||||
SessionEndInput,
|
||||
} from './types.js';
|
||||
import { SessionEndReason } from './types.js';
|
||||
import type { LLMRequest } from './hookTranslator.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { sanitizeEnvironment } from '../services/environmentSanitization.js';
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
getShellConfiguration,
|
||||
type ShellType,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { SessionLearningsService } from '../services/sessionLearningsService.js';
|
||||
|
||||
/**
|
||||
* Default timeout for hook execution (60 seconds)
|
||||
@@ -43,9 +46,11 @@ const EXIT_CODE_NON_BLOCKING_ERROR = 1;
|
||||
*/
|
||||
export class HookRunner {
|
||||
private readonly config: Config;
|
||||
private readonly sessionLearningsService: SessionLearningsService;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.sessionLearningsService = new SessionLearningsService(config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,12 +81,25 @@ export class HookRunner {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
);
|
||||
if (hookConfig.type === HookType.Command) {
|
||||
return await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
);
|
||||
} else if (hookConfig.type === HookType.Builtin) {
|
||||
return await this.executeBuiltinHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported hook type: ${(hookConfig as HookConfig).type}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const hookId = hookConfig.name || hookConfig.command || 'unknown';
|
||||
@@ -231,6 +249,52 @@ export class HookRunner {
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a builtin hook
|
||||
*/
|
||||
private async executeBuiltinHook(
|
||||
hookConfig: HookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
startTime: number,
|
||||
): Promise<HookExecutionResult> {
|
||||
if (hookConfig.type !== HookType.Builtin) {
|
||||
throw new Error('Expected builtin hook configuration');
|
||||
}
|
||||
|
||||
try {
|
||||
if (hookConfig.builtin_id === 'session-learnings') {
|
||||
if (eventName === HookEventName.SessionEnd) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const sessionEndInput = input as SessionEndInput;
|
||||
if (
|
||||
sessionEndInput.reason === SessionEndReason.Exit ||
|
||||
sessionEndInput.reason === SessionEndReason.Logout
|
||||
) {
|
||||
await this.sessionLearningsService.generateAndSaveLearnings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
exitCode: EXIT_CODE_SUCCESS,
|
||||
output: {},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command hook
|
||||
*/
|
||||
|
||||
@@ -62,7 +62,16 @@ export interface CommandHookConfig {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HookConfig = CommandHookConfig;
|
||||
export interface BuiltinHookConfig {
|
||||
type: HookType.Builtin;
|
||||
builtin_id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
source?: ConfigSource;
|
||||
}
|
||||
|
||||
export type HookConfig = CommandHookConfig | BuiltinHookConfig;
|
||||
|
||||
/**
|
||||
* Hook definition with matcher
|
||||
@@ -78,6 +87,7 @@ export interface HookDefinition {
|
||||
*/
|
||||
export enum HookType {
|
||||
Command = 'command',
|
||||
Builtin = 'builtin',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,8 +95,9 @@ export enum HookType {
|
||||
*/
|
||||
export function getHookKey(hook: HookConfig): string {
|
||||
const name = hook.name || '';
|
||||
const command = hook.command || '';
|
||||
return `${name}:${command}`;
|
||||
const identifier =
|
||||
hook.type === HookType.Command ? hook.command : hook.builtin_id;
|
||||
return `${name}:${identifier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -164,10 +164,6 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string {
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in ${formattedFilenames} files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
|
||||
@@ -46,6 +46,9 @@ describe('sanitizeEnvironment', () => {
|
||||
CLIENT_ID: 'sensitive-id',
|
||||
DB_URI: 'sensitive-uri',
|
||||
DATABASE_URL: 'sensitive-url',
|
||||
GEMINI_API_KEY: 'sensitive-gemini-key',
|
||||
GOOGLE_API_KEY: 'sensitive-google-key',
|
||||
GOOGLE_APPLICATION_CREDENTIALS: '/path/to/creds.json',
|
||||
SAFE_VAR: 'is-safe',
|
||||
};
|
||||
const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS);
|
||||
|
||||
@@ -103,6 +103,9 @@ export const NEVER_ALLOWED_ENVIRONMENT_VARIABLES: ReadonlySet<string> = new Set(
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_ACCOUNT',
|
||||
'FIREBASE_PROJECT_ID',
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionLearningsService } from './sessionLearningsService.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('SessionLearningsService', () => {
|
||||
let service: SessionLearningsService;
|
||||
let mockConfig: unknown;
|
||||
let mockRecordingService: any;
|
||||
let mockGeminiClient: any;
|
||||
let mockContentGenerator: any;
|
||||
let mockGenerateContent: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGenerateContent = vi.fn().mockImplementation((_params, promptId) => {
|
||||
if (promptId === 'session-learnings-generation') {
|
||||
return Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: '# Session Learnings\nSummary text here.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
} else if (promptId === 'session-summary-generation') {
|
||||
return Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Mock Session Title' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected promptId: ${promptId}`));
|
||||
});
|
||||
|
||||
mockContentGenerator = {
|
||||
generateContent: mockGenerateContent,
|
||||
};
|
||||
|
||||
mockRecordingService = {
|
||||
getConversation: vi.fn().mockReturnValue({
|
||||
messages: [
|
||||
{ type: 'user', content: [{ text: 'Question' }] },
|
||||
{ type: 'gemini', content: [{ text: 'Answer' }] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mockGeminiClient = {
|
||||
getChatRecordingService: () => mockRecordingService,
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionLearningsOutputPath: vi.fn().mockReturnValue(undefined),
|
||||
getGeminiClient: () => mockGeminiClient,
|
||||
getContentGenerator: () => mockContentGenerator,
|
||||
getWorkingDir: () => '/mock/cwd',
|
||||
getActiveModel: () => 'gemini-1.5-flash',
|
||||
getModel: () => 'gemini-1.5-flash',
|
||||
isInteractive: () => true,
|
||||
setActiveModel: vi.fn(),
|
||||
getUserTier: () => 'free',
|
||||
getContentGeneratorConfig: () => ({ authType: 'apiKey' }),
|
||||
getModelAvailabilityService: () => ({
|
||||
selectFirstAvailable: (models: string[]) => ({
|
||||
selectedModel: models[0],
|
||||
}),
|
||||
consumeStickyAttempt: vi.fn(),
|
||||
markHealthy: vi.fn(),
|
||||
}),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi
|
||||
.fn()
|
||||
.mockReturnValue({ model: 'gemini-1.5-flash', config: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
service = new SessionLearningsService(mockConfig as Config);
|
||||
|
||||
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should generate and save learnings with descriptive filename', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/mock/cwd', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom output path if configured', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||
'custom/path',
|
||||
);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
path.join('/mock/cwd', 'custom/path'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join(
|
||||
'/mock/cwd',
|
||||
'custom/path',
|
||||
`learnings-Mock-Session-Title-${dateStr}.md`,
|
||||
),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use absolute output path if configured', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||
'/absolute/path',
|
||||
);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith('/absolute/path', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/absolute/path', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not generate learnings if disabled', async () => {
|
||||
(mockConfig as any).isSessionLearningsEnabled.mockReturnValue(false);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not generate learnings if not enough messages', async () => {
|
||||
mockRecordingService.getConversation.mockReturnValue({
|
||||
messages: [{ type: 'user', content: [{ text: 'Single message' }] }],
|
||||
});
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('LLM Error'));
|
||||
|
||||
// Should not throw
|
||||
await expect(service.generateAndSaveLearnings()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { SessionSummaryService } from './sessionSummaryService.js';
|
||||
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const MIN_MESSAGES = 2;
|
||||
const TIMEOUT_MS = 60000; // Increased timeout for potentially larger context
|
||||
|
||||
const LEARNINGS_PROMPT = `It's time to pause on this development. Looking back at what you have done so far:
|
||||
Prepare a summary of the problem you were trying to solve, the analysis synthesized, and information you would need to implement this request if you were to start again
|
||||
Don't focus on unnecessary details - keep the abstraction at a level that allows a senior engineer for example, to take it from you.
|
||||
Do focus on gotchas, explored paths that didn't go anywhere with a why, and what you'd do differently.
|
||||
Also note down other issues you might have found as future project ideas.
|
||||
|
||||
Conversation transcript follows:
|
||||
---
|
||||
{transcript}
|
||||
---
|
||||
|
||||
Provide your response in Markdown format.`;
|
||||
|
||||
/**
|
||||
* Service to generate and save session learnings summaries.
|
||||
*/
|
||||
export class SessionLearningsService {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
/**
|
||||
* Generates a summary of the session learnings and saves it to a file.
|
||||
*/
|
||||
async generateAndSaveLearnings(): Promise<void> {
|
||||
try {
|
||||
// Check if enabled in settings
|
||||
if (!this.config.isSessionLearningsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
const recordingService = geminiClient.getChatRecordingService();
|
||||
|
||||
if (!recordingService) {
|
||||
debugLogger.debug('[SessionLearnings] Recording service not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = recordingService.getConversation();
|
||||
|
||||
if (!conversation || conversation.messages.length < MIN_MESSAGES) {
|
||||
debugLogger.debug(
|
||||
`[SessionLearnings] Skipping summary, not enough messages (${conversation?.messages.length || 0})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare transcript (no max messages, no max length)
|
||||
const transcript = conversation.messages
|
||||
.map((msg) => {
|
||||
const role = msg.type === 'user' ? 'User' : 'Assistant';
|
||||
const content = partListUnionToString(msg.content);
|
||||
return `[${role}]: ${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = LEARNINGS_PROMPT.replace('{transcript}', transcript);
|
||||
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
debugLogger.debug('[SessionLearnings] Content generator not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, this.config);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
];
|
||||
|
||||
debugLogger.debug('[SessionLearnings] Generating summary...');
|
||||
const response = await baseLlmClient.generateContent({
|
||||
modelConfigKey: { model: 'summarizer-default' },
|
||||
contents,
|
||||
abortSignal: abortController.signal,
|
||||
promptId: 'session-learnings-generation',
|
||||
});
|
||||
|
||||
const summaryText = getResponseText(response);
|
||||
if (!summaryText) {
|
||||
debugLogger.warn(
|
||||
'[SessionLearnings] Failed to generate summary (empty response)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate descriptive filename
|
||||
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||
const sessionTitle = await summaryService.generateSummary({
|
||||
messages: conversation.messages,
|
||||
});
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const sanitizedTitle = sessionTitle
|
||||
? sanitizeFilenamePart(sessionTitle.trim().replace(/\s+/g, '-'))
|
||||
: 'untitled';
|
||||
|
||||
const fileName = `learnings-${sanitizedTitle}-${dateStr}.md`;
|
||||
|
||||
// Determine output directory
|
||||
const configOutputPath = this.config.getSessionLearningsOutputPath();
|
||||
let outputDir = this.config.getWorkingDir();
|
||||
|
||||
if (configOutputPath) {
|
||||
if (path.isAbsolute(configOutputPath)) {
|
||||
outputDir = configOutputPath;
|
||||
} else {
|
||||
outputDir = path.join(
|
||||
this.config.getWorkingDir(),
|
||||
configOutputPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
await fs.writeFile(filePath, summaryText, 'utf-8');
|
||||
debugLogger.log(
|
||||
`[SessionLearnings] Saved session learnings to ${filePath}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[SessionLearnings] Error generating learnings: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,13 +104,6 @@
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
@@ -160,7 +153,7 @@
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -172,7 +165,7 @@
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -184,35 +177,35 @@
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"model": "gemini-3-pro-preview",
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
@@ -239,7 +232,7 @@
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"model": "gemini-3-pro-preview",
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,13 +104,6 @@
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
@@ -160,7 +153,7 @@
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -172,7 +165,7 @@
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -184,35 +177,35 @@
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"model": "gemini-3-pro-preview",
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"model": "gemini-2.5-flash",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
@@ -239,7 +232,7 @@
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"model": "gemini-3-pro-preview",
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {}
|
||||
}
|
||||
}
|
||||
|
||||
-16
@@ -45,10 +45,6 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
|
||||
"description": "Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.",
|
||||
"type": "string",
|
||||
},
|
||||
"exclude_pattern": {
|
||||
"description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.",
|
||||
"type": "string",
|
||||
},
|
||||
"include": {
|
||||
"description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
|
||||
"type": "string",
|
||||
@@ -58,10 +54,6 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
"names_only": {
|
||||
"description": "Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.",
|
||||
"type": "boolean",
|
||||
},
|
||||
"pattern": {
|
||||
"description": "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
|
||||
"type": "string",
|
||||
@@ -262,10 +254,6 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
"description": "Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.",
|
||||
"type": "string",
|
||||
},
|
||||
"exclude_pattern": {
|
||||
"description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.",
|
||||
"type": "string",
|
||||
},
|
||||
"include": {
|
||||
"description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
|
||||
"type": "string",
|
||||
@@ -275,10 +263,6 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
"names_only": {
|
||||
"description": "Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.",
|
||||
"type": "boolean",
|
||||
},
|
||||
"pattern": {
|
||||
"description": "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
|
||||
"type": "string",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { ToolDefinition } from './types.js';
|
||||
import { Type } from '@google/genai';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Centralized tool names to avoid circular dependencies
|
||||
@@ -24,21 +25,21 @@ export const READ_FILE_DEFINITION: ToolDefinition = {
|
||||
name: READ_FILE_TOOL_NAME,
|
||||
description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
file_path: {
|
||||
description: 'The path to the file to read.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
offset: {
|
||||
description:
|
||||
"Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
|
||||
type: 'number',
|
||||
type: Type.NUMBER,
|
||||
},
|
||||
limit: {
|
||||
description:
|
||||
"Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",
|
||||
type: 'number',
|
||||
type: Type.NUMBER,
|
||||
},
|
||||
},
|
||||
required: ['file_path'],
|
||||
@@ -57,15 +58,15 @@ export const WRITE_FILE_DEFINITION: ToolDefinition = {
|
||||
|
||||
The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
file_path: {
|
||||
description: 'The path to the file to write to.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
content: {
|
||||
description: 'The content to write to the file.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
},
|
||||
required: ['file_path', 'content'],
|
||||
@@ -83,41 +84,31 @@ export const GREP_DEFINITION: ToolDefinition = {
|
||||
description:
|
||||
'Searches for a regular expression pattern within file contents. Max 100 matches.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
pattern: {
|
||||
description: `The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').`,
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
dir_path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
include: {
|
||||
description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`,
|
||||
type: 'string',
|
||||
},
|
||||
exclude_pattern: {
|
||||
description:
|
||||
'Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
names_only: {
|
||||
description:
|
||||
'Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.',
|
||||
type: 'boolean',
|
||||
type: Type.STRING,
|
||||
},
|
||||
max_matches_per_file: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.',
|
||||
type: 'integer',
|
||||
type: Type.INTEGER,
|
||||
minimum: 1,
|
||||
},
|
||||
total_max_matches: {
|
||||
description:
|
||||
'Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.',
|
||||
type: 'integer',
|
||||
type: Type.INTEGER,
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
@@ -136,32 +127,32 @@ export const GLOB_DEFINITION: ToolDefinition = {
|
||||
description:
|
||||
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
pattern: {
|
||||
description:
|
||||
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
dir_path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
case_sensitive: {
|
||||
description:
|
||||
'Optional: Whether the search should be case-sensitive. Defaults to false.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when finding files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
@@ -179,33 +170,33 @@ export const LS_DEFINITION: ToolDefinition = {
|
||||
description:
|
||||
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
dir_path: {
|
||||
description: 'The path to the directory to list',
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
ignore: {
|
||||
description: 'List of glob patterns to ignore',
|
||||
items: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
},
|
||||
type: 'array',
|
||||
type: Type.ARRAY,
|
||||
},
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -283,24 +274,24 @@ export function getShellDefinition(
|
||||
enableEfficiency,
|
||||
),
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
description: getCommandDescription(),
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
description:
|
||||
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
|
||||
},
|
||||
dir_path: {
|
||||
type: 'string',
|
||||
type: Type.STRING,
|
||||
description:
|
||||
'(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
|
||||
},
|
||||
is_background: {
|
||||
type: 'boolean',
|
||||
type: Type.BOOLEAN,
|
||||
description:
|
||||
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
|
||||
},
|
||||
|
||||
@@ -498,41 +498,6 @@ describe('GrepTool', () => {
|
||||
expect(result.llmContent).toContain('File: sub/fileC.txt');
|
||||
expect(result.llmContent).toContain('L1: another world in sub dir');
|
||||
});
|
||||
|
||||
it('should return only file paths when names_only is true', async () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'world',
|
||||
names_only: true,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 files with matches');
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).toContain('sub/fileC.txt');
|
||||
expect(result.llmContent).not.toContain('L1:');
|
||||
expect(result.llmContent).not.toContain('hello world');
|
||||
});
|
||||
|
||||
it('should filter out matches based on exclude_pattern', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'copyright.txt'),
|
||||
'Copyright 2025 Google LLC\nCopyright 2026 Google LLC',
|
||||
);
|
||||
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'Copyright .* Google LLC',
|
||||
exclude_pattern: '2026',
|
||||
dir_path: '.',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('copyright.txt');
|
||||
expect(result.llmContent).toContain('Copyright 2025 Google LLC');
|
||||
expect(result.llmContent).not.toContain('Copyright 2026 Google LLC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
|
||||
@@ -49,16 +49,6 @@ export interface GrepToolParams {
|
||||
*/
|
||||
include?: string;
|
||||
|
||||
/**
|
||||
* Optional: A regular expression pattern to exclude from the search results.
|
||||
*/
|
||||
exclude_pattern?: string;
|
||||
|
||||
/**
|
||||
* Optional: If true, only the file paths of the matches will be returned.
|
||||
*/
|
||||
names_only?: boolean;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.
|
||||
*/
|
||||
@@ -235,7 +225,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: this.params.pattern,
|
||||
path: searchDir,
|
||||
include: this.params.include,
|
||||
exclude_pattern: this.params.exclude_pattern,
|
||||
maxMatches: remainingLimit,
|
||||
max_matches_per_file: this.params.max_matches_per_file,
|
||||
signal: timeoutController.signal,
|
||||
@@ -291,16 +280,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
const matchCount = allMatches.length;
|
||||
const matchTerm = matchCount === 1 ? 'match' : 'matches';
|
||||
|
||||
if (this.params.names_only) {
|
||||
const filePaths = Object.keys(matchesByFile).sort();
|
||||
let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`;
|
||||
llmContent += filePaths.join('\n');
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`;
|
||||
|
||||
if (wasTruncated) {
|
||||
@@ -375,7 +354,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: string;
|
||||
path: string; // Expects absolute path
|
||||
include?: string;
|
||||
exclude_pattern?: string;
|
||||
maxMatches: number;
|
||||
max_matches_per_file?: number;
|
||||
signal: AbortSignal;
|
||||
@@ -384,18 +362,12 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern,
|
||||
path: absolutePath,
|
||||
include,
|
||||
exclude_pattern,
|
||||
maxMatches,
|
||||
max_matches_per_file,
|
||||
} = options;
|
||||
let strategyUsed = 'none';
|
||||
|
||||
try {
|
||||
let excludeRegex: RegExp | null = null;
|
||||
if (exclude_pattern) {
|
||||
excludeRegex = new RegExp(exclude_pattern, 'i');
|
||||
}
|
||||
|
||||
// --- Strategy 1: git grep ---
|
||||
const isGit = isGitRepository(absolutePath);
|
||||
const gitAvailable = isGit && (await this.isCommandAvailable('git'));
|
||||
@@ -428,9 +400,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
for await (const line of generator) {
|
||||
const match = this.parseGrepLine(line, absolutePath);
|
||||
if (match) {
|
||||
if (excludeRegex && excludeRegex.test(match.line)) {
|
||||
continue;
|
||||
}
|
||||
results.push(match);
|
||||
if (results.length >= maxMatches) {
|
||||
break;
|
||||
@@ -498,9 +467,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
for await (const line of generator) {
|
||||
const match = this.parseGrepLine(line, absolutePath);
|
||||
if (match) {
|
||||
if (excludeRegex && excludeRegex.test(match.line)) {
|
||||
continue;
|
||||
}
|
||||
results.push(match);
|
||||
if (results.length >= maxMatches) {
|
||||
break;
|
||||
@@ -562,9 +528,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
if (regex.test(line)) {
|
||||
if (excludeRegex && excludeRegex.test(line)) {
|
||||
continue;
|
||||
}
|
||||
allMatches.push({
|
||||
filePath:
|
||||
path.relative(absolutePath, fileAbsolutePath) ||
|
||||
@@ -674,14 +637,6 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
|
||||
if (params.exclude_pattern) {
|
||||
try {
|
||||
new RegExp(params.exclude_pattern);
|
||||
} catch (error) {
|
||||
return `Invalid exclude regular expression pattern provided: ${params.exclude_pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.max_matches_per_file !== undefined &&
|
||||
params.max_matches_per_file < 1
|
||||
|
||||
@@ -1623,7 +1623,7 @@ describe('mcp-client', () => {
|
||||
{
|
||||
command: 'test-command',
|
||||
args: ['--foo', 'bar'],
|
||||
env: { FOO: 'bar' },
|
||||
env: { GEMINI_CLI_FOO: 'bar' },
|
||||
cwd: 'test/cwd',
|
||||
},
|
||||
false,
|
||||
@@ -1634,11 +1634,80 @@ describe('mcp-client', () => {
|
||||
command: 'test-command',
|
||||
args: ['--foo', 'bar'],
|
||||
cwd: 'test/cwd',
|
||||
env: expect.objectContaining({ FOO: 'bar' }),
|
||||
env: expect.objectContaining({ GEMINI_CLI_FOO: 'bar' }),
|
||||
stderr: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
it('should redact sensitive environment variables for command transport', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
||||
|
||||
const originalEnv = process.env;
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
GEMINI_API_KEY: 'sensitive-key',
|
||||
GEMINI_CLI_SAFE_VAR: 'safe-value',
|
||||
};
|
||||
// Ensure strict sanitization is not triggered for this test
|
||||
delete process.env['GITHUB_SHA'];
|
||||
delete process.env['SURFACE'];
|
||||
|
||||
try {
|
||||
await createTransport(
|
||||
'test-server',
|
||||
{
|
||||
command: 'test-command',
|
||||
},
|
||||
false,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
const callArgs = mockedTransport.mock.calls[0][0];
|
||||
expect(callArgs.env).toBeDefined();
|
||||
expect(callArgs.env!['GEMINI_CLI_SAFE_VAR']).toBe('safe-value');
|
||||
expect(callArgs.env!['GEMINI_API_KEY']).toBeUndefined();
|
||||
} finally {
|
||||
process.env = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should include extension settings in environment', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
||||
|
||||
await createTransport(
|
||||
'test-server',
|
||||
{
|
||||
command: 'test-command',
|
||||
extension: {
|
||||
name: 'test-ext',
|
||||
resolvedSettings: [
|
||||
{
|
||||
envVar: 'GEMINI_CLI_EXT_VAR',
|
||||
value: 'ext-value',
|
||||
sensitive: false,
|
||||
name: 'ext-setting',
|
||||
},
|
||||
],
|
||||
version: '',
|
||||
isActive: false,
|
||||
path: '',
|
||||
contextFiles: [],
|
||||
id: '',
|
||||
},
|
||||
},
|
||||
false,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
const callArgs = mockedTransport.mock.calls[0][0];
|
||||
expect(callArgs.env).toBeDefined();
|
||||
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
|
||||
});
|
||||
|
||||
it('should exclude extension settings with undefined values from environment', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { ApprovalMode, PolicyDecision } from '../policy/types.js';
|
||||
import { parse } from 'shell-quote';
|
||||
import type { Config, MCPServerConfig } from '../config/config.js';
|
||||
import type {
|
||||
Config,
|
||||
GeminiCLIExtension,
|
||||
MCPServerConfig,
|
||||
} from '../config/config.js';
|
||||
import { AuthProviderType } from '../config/config.js';
|
||||
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
|
||||
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
|
||||
@@ -1898,10 +1902,23 @@ export async function createTransport(
|
||||
command: mcpServerConfig.command,
|
||||
args: mcpServerConfig.args || [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
env: {
|
||||
...sanitizeEnvironment(process.env, sanitizationConfig),
|
||||
...(mcpServerConfig.env || {}),
|
||||
} as Record<string, string>,
|
||||
env: sanitizeEnvironment(
|
||||
{
|
||||
...process.env,
|
||||
...getExtensionEnvironment(mcpServerConfig.extension),
|
||||
...(mcpServerConfig.env || {}),
|
||||
},
|
||||
{
|
||||
...sanitizationConfig,
|
||||
allowedEnvironmentVariables: [
|
||||
...(sanitizationConfig.allowedEnvironmentVariables ?? []),
|
||||
...(mcpServerConfig.extension?.resolvedSettings?.map(
|
||||
(s) => s.envVar,
|
||||
) ?? []),
|
||||
],
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
) as Record<string, string>,
|
||||
cwd: mcpServerConfig.cwd,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
@@ -1976,3 +1993,17 @@ export function isEnabled(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensionEnvironment(
|
||||
extension?: GeminiCLIExtension,
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (extension?.resolvedSettings) {
|
||||
for (const setting of extension.resolvedSettings) {
|
||||
if (setting.value) {
|
||||
env[setting.envVar] = setting.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -1930,85 +1930,6 @@ describe('RipGrepTool', () => {
|
||||
expect(result.llmContent).not.toContain('L3: match 3');
|
||||
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
|
||||
});
|
||||
|
||||
it('should return only file paths when names_only is true', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData:
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'hello world\n' },
|
||||
},
|
||||
}) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileB.txt' },
|
||||
line_number: 5,
|
||||
lines: { text: 'hello again\n' },
|
||||
},
|
||||
}) +
|
||||
'\n',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'hello',
|
||||
names_only: true,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 files with matches');
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).toContain('fileB.txt');
|
||||
expect(result.llmContent).not.toContain('L1:');
|
||||
expect(result.llmContent).not.toContain('hello world');
|
||||
});
|
||||
|
||||
it('should filter out matches based on exclude_pattern', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData:
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'Copyright 2025 Google LLC\n' },
|
||||
},
|
||||
}) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileB.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'Copyright 2026 Google LLC\n' },
|
||||
},
|
||||
}) +
|
||||
'\n',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'Copyright .* Google LLC',
|
||||
exclude_pattern: '2026',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).not.toContain('fileB.txt');
|
||||
expect(result.llmContent).toContain('Copyright 2025 Google LLC');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -102,16 +102,6 @@ export interface RipGrepToolParams {
|
||||
*/
|
||||
include?: string;
|
||||
|
||||
/**
|
||||
* Optional: A regular expression pattern to exclude from the search results.
|
||||
*/
|
||||
exclude_pattern?: string;
|
||||
|
||||
/**
|
||||
* Optional: If true, only the file paths of the matches will be returned.
|
||||
*/
|
||||
names_only?: boolean;
|
||||
|
||||
/**
|
||||
* If true, searches case-sensitively. Defaults to false.
|
||||
*/
|
||||
@@ -254,7 +244,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: this.params.pattern,
|
||||
path: searchDirAbs,
|
||||
include: this.params.include,
|
||||
exclude_pattern: this.params.exclude_pattern,
|
||||
case_sensitive: this.params.case_sensitive,
|
||||
fixed_strings: this.params.fixed_strings,
|
||||
context: this.params.context,
|
||||
@@ -310,16 +299,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const wasTruncated = matchCount >= totalMaxMatches;
|
||||
|
||||
if (this.params.names_only) {
|
||||
const filePaths = Object.keys(matchesByFile).sort();
|
||||
let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`;
|
||||
llmContent += filePaths.join('\n');
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n---\n`;
|
||||
|
||||
for (const filePath in matchesByFile) {
|
||||
@@ -351,7 +330,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: string;
|
||||
path: string;
|
||||
include?: string;
|
||||
exclude_pattern?: string;
|
||||
case_sensitive?: boolean;
|
||||
fixed_strings?: boolean;
|
||||
context?: number;
|
||||
@@ -366,7 +344,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern,
|
||||
path: absolutePath,
|
||||
include,
|
||||
exclude_pattern,
|
||||
case_sensitive,
|
||||
fixed_strings,
|
||||
context,
|
||||
@@ -446,18 +423,9 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
});
|
||||
|
||||
let matchesFound = 0;
|
||||
let excludeRegex: RegExp | null = null;
|
||||
if (exclude_pattern) {
|
||||
excludeRegex = new RegExp(exclude_pattern, case_sensitive ? '' : 'i');
|
||||
}
|
||||
|
||||
for await (const line of generator) {
|
||||
const match = this.parseRipgrepJsonLine(line, absolutePath);
|
||||
if (match) {
|
||||
if (excludeRegex && excludeRegex.test(match.line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(match);
|
||||
if (!match.isContext) {
|
||||
matchesFound++;
|
||||
@@ -559,7 +527,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
super(
|
||||
RipGrepTool.Name,
|
||||
'SearchText',
|
||||
'Searches for a regular expression pattern within file contents.',
|
||||
'Searches for a regular expression pattern within file contents. Max 100 matches.',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
@@ -578,16 +546,6 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
"Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.",
|
||||
type: 'string',
|
||||
},
|
||||
exclude_pattern: {
|
||||
description:
|
||||
'Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
names_only: {
|
||||
description:
|
||||
'Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.',
|
||||
type: 'boolean',
|
||||
},
|
||||
case_sensitive: {
|
||||
description:
|
||||
'If true, search is case-sensitive. Defaults to false (ignore case) if omitted.',
|
||||
@@ -607,13 +565,11 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
description:
|
||||
'Show this many lines after each match (equivalent to grep -A). Defaults to 0 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
},
|
||||
before: {
|
||||
description:
|
||||
'Show this many lines before each match (equivalent to grep -B). Defaults to 0 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
},
|
||||
no_ignore: {
|
||||
description:
|
||||
@@ -662,14 +618,6 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
}
|
||||
}
|
||||
|
||||
if (params.exclude_pattern) {
|
||||
try {
|
||||
new RegExp(params.exclude_pattern);
|
||||
} catch (error) {
|
||||
return `Invalid exclude regular expression pattern provided: ${params.exclude_pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.max_matches_per_file !== undefined &&
|
||||
params.max_matches_per_file < 1
|
||||
|
||||
File diff suppressed because one or more lines are too long
+15
-19
@@ -12,7 +12,6 @@ import { execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
const artifactsDir = process.argv[2] || '.';
|
||||
const outputFile = process.argv[3];
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
// Find all report.json files recursively
|
||||
@@ -146,8 +145,10 @@ function fetchHistoricalData() {
|
||||
}
|
||||
|
||||
function generateMarkdown(currentStatsByModel, history) {
|
||||
let md = '### Evals Nightly Summary\n\n';
|
||||
md += 'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n\n';
|
||||
console.log('### Evals Nightly Summary\n');
|
||||
console.log(
|
||||
'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n',
|
||||
);
|
||||
|
||||
// Reverse history to show oldest first
|
||||
const reversedHistory = [...history].reverse();
|
||||
@@ -170,8 +171,8 @@ function generateMarkdown(currentStatsByModel, history) {
|
||||
? ((totalStats.passed / totalStats.total) * 100).toFixed(1) + '%'
|
||||
: 'N/A';
|
||||
|
||||
md += `#### Model: ${model}\n`;
|
||||
md += `**Total Pass Rate: ${totalPassRate}**\n\n`;
|
||||
console.log(`#### Model: ${model}`);
|
||||
console.log(`**Total Pass Rate: ${totalPassRate}**\n`);
|
||||
|
||||
// Header
|
||||
let header = '| Test Name |';
|
||||
@@ -186,8 +187,8 @@ function generateMarkdown(currentStatsByModel, history) {
|
||||
header += ' Current |';
|
||||
separator += ' :---: |';
|
||||
|
||||
md += header + '\n';
|
||||
md += separator + '\n';
|
||||
console.log(header);
|
||||
console.log(separator);
|
||||
|
||||
// Collect all test names for this model
|
||||
const allTestNames = new Set(Object.keys(currentStats));
|
||||
@@ -223,28 +224,23 @@ function generateMarkdown(currentStatsByModel, history) {
|
||||
row += ' - |';
|
||||
}
|
||||
|
||||
md += row + '\n';
|
||||
console.log(row);
|
||||
}
|
||||
md += '\n';
|
||||
console.log('\n');
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const currentReports = findReports(artifactsDir);
|
||||
if (currentReports.length === 0) {
|
||||
console.error('No reports found.');
|
||||
console.log('No reports found.');
|
||||
// We don't exit here because we might still want to see history if available,
|
||||
// but practically if current has no reports, something is wrong.
|
||||
// Sticking to original behavior roughly, but maybe we can continue.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const currentStats = getStats(currentReports);
|
||||
const history = fetchHistoricalData();
|
||||
const markdown = generateMarkdown(currentStats, history);
|
||||
|
||||
if (outputFile) {
|
||||
fs.writeFileSync(outputFile, markdown);
|
||||
console.log(`Summary written to ${outputFile}`);
|
||||
} else {
|
||||
console.log(markdown);
|
||||
}
|
||||
generateMarkdown(currentStats, history);
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const models = [
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
];
|
||||
|
||||
const artifactsDir = path.resolve('artifacts');
|
||||
const logsDir = path.resolve('evals/logs');
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
let testPattern = '';
|
||||
let attempts = 1;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--attempts') {
|
||||
attempts = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
testPattern = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure GEMINI_API_KEY is set
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
console.error('Error: GEMINI_API_KEY environment variable is not set.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prepare artifacts directory
|
||||
if (fs.existsSync(artifactsDir)) {
|
||||
console.log(`Cleaning artifacts directory: ${artifactsDir}`);
|
||||
fs.rmSync(artifactsDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(artifactsDir);
|
||||
|
||||
// Build project
|
||||
console.log('Building project...');
|
||||
try {
|
||||
execSync('npm run build', { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
console.error('Build failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Starting evals with ${attempts} attempt(s) per model.`);
|
||||
|
||||
for (const model of models) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
console.log(`
|
||||
--------------------------------------------------`);
|
||||
console.log(`Running evals for ${model} (Attempt ${attempt}/${attempts})`);
|
||||
console.log(`--------------------------------------------------
|
||||
`);
|
||||
|
||||
// Clean logs directory for this run
|
||||
if (fs.existsSync(logsDir)) {
|
||||
fs.rmSync(logsDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Construct command
|
||||
let cmd = 'npm run test:all_evals';
|
||||
if (testPattern) {
|
||||
if (
|
||||
testPattern.endsWith('.ts') ||
|
||||
testPattern.endsWith('.js') ||
|
||||
testPattern.includes('/')
|
||||
) {
|
||||
cmd += ` -- "${testPattern}"`;
|
||||
} else {
|
||||
cmd += ` -- -t "${testPattern}"`;
|
||||
}
|
||||
}
|
||||
|
||||
// Run evals
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_MODEL: model,
|
||||
RUN_EVALS: 'true',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`
|
||||
Evals for ${model} (Attempt ${attempt}) finished with failures.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Copy logs to artifacts
|
||||
// Format: eval-logs-<model>-<attempt>
|
||||
const artifactName = `eval-logs-${model}-${attempt}`;
|
||||
const artifactPath = path.join(artifactsDir, artifactName);
|
||||
|
||||
// Ensure parent dir exists (though artifactsDir should exist)
|
||||
if (fs.existsSync(logsDir)) {
|
||||
console.log(`Copying logs to ${artifactPath}`);
|
||||
fs.cpSync(logsDir, artifactPath, { recursive: true });
|
||||
} else {
|
||||
console.error(`Warning: No logs found in ${logsDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--------------------------------------------------');
|
||||
console.log('Aggregating results...');
|
||||
console.log('--------------------------------------------------\n');
|
||||
|
||||
try {
|
||||
const summaryFile = 'local_evals_summary.md';
|
||||
execSync(`node scripts/aggregate_evals.js "${artifactsDir}" "${summaryFile}"`, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\nSummary written to ${summaryFile}`);
|
||||
console.log('\nPreview:\n');
|
||||
console.log(fs.readFileSync(summaryFile, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.error('Aggregation failed:', e);
|
||||
}
|
||||
Reference in New Issue
Block a user