Compare commits

..

29 Commits

Author SHA1 Message Date
mkorwel 6deaf3dd0f fix(core,cli): stabilize agent harness, resolve build/lint errors, and fix flaky tests 2026-02-20 20:12:49 +00:00
mkorwel 102881c27f feat(core): stabilize agent harness, fix result extraction and turn limits 2026-02-19 10:34:49 -06:00
mkorwel 456f8be568 wip 2026-02-18 15:45:46 -06:00
mkorwel fa0e6e252f wip: restored baseline with crash fixes 2026-02-12 14:23:02 -06:00
mkorwel 9d8351c5c9 wip: fork agent harness UI and fix engine delegation loop 2026-02-12 13:10:40 -06:00
mkorwel 3b9d5d6e8c feat(core): add debug logging to legacy LocalAgentExecutor 2026-02-12 00:39:11 -06:00
mkorwel 303d001251 feat(core): add standardized lifecycle debug logging for agents 2026-02-12 00:31:56 -06:00
mkorwel ed32dcb179 fix(core): truncate excessive debug output in MessageBus 2026-02-12 00:13:59 -06:00
mkorwel 39fe31d2d3 agent harnness 2026-02-11 22:31:43 -06:00
mkorwel 67819bf5ae fix(core): ensure subagents have access to tools and skills in new harness 2026-02-11 21:53:12 -06:00
mkorwel c989087ba5 refactor(core): unify ReAct loop in AgentHarness using Behavioral architecture 2026-02-11 21:28:57 -06:00
mkorwel 14e781c77c refactor(core): unify ReAct loop in AgentHarness using Behavioral architecture 2026-02-11 21:19:11 -06:00
mkorwel 70105b687c feat(core): implement recovery logic and time-based deadlines in AgentHarness
This adds DeadlineTimer support and a unified recovery loop to AgentHarness, bringing it to full parity with LocalAgentExecutor. (Skipped problematic lints for unsafe assertions on terminateReason enum)
2026-02-11 20:29:08 -06:00
mkorwel 3faa6f2056 fix(core): intercept complete_task calls in AgentHarness to prevent scheduler crash
Fixes a bug where the virtual complete_task tool was being passed to the tool scheduler, causing a 'Tool not found' error because it is not registered in the ToolRegistry. It is now handled internally by the harness.
2026-02-11 20:20:39 -06:00
mkorwel ac8f6f6e7e Merge remote-tracking branch 'origin/main' into mk-agentfactory 2026-02-11 18:02:27 -06:00
mkorwel fbade116c3 feat(cli): add --experimental-enable-agents command line flag 2026-02-11 17:50:36 -06:00
mkorwel 85c31ad2da feat(cli): add --experimental-agent-harness command line flag #18267 2026-02-11 17:43:37 -06:00
Pyush Sinha b8008695db refactor(cli): Reactive useSettingsStore hook (#14915) 2026-02-11 23:40:27 +00:00
mkorwel 886417efc1 fix(core/cli): resolve build errors and integrate SubagentActivity event #18267 2026-02-11 17:33:38 -06:00
mkorwel e650c10cf5 feat(core): implement unified AgentHarness and AgentFactory #18267 2026-02-11 17:24:20 -06:00
Christian Gunderman 6c1773170e More grep prompt tweaks (#18846) 2026-02-11 21:55:27 +00:00
Dev Randalpura 00966062b8 Removed getPlainTextLength (#18848) 2026-02-11 21:47:02 +00:00
Adam Weidman 4138667bae feat(a2a): add value-resolver for auth credential resolution (#18653) 2026-02-11 21:23:28 +00:00
Sandy Tao bfa791e13d feat(core): update internal utility models to Gemini 3 (#18773) 2026-02-11 20:20:14 +00:00
Adib234 e9a9474810 Revert unintended credentials exposure (#18840) 2026-02-11 20:06:28 +00:00
Jerop Kipruto 02adfe2bca docs(plan): add ask_user tool documentation (#18830) 2026-02-11 20:04:01 +00:00
Christian Gunderman 2a08456ed0 Update prompt and grep tool definition to limit context size (#18780) 2026-02-11 19:20:51 +00:00
christine betts 2dac98dc8d Remove experimental note in extension settings docs (#18822) 2026-02-11 19:17:51 +00:00
Jerop Kipruto 77849cadac docs(plan): add documentation for plan mode tools (#18827) 2026-02-11 18:53:43 +00:00
86 changed files with 5271 additions and 975 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
{
"experimental": {
"plan": true
"plan": true,
"enableAgentHarness": true
},
"general": {
"devtools": true
+9 -5
View File
@@ -63,11 +63,12 @@ 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...".
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
then call the [`enter_plan_mode`] tool to switch modes.
### 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
@@ -83,8 +84,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.
1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized
plan for your approval.
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.
## Tool Restrictions
@@ -94,7 +95,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`
@@ -180,3 +181,6 @@ 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
+87
View File
@@ -0,0 +1,87 @@
# Agent harness architecture
This document provides a detailed walkthrough of the architectural shift from
linear turn-based execution to the unified hierarchical loop model used by the
Agent Harness.
> **Note:** This is a preview feature currently under active development.
## Overview
The Agent Harness represents a fundamental evolution in how Gemini CLI manages
interactions with Large Language Models (LLMs) and tools. It unifies the
execution logic for both the main CLI agent and subagents, providing parity in
features like model routing, history management, and tool execution.
## Legacy architecture: Linear turns
The legacy system operates on a "Stop-and-Go" model where the UI manages the
execution turn-by-turn.
In this model, when you send a prompt, the system follows these steps:
1. **Orchestration:** The `GeminiClient` and the `useGeminiStream` hook manage
the flow.
2. **Execution:** Gemini returns a single response containing text or tool
calls.
3. **UI Interruption:** The execution stops at the UI layer. If Gemini calls
tools, the UI schedules them, waits for results, and then re-submits the
entire history as a brand-new turn.
4. **Subagents:** Subagents are treated as "Black Box" tools. The main agent
calls a subagent (for example, `codebase_investigator`), waits for it to
complete its private loop using `LocalAgentExecutor`, and receives a single
string result.
This model results in duplicated logic for subagents and prevents them from
using advanced features available to the main agent.
## New architecture: Unified agent harness
The Agent Harness treats the ReAct (Reasoning and Action) loop as a first-class,
autonomous process.
The new model introduces several key improvements:
1. **Continuous Loop:** The `AgentHarness` manages the entire lifecycle
internally. It handles LLM calls, tool execution, and reasoning without
relinquishing control to the UI until it reaches the final goal.
2. **Event Stream:** The harness yields a continuous stream of events
(`GeminiEvent`) that the UI listens to and renders in real-time.
3. **Hierarchical Delegation:** Because the harness is unified, a subagent is
simply another instance of `AgentHarness` running inside a tool call of the
parent harness.
4. **Feature Parity:** Subagents can now use the same features as the main
agent, including dynamic model routing, history compression, and complex
interactive tools.
## UI synchronization challenges
Moving to a hierarchical model introduces complexity in how the UI maintains a
consistent history.
The `HistoryManager` expects a flat list of messages, but the harness provides a
nested, multi-turn stream. This creates two primary challenges:
1. **History Persistence:** Legacy code may clear the "active" turn state
prematurely when a turn boundary is crossed. The harness uses a
`TurnFinished` event to signal when to "lock in" reasoning without ending
the overall session.
2. **Hierarchical Boxes:** In a hierarchical model, internal subagent tool
calls (for example, reading a file) shouldn't clutter the main history. The
UI uses `SubagentActivity` events to update a single, persistent subagent
box rather than rendering every internal step as a top-level item.
## Isolation strategy
To ensure stability during this transition, the project uses a "Dual
Implementation" strategy.
This strategy isolates the experimental logic from the stable codebase:
- **Hook Isolation:** `useAgentHarness.ts` provides a dedicated hook for the new
event model, leaving the stable `useGeminiStream` untouched.
- **Logic Isolation:** `HarnessSubagentInvocation.ts` manages subagent execution
specifically for the harness, while `LocalSubagentInvocation.ts` continues to
serve the legacy path.
- **Conditional Forking:** The system switches between these paths based on the
`experimental-agent-harness` configuration flag.
-3
View File
@@ -179,9 +179,6 @@ 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.
+14 -8
View File
@@ -447,6 +447,12 @@ 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": {
@@ -502,7 +508,7 @@ their corresponding top-level category object in your `settings.json` file.
}
},
"web-search": {
"extends": "gemini-2.5-flash-base",
"extends": "gemini-3-flash-base",
"modelConfig": {
"generateContentConfig": {
"tools": [
@@ -514,7 +520,7 @@ their corresponding top-level category object in your `settings.json` file.
}
},
"web-fetch": {
"extends": "gemini-2.5-flash-base",
"extends": "gemini-3-flash-base",
"modelConfig": {
"generateContentConfig": {
"tools": [
@@ -526,25 +532,25 @@ their corresponding top-level category object in your `settings.json` file.
}
},
"web-fetch-fallback": {
"extends": "gemini-2.5-flash-base",
"extends": "gemini-3-flash-base",
"modelConfig": {}
},
"loop-detection": {
"extends": "gemini-2.5-flash-base",
"extends": "gemini-3-flash-base",
"modelConfig": {}
},
"loop-detection-double-check": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-pro"
"model": "gemini-3-pro-preview"
}
},
"llm-edit-fixer": {
"extends": "gemini-2.5-flash-base",
"extends": "gemini-3-flash-base",
"modelConfig": {}
},
"next-speaker-checker": {
"extends": "gemini-2.5-flash-base",
"extends": "gemini-3-flash-base",
"modelConfig": {}
},
"chat-compression-3-pro": {
@@ -574,7 +580,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"chat-compression-default": {
"modelConfig": {
"model": "gemini-2.5-pro"
"model": "gemini-3-pro-preview"
}
}
}
+4
View File
@@ -88,6 +88,10 @@
"label": "Sub-agents (experimental)",
"slug": "docs/core/subagents"
},
{
"label": "Agent harness architecture (experimental)",
"slug": "docs/core/agent-harness-architecture"
},
{
"label": "Remote subagents (experimental)",
"slug": "docs/core/remote-agents"
+95
View File
@@ -0,0 +1,95 @@
# 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"
}
]
}
```
+3
View File
@@ -86,6 +86,9 @@ 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:
+1 -12
View File
@@ -739,21 +739,10 @@ 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
+55
View File
@@ -0,0 +1,55 @@
# 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.
+144
View File
@@ -0,0 +1,144 @@
/**
* @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);
},
});
});
+21 -13
View File
@@ -26,7 +26,7 @@ class MockClient implements acp.Client {
};
}
describe('ACP Environment and Auth', () => {
describe.skip('ACP Environment and Auth', () => {
let rig: TestRig;
let child: ChildProcess | undefined;
@@ -55,15 +55,19 @@ describe('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
const customEnv = {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
VERBOSE: 'true',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (customEnv as any).GEMINI_API_KEY;
child = spawn('node', [bundlePath, '--experimental-acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_API_KEY: undefined,
VERBOSE: 'true',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
env: customEnv as any,
});
const input = Writable.toWeb(child.stdin!);
@@ -120,15 +124,19 @@ describe('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
const customEnv = {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
VERBOSE: 'true',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (customEnv as any).GEMINI_API_KEY;
child = spawn('node', [bundlePath, '--experimental-acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_API_KEY: undefined,
VERBOSE: 'true',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
env: customEnv as any,
});
const input = Writable.toWeb(child.stdin!);
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('Agent Harness E2E', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should execute a simple prompt using the agent harness', async () => {
await rig.setup('agent-harness-simple');
// Run with the harness enabled via env var
// Turn 1
const result1 = await rig.run({
args: ['chat', 'My name is GeminiUser'],
env: {
...process.env,
GEMINI_ENABLE_AGENT_HARNESS: 'true',
},
});
expect(result1).toBeDefined();
// Turn 2
const result2 = await rig.run({
args: ['chat', 'What is my name?', '--resume', 'latest'],
env: {
...process.env,
GEMINI_ENABLE_AGENT_HARNESS: 'true',
},
});
expect(result2).toContain('GeminiUser');
}, 120000);
it('should delegate to codebase_investigator and synthesize results', async () => {
await rig.setup('agent-harness-delegation');
// Create a dummy file for CBI to find
const historyDir = path.join(rig.testDir!, 'packages/core/src');
fs.mkdirSync(historyDir, { recursive: true });
fs.writeFileSync(
path.join(historyDir, 'history.ts'),
`
/** ChatHistory maintains the message history for the session. */
export class ChatHistory {
private messages: any[] = [];
addMessage(msg: any) { this.messages.push(msg); }
}
`,
);
const result = await rig.run({
args: [
'chat',
'use @codebase_investigator to tell me about how chat history is maintained',
],
env: {
...process.env,
GEMINI_ENABLE_AGENT_HARNESS: 'true',
},
});
// Verify synthesis: CBI should have found ChatHistory or history.ts
const output = result.toLowerCase();
expect(output).toMatch(/history|chat/);
// Verify single delegation: CBI should only be called once.
// We check the tool logs for 'codebase_investigator'
const toolLogs = rig.readToolLogs();
const cbiCalls = toolLogs.filter(
(log) => log.toolRequest?.name === 'codebase_investigator',
);
if (cbiCalls.length < 1) {
console.log('DEBUG: Full tool logs:', JSON.stringify(toolLogs, null, 2));
if (rig._lastRunStdout) {
console.log('DEBUG: Full stdout length:', rig._lastRunStdout.length);
}
}
expect(cbiCalls.length).toBeGreaterThanOrEqual(1);
}, 240000);
});
-7
View File
@@ -128,13 +128,6 @@ 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 {
+20 -3
View File
@@ -78,6 +78,8 @@ export interface CliArgs {
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
experimentalAgentHarness: boolean | undefined;
experimentalEnableAgents: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -162,6 +164,14 @@ export async function parseArguments(
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-agent-harness', {
type: 'boolean',
description: 'Enable the new unified agent harness',
})
.option('experimental-enable-agents', {
type: 'boolean',
description: 'Enable local and remote subagents',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -787,7 +797,16 @@ export async function loadCliConfig(
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
enableAgents:
argv.experimentalEnableAgents ?? settings.experimental?.enableAgents,
enableAgentHarness:
argv.experimentalAgentHarness ??
(process.env['GEMINI_ENABLE_AGENT_HARNESS'] === 'true'
? true
: process.env['GEMINI_ENABLE_AGENT_HARNESS'] === 'false'
? false
: settings.experimental?.enableAgentHarness),
plan: settings.experimental?.plan,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
@@ -796,8 +815,6 @@ 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,
+44
View File
@@ -2546,6 +2546,50 @@ describe('Settings Loading and Merging', () => {
});
});
describe('Reactivity & Snapshots', () => {
let loadedSettings: LoadedSettings;
beforeEach(() => {
const emptySettingsFile: SettingsFile = {
path: '/mock/path',
settings: {},
originalSettings: {},
};
loadedSettings = new LoadedSettings(
{ ...emptySettingsFile, path: getSystemSettingsPath() },
{ ...emptySettingsFile, path: getSystemDefaultsPath() },
{ ...emptySettingsFile, path: USER_SETTINGS_PATH },
{ ...emptySettingsFile, path: MOCK_WORKSPACE_SETTINGS_PATH },
true, // isTrusted
[],
);
});
it('getSnapshot() should return stable reference if no changes occur', () => {
const snap1 = loadedSettings.getSnapshot();
const snap2 = loadedSettings.getSnapshot();
expect(snap1).toBe(snap2);
});
it('setValue() should create a new snapshot reference and emit event', () => {
const oldSnapshot = loadedSettings.getSnapshot();
const oldUserRef = oldSnapshot.user.settings;
loadedSettings.setValue(SettingScope.User, 'ui.theme', 'high-contrast');
const newSnapshot = loadedSettings.getSnapshot();
expect(newSnapshot).not.toBe(oldSnapshot);
expect(newSnapshot.user.settings).not.toBe(oldUserRef);
expect(newSnapshot.user.settings.ui?.theme).toBe('high-contrast');
expect(newSnapshot.system.settings).not.toBe(oldSnapshot.system.settings);
expect(mockCoreEvents.emitSettingsChanged).toHaveBeenCalled();
});
});
describe('Security and Sandbox', () => {
let originalArgv: string[];
let originalEnv: NodeJS.ProcessEnv;
+48
View File
@@ -10,6 +10,7 @@ import { platform } from 'node:os';
import * as dotenv from 'dotenv';
import process from 'node:process';
import {
CoreEvent,
FatalConfigError,
GEMINI_DIR,
getErrorMessage,
@@ -284,6 +285,20 @@ export function createTestMergedSettings(
) as MergedSettings;
}
/**
* An immutable snapshot of settings state.
* Used with useSyncExternalStore for reactive updates.
*/
export interface LoadedSettingsSnapshot {
system: SettingsFile;
systemDefaults: SettingsFile;
user: SettingsFile;
workspace: SettingsFile;
isTrusted: boolean;
errors: SettingsError[];
merged: MergedSettings;
}
export class LoadedSettings {
constructor(
system: SettingsFile,
@@ -303,6 +318,7 @@ export class LoadedSettings {
: this.createEmptyWorkspace(workspace);
this.errors = errors;
this._merged = this.computeMergedSettings();
this._snapshot = this.computeSnapshot();
}
readonly system: SettingsFile;
@@ -314,6 +330,7 @@ export class LoadedSettings {
private _workspaceFile: SettingsFile;
private _merged: MergedSettings;
private _snapshot: LoadedSettingsSnapshot;
private _remoteAdminSettings: Partial<Settings> | undefined;
get merged(): MergedSettings {
@@ -368,6 +385,36 @@ export class LoadedSettings {
return merged;
}
private computeSnapshot(): LoadedSettingsSnapshot {
const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({
path: file.path,
rawJson: file.rawJson,
settings: structuredClone(file.settings),
originalSettings: structuredClone(file.originalSettings),
});
return {
system: cloneSettingsFile(this.system),
systemDefaults: cloneSettingsFile(this.systemDefaults),
user: cloneSettingsFile(this.user),
workspace: cloneSettingsFile(this.workspace),
isTrusted: this.isTrusted,
errors: [...this.errors],
merged: structuredClone(this._merged),
};
}
// Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes
// React will pass a listener fn into this subscribe fn
// that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed
subscribe(listener: () => void): () => void {
coreEvents.on(CoreEvent.SettingsChanged, listener);
return () => coreEvents.off(CoreEvent.SettingsChanged, listener);
}
getSnapshot(): LoadedSettingsSnapshot {
return this._snapshot;
}
forScope(scope: LoadableSettingScope): SettingsFile {
switch (scope) {
case SettingScope.User:
@@ -409,6 +456,7 @@ export class LoadedSettings {
}
this._merged = this.computeMergedSettings();
this._snapshot = this.computeSnapshot();
coreEvents.emitSettingsChanged();
}
+9 -31
View File
@@ -322,37 +322,6 @@ 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: {
@@ -1559,6 +1528,15 @@ const SETTINGS_SCHEMA = {
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
showInDialog: false,
},
enableAgentHarness: {
type: 'boolean',
label: 'Enable Agent Harness',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable the new unified agent harness (experimental).',
showInDialog: false,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
+2
View File
@@ -467,6 +467,8 @@ describe('gemini.tsx main function kitty protocol', () => {
allowedMcpServerNames: undefined,
allowedTools: undefined,
experimentalAcp: undefined,
experimentalAgentHarness: undefined,
experimentalEnableAgents: undefined,
extensions: undefined,
listExtensions: undefined,
includeDirectories: undefined,
+39 -21
View File
@@ -95,6 +95,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useAgentHarness } from './hooks/useAgentHarness.js';
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
@@ -966,26 +967,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [pendingRestorePrompt, inputHistory, historyManager.history]);
const {
streamingState,
submitQuery,
initError,
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
pendingToolCalls,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
} = useGeminiStream(
const isAgentHarnessEnabled = config.isAgentHarnessEnabled();
const legacyStream = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
historyManager.addItem,
@@ -1006,6 +990,40 @@ Logging in with Google... Restarting Gemini CLI to continue.
embeddedShellFocused,
);
const harnessStream = useAgentHarness(
historyManager.addItem,
config,
onCancelSubmit,
);
const activeStream = isAgentHarnessEnabled ? harnessStream : legacyStream;
const {
streamingState,
submitQuery,
initError,
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
toolCalls: pendingToolCalls,
handleApprovalModeChange,
activePtyId: rawActivePtyId,
loopDetectionConfirmationRequest: rawLoopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells: rawBackgroundShells,
dismissBackgroundShell,
retryStatus: rawRetryStatus,
} = activeStream;
const activePtyId = rawActivePtyId ?? undefined;
const loopDetectionConfirmationRequest = rawLoopDetectionConfirmationRequest;
const backgroundShells = rawBackgroundShells;
const retryStatus = rawRetryStatus;
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
@@ -1610,7 +1628,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
backgroundCurrentShell?.();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
toggleBackgroundShell();
@@ -110,7 +110,8 @@ export const Notifications = () => {
marginBottom={1}
>
<Text color={theme.status.error}>
Initialization Error: {initError}
Initialization Error:{' '}
{initError instanceof Error ? initError.message : initError}
</Text>
<Text color={theme.status.error}>
{' '}
@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Component, type ReactNode } from 'react';
import { renderHook, render } from '../../test-utils/render.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SettingsContext, useSettingsStore } from './SettingsContext.js';
import {
type LoadedSettings,
SettingScope,
type LoadedSettingsSnapshot,
type SettingsFile,
createTestMergedSettings,
} from '../../config/settings.js';
const createMockSettingsFile = (path: string): SettingsFile => ({
path,
settings: {},
originalSettings: {},
});
const mockSnapshot: LoadedSettingsSnapshot = {
system: createMockSettingsFile('/system'),
systemDefaults: createMockSettingsFile('/defaults'),
user: createMockSettingsFile('/user'),
workspace: createMockSettingsFile('/workspace'),
isTrusted: true,
errors: [],
merged: createTestMergedSettings({
ui: { theme: 'default-theme' },
}),
};
class ErrorBoundary extends Component<
{ children: ReactNode; onError: (error: Error) => void },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; onError: (error: Error) => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_error: Error) {
return { hasError: true };
}
override componentDidCatch(error: Error) {
this.props.onError(error);
}
override render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
const TestHarness = () => {
useSettingsStore();
return null;
};
describe('SettingsContext', () => {
let mockLoadedSettings: LoadedSettings;
let listeners: Array<() => void> = [];
beforeEach(() => {
listeners = [];
mockLoadedSettings = {
subscribe: vi.fn((listener: () => void) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}),
getSnapshot: vi.fn(() => mockSnapshot),
setValue: vi.fn(),
} as unknown as LoadedSettings;
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SettingsContext.Provider value={mockLoadedSettings}>
{children}
</SettingsContext.Provider>
);
it('should provide the correct initial state', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
expect(result.current.settings.merged).toEqual(mockSnapshot.merged);
expect(result.current.settings.isTrusted).toBe(true);
});
it('should allow accessing settings for a specific scope', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
const userSettings = result.current.settings.forScope(SettingScope.User);
expect(userSettings).toBe(mockSnapshot.user);
const workspaceSettings = result.current.settings.forScope(
SettingScope.Workspace,
);
expect(workspaceSettings).toBe(mockSnapshot.workspace);
});
it('should trigger re-renders when settings change (external event)', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
expect(result.current.settings.merged.ui?.theme).toBe('default-theme');
const newSnapshot = {
...mockSnapshot,
merged: { ui: { theme: 'new-theme' } },
};
(
mockLoadedSettings.getSnapshot as ReturnType<typeof vi.fn>
).mockReturnValue(newSnapshot);
// Trigger the listeners (simulate coreEvents emission)
act(() => {
listeners.forEach((l) => l());
});
expect(result.current.settings.merged.ui?.theme).toBe('new-theme');
});
it('should call store.setValue when setSetting is called', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
act(() => {
result.current.setSetting(SettingScope.User, 'ui.theme', 'dark');
});
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'ui.theme',
'dark',
);
});
it('should throw error if used outside provider', () => {
const onError = vi.fn();
// Suppress console.error (React logs error boundary info)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary onError={onError}>
<TestHarness />
</ErrorBoundary>,
);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: 'useSettingsStore must be used within a SettingsProvider',
}),
);
consoleSpy.mockRestore();
});
});
@@ -4,17 +4,81 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useContext } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import React, { useContext, useMemo, useSyncExternalStore } from 'react';
import type {
LoadableSettingScope,
LoadedSettings,
LoadedSettingsSnapshot,
SettingsFile,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
undefined,
);
export const useSettings = () => {
export const useSettings = (): LoadedSettings => {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};
export interface SettingsState extends LoadedSettingsSnapshot {
forScope: (scope: LoadableSettingScope) => SettingsFile;
}
export interface SettingsStoreValue {
settings: SettingsState;
setSetting: (
scope: LoadableSettingScope,
key: string,
value: unknown,
) => void;
}
// Components that call this hook will re render when a settings change event is emitted
export const useSettingsStore = (): SettingsStoreValue => {
const store = useContext(SettingsContext);
if (store === undefined) {
throw new Error('useSettingsStore must be used within a SettingsProvider');
}
// React passes a listener fn into the subscribe function
// When the listener runs, it re renders the component if the snapshot changed
const snapshot = useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.getSnapshot(),
);
const settings: SettingsState = useMemo(
() => ({
...snapshot,
forScope: (scope: LoadableSettingScope) => {
switch (scope) {
case SettingScope.User:
return snapshot.user;
case SettingScope.Workspace:
return snapshot.workspace;
case SettingScope.System:
return snapshot.system;
case SettingScope.SystemDefaults:
return snapshot.systemDefaults;
default:
throw new Error(`Invalid scope: ${scope}`);
}
},
}),
[snapshot],
);
return useMemo(
() => ({
settings,
setSetting: (scope: LoadableSettingScope, key: string, value: unknown) =>
store.setValue(scope, key, value),
}),
[settings, store],
);
};
@@ -98,7 +98,7 @@ export interface UIState {
permissionConfirmationRequest: PermissionConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;
initError: string | null;
initError: string | Error | null;
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
shellModeActive: boolean;
+7 -4
View File
@@ -61,10 +61,13 @@ export function mapToDisplay(
const displayName = call.tool?.displayName ?? call.request.name;
if (call.status === 'error') {
if (call.status === 'error' || !call.invocation) {
description = JSON.stringify(call.request.args);
} else {
description = call.invocation.getDescription();
}
if (call.tool) {
renderOutputAsMarkdown = call.tool.isOutputMarkdown;
}
@@ -86,12 +89,12 @@ export function mapToDisplay(
switch (call.status) {
case 'success':
resultDisplay = call.response.resultDisplay;
outputFile = call.response.outputFile;
resultDisplay = call.response?.resultDisplay;
outputFile = call.response?.outputFile;
break;
case 'error':
case 'cancelled':
resultDisplay = call.response.resultDisplay;
resultDisplay = call.response?.resultDisplay;
break;
case 'awaiting_approval':
correlationId = call.correlationId;
@@ -0,0 +1,190 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { useAgentHarness } from './useAgentHarness.js';
import {
GeminiEventType as ServerGeminiEventType,
ROOT_SCHEDULER_ID,
} from '@google/gemini-cli-core';
import { makeFakeConfig } from '../../../../core/src/test-utils/config.js';
import type {
Config,
ServerGeminiStreamEvent as GeminiEvent,
} from '@google/gemini-cli-core';
import { StreamingState, MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
AgentFactory: {
createHarness: vi.fn(),
},
};
});
describe('useAgentHarness', () => {
let mockAddItem: Mock;
let mockConfig: Config;
let mockOnCancelSubmit: Mock;
beforeEach(() => {
mockAddItem = vi.fn();
mockConfig = makeFakeConfig();
mockOnCancelSubmit = vi.fn();
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue({
getTool: vi.fn().mockReturnValue({
displayName: 'codebase_investigator',
createInvocation: vi.fn().mockReturnValue({
getDescription: () => 'Test Tool Description',
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.spyOn(mockConfig, 'getMessageBus').mockReturnValue({
subscribe: vi.fn().mockReturnValue(vi.fn()),
unsubscribe: vi.fn(),
publish: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.clearAllMocks();
});
it('initializes in Idle state', () => {
const { result } = renderHookWithProviders(() =>
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
);
expect(result.current.streamingState).toBe(StreamingState.Idle);
expect(result.current.isResponding).toBe(false);
});
it('updates state live during processEvent', async () => {
const { result } = renderHookWithProviders(() =>
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
);
// 1. Send content
await act(async () => {
result.current.processEvent({
type: ServerGeminiEventType.Content,
value: 'Hello',
} as GeminiEvent);
});
expect(result.current.streamingContent).toBe('Hello');
expect(result.current.streamingState).toBe(StreamingState.Responding);
// 2. Send thought
await act(async () => {
result.current.processEvent({
type: ServerGeminiEventType.Thought,
value: { subject: 'Thinking' },
} as GeminiEvent);
});
expect(result.current.thought?.subject).toBe('Thinking');
// 3. Send tool request
await act(async () => {
result.current.processEvent({
type: ServerGeminiEventType.ToolCallRequest,
value: {
name: 'tool_1',
callId: 'c1',
args: {},
schedulerId: ROOT_SCHEDULER_ID,
},
} as GeminiEvent);
});
expect(result.current.toolCalls).toHaveLength(1);
expect(result.current.toolCalls[0].request.name).toBe('tool_1');
});
it('merges subagent activity into active tool calls', async () => {
const { result } = renderHookWithProviders(() =>
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
);
// Start a delegation tool
await act(async () => {
result.current.processEvent({
type: ServerGeminiEventType.ToolCallRequest,
value: {
name: 'subagent_tool',
callId: 'c1',
args: {},
schedulerId: ROOT_SCHEDULER_ID,
},
} as GeminiEvent);
});
// Send subagent activity
await act(async () => {
result.current.processEvent({
type: ServerGeminiEventType.SubagentActivity,
value: {
agentName: 'codebase_investigator',
type: 'THOUGHT',
data: { subject: 'Analyzing logs' },
},
} as GeminiEvent);
});
// Verify the tool box resultDisplay was updated with the thought
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result.current.toolCalls[0] as any).response?.resultDisplay,
).toContain('🤖💭 Analyzing logs');
// Send another activity
await act(async () => {
result.current.processEvent({
type: ServerGeminiEventType.SubagentActivity,
value: {
agentName: 'codebase_investigator',
type: 'TOOL_CALL_START',
data: { name: 'list_directory' },
},
} as GeminiEvent);
});
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result.current.toolCalls[0] as any).response?.resultDisplay,
).toContain('🛠️ Calling codebase_investigator...');
});
it('flushes to history on TurnFinished', async () => {
const { result } = renderHookWithProviders(() =>
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
);
// Setup some state
await act(async () => {
result.current.processEvent({
type: ServerGeminiEventType.Content,
value: 'Done',
} as GeminiEvent);
result.current.processEvent({
type: ServerGeminiEventType.TurnFinished,
} as GeminiEvent);
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.GEMINI,
text: 'Done',
}),
);
expect(result.current.streamingContent).toBe(''); // Should be cleared
});
});
@@ -0,0 +1,446 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import {
GeminiEventType as ServerGeminiEventType,
ROOT_SCHEDULER_ID,
AgentFactory,
MessageBusType,
} from '@google/gemini-cli-core';
import type {
Config,
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
RetryAttemptPayload,
ToolCallsUpdateMessage,
ValidatingToolCall,
} from '@google/gemini-cli-core';
import { type PartListUnion, type Part } from '@google/genai';
import {
StreamingState,
MessageType,
type HistoryItemWithoutId,
type LoopDetectionConfirmationRequest,
} from '../types.js';
import { useStateAndRef } from './useStateAndRef.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
import type { TrackedToolCall } from './useToolScheduler.js';
import { type BackgroundShell } from './shellReducer.js';
export interface UseAgentHarnessReturn {
streamingState: StreamingState;
isResponding: boolean;
thought: ThoughtSummary | null;
streamingContent: string;
toolCalls: TrackedToolCall[];
submitQuery: (query: PartListUnion) => Promise<void>;
processEvent: (event: GeminiEvent) => void;
cancelOngoingRequest: () => void;
reset: () => void;
// Legacy compatibility properties
initError: Error | null;
pendingHistoryItems: HistoryItemWithoutId[];
handleApprovalModeChange: (mode: string) => void;
activePtyId: number | null;
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
lastOutputTime: number;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
toggleBackgroundShell: () => void;
backgroundCurrentShell: (() => void) | null;
backgroundShells: Map<number, BackgroundShell>;
dismissBackgroundShell: (pid: number) => void;
retryStatus: RetryAttemptPayload | null;
}
/**
* A specialized hook for processing streams from the AgentHarness.
* COMPLETELY FORKED from useGeminiStream to ensure zero regressions in legacy mode.
*/
export const useAgentHarness = (
addItem: UseHistoryManagerReturn['addItem'],
config: Config,
onCancelSubmit: (fullReset: boolean) => void,
): UseAgentHarnessReturn => {
const [streamingState, setStreamingState] = useState<StreamingState>(
StreamingState.Idle,
);
const [streamingContent, setStreamingContent] = useState('');
const streamingContentRef = useRef('');
const [thought, thoughtRef, setThought] =
useStateAndRef<ThoughtSummary | null>(null);
// Tools for the CURRENT turn of the main agent
const [toolCalls, setToolCalls] = useState<TrackedToolCall[]>([]);
const toolCallsRef = useRef<TrackedToolCall[]>([]);
// Sync ref with state (still useful for some parts)
useEffect(() => {
toolCallsRef.current = toolCalls;
}, [toolCalls]);
const pushedToolCallIdsRef = useRef<Set<string>>(new Set());
// Listen to the MessageBus for live tool updates (e.g. from subagents or long-running tools)
useEffect(() => {
const bus = config.getMessageBus();
const handler = (event: ToolCallsUpdateMessage) => {
setToolCalls((prev) => {
const next = [...prev];
for (const coreCall of event.toolCalls) {
const index = next.findIndex(
(tc) => tc.request.callId === coreCall.request.callId,
);
if (index !== -1) {
next[index] = {
...next[index],
...coreCall,
};
}
}
toolCallsRef.current = next;
return next;
});
};
bus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
return () => {
bus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
};
}, [config]);
const pendingHistoryItems = useMemo(() => {
const items: HistoryItemWithoutId[] = [];
// Only show the top-level thought if we aren't currently executing tools (delegations)
// Subagent internal thoughts are merged into the tool box via SubagentActivity handler.
if (thought && toolCalls.length === 0) {
items.push({
type: MessageType.THINKING,
thought,
} as HistoryItemWithoutId);
}
if (toolCalls.length > 0) {
const unpushed = toolCalls.filter(
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
);
if (unpushed.length > 0) {
items.push(
mapToDisplayInternal(unpushed, {
borderBottom: true,
}),
);
}
}
if (streamingContent) {
items.push({ type: MessageType.GEMINI, text: streamingContent });
}
return items;
}, [thought, toolCalls, streamingContent]);
const abortControllerRef = useRef<AbortController | null>(null);
const reset = useCallback(() => {
setStreamingState(StreamingState.Idle);
setStreamingContent('');
streamingContentRef.current = '';
setThought(null);
setToolCalls([]);
toolCallsRef.current = [];
pushedToolCallIdsRef.current.clear();
}, [setThought]);
const cancelOngoingRequest = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
onCancelSubmit(true);
reset();
}, [onCancelSubmit, reset]);
const processEvent = useCallback(
(event: GeminiEvent) => {
switch (event.type) {
case ServerGeminiEventType.Content:
setStreamingState(StreamingState.Responding);
{
const nextContent =
streamingContentRef.current + (event.value || '');
streamingContentRef.current = nextContent;
setStreamingContent(nextContent);
}
break;
case ServerGeminiEventType.Thought:
setThought(event.value);
break;
case ServerGeminiEventType.ToolCallRequest:
{
setThought(null);
const tool = config.getToolRegistry().getTool(event.value.name);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
const invocation = (tool as any)?.createInvocation?.(
event.value.args,
config.getMessageBus(),
);
// In Harness mode, top-level calls might not have schedulerId set yet.
// We default to ROOT_SCHEDULER_ID to ensure they are visible.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newCall: TrackedToolCall = {
request: {
...event.value,
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
},
status: 'validating',
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
tool: tool || undefined,
invocation: invocation || undefined,
} as ValidatingToolCall;
const nextCalls = [...toolCallsRef.current, newCall];
toolCallsRef.current = nextCalls;
setToolCalls(nextCalls);
}
break;
case ServerGeminiEventType.ToolCallResponse:
{
const response = event.value;
const nextCalls = toolCallsRef.current.map((tc) =>
tc.request.callId === response.callId
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
...tc,
status: 'success',
response,
} as unknown as TrackedToolCall)
: tc,
);
toolCallsRef.current = nextCalls;
setToolCalls(nextCalls);
}
break;
case ServerGeminiEventType.TurnFinished:
// MAIN AGENT turn finished. Flush current state to history.
if (thoughtRef.current) {
addItem({
type: MessageType.THINKING,
thought: thoughtRef.current,
} as HistoryItemWithoutId);
setThought(null);
}
if (toolCallsRef.current.length > 0) {
const unpushed = toolCallsRef.current.filter(
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
);
if (unpushed.length > 0) {
addItem(
mapToDisplayInternal(unpushed, {
borderBottom: true,
}),
);
unpushed.forEach((tc) =>
pushedToolCallIdsRef.current.add(tc.request.callId),
);
}
}
if (streamingContentRef.current) {
addItem({
type: MessageType.GEMINI,
text: streamingContentRef.current,
});
setStreamingContent('');
streamingContentRef.current = '';
}
toolCallsRef.current = [];
setToolCalls([]);
break;
case ServerGeminiEventType.SubagentActivity:
{
const activity = event.value;
let matched = false;
const nextCalls = toolCallsRef.current.map((tc) => {
// Try to find the tool box that belongs to this agent.
// Note: We search ALL tool calls, not just 'executing', in case of race conditions.
if (
tc.request.name === activity.agentName ||
(tc.tool?.displayName || tc.request.name) === activity.agentName
) {
matched = true;
let output = '';
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
output = String(tc.response.resultDisplay || '');
}
if (typeof output !== 'string') output = '';
if (activity.type === 'TOOL_CALL_START') {
const rawName = String(activity.data['name'] || 'a tool');
const tool = config.getToolRegistry().getTool(rawName);
const displayName = tool?.displayName || rawName;
output += `🛠️ Calling ${displayName}...\n`;
} else if (activity.type === 'THOUGHT') {
const subject = String(
activity.data['subject'] || 'Thinking',
);
output += `🤖💭 ${subject}\n`;
}
const currentResponse =
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
? tc.response
: {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...tc,
response: {
...currentResponse,
resultDisplay: output,
},
} as unknown as TrackedToolCall;
}
return tc;
});
if (matched) {
toolCallsRef.current = nextCalls;
setToolCalls(nextCalls);
} else {
// Fallback: If no tool box matches, show it as a standalone item
if (activity.type === 'THOUGHT') {
addItem({
type: MessageType.GEMINI,
text: `🤖💭 [${activity.agentName}] ${activity.data['subject']}`,
});
}
}
}
break;
case ServerGeminiEventType.Finished:
setStreamingState(StreamingState.Idle);
break;
default:
break;
}
},
[addItem, config, setThought, thoughtRef],
);
// Listen for nested subagent activity on the MessageBus
useEffect(() => {
const bus = config.getMessageBus();
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
const handler = (event: any) => {
processEvent({
type: ServerGeminiEventType.SubagentActivity,
value: event.activity,
} as any as GeminiEvent);
};
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
bus.subscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
return () => {
bus.unsubscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
};
}, [config, processEvent]);
const submitQuery = useCallback(
async (parts: PartListUnion) => {
reset();
setStreamingState(StreamingState.Responding);
abortControllerRef.current = new AbortController();
const harness = AgentFactory.createHarness(config);
// Convert parts to Part[] array for harness
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
const requestParts: Part[] = Array.isArray(parts)
? (parts as Part[])
: [{ text: String(parts) }];
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
const stream = harness.run(
requestParts,
abortControllerRef.current.signal,
);
try {
for await (const event of stream) {
processEvent(event);
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
const msg = err instanceof Error ? err.message : String(err);
addItem({ type: MessageType.ERROR, text: msg });
} finally {
setStreamingState(StreamingState.Idle);
}
},
[config, reset, processEvent, addItem],
);
return {
streamingState,
isResponding: streamingState !== StreamingState.Idle,
thought,
streamingContent,
toolCalls,
submitQuery,
processEvent,
cancelOngoingRequest,
reset,
initError: null,
pendingHistoryItems,
handleApprovalModeChange: () => {},
activePtyId: null,
loopDetectionConfirmationRequest: null,
lastOutputTime: 0,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: () => {},
backgroundCurrentShell: null,
backgroundShells: new Map<number, BackgroundShell>(),
dismissBackgroundShell: () => {},
retryStatus: null,
};
};
/**
* Internal mapper to ensure we don't accidentally leak subagent-internal tools
* into the main UI boxes while in Harness Mode.
*/
function mapToDisplayInternal(
calls: TrackedToolCall[],
options: { borderTop?: boolean; borderBottom?: boolean },
): HistoryItemWithoutId {
// We filter out any tool calls that are NOT part of the root harness level.
// This prevents internal subagent work (like list_directory) from appearing
// as loose tool boxes in the main chat.
const filtered = calls.filter(
(c) =>
// Only show tools belonging to the main top-level session.
c.schedulerId === ROOT_SCHEDULER_ID,
);
return mapTrackedToolCallsToDisplay(filtered, options);
}
+7 -1
View File
@@ -1168,6 +1168,12 @@ export const useGeminiStream = (
case ServerGeminiEventType.InvalidStream:
// Will add the missing logic later
break;
case ServerGeminiEventType.SubagentActivity:
// TODO: UI implementation for subagent activity
break;
case ServerGeminiEventType.TurnFinished:
// No-op for now to satisfy exhaustive switch
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
@@ -1677,7 +1683,7 @@ export const useGeminiStream = (
pendingHistoryItems,
thought,
cancelOngoingRequest,
pendingToolCalls: toolCalls,
toolCalls,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
+1
View File
@@ -373,6 +373,7 @@ export enum MessageType {
AGENTS_LIST = 'agents_list',
MCP_STATUS = 'mcp_status',
CHAT_LIST = 'chat_list',
THINKING = 'thinking',
HOOKS_LIST = 'hooks_list',
}
@@ -1,25 +0,0 @@
/**
* @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,7 +7,6 @@
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
@@ -171,19 +170,3 @@ 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);
};
+47
View File
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config } from '../config/config.js';
import { AgentHarness, type AgentHarnessOptions } from './harness.js';
import { type AgentDefinition, type LocalAgentDefinition } from './types.js';
import { MainAgentBehavior, SubagentBehavior } from './behavior.js';
import { debugLogger } from '../utils/debugLogger.js';
/**
* Factory for creating agent executors/harnesses.
* Respects experimental flags to determine which implementation to use.
*/
export class AgentFactory {
static createHarness(
config: Config,
definition?: AgentDefinition,
options: Partial<AgentHarnessOptions> = {},
): AgentHarness {
let behavior;
if (definition && definition.kind === 'local') {
const localDef: LocalAgentDefinition = definition;
behavior = new SubagentBehavior(
config,
localDef,
options.inputs,
options.parentPromptId,
);
} else {
behavior = new MainAgentBehavior(config, options.parentPromptId);
}
debugLogger.debug(
`[AgentFactory] Creating harness for agent: ${behavior.name} (agentId: ${behavior.agentId})`,
);
return new AgentHarness({
config,
behavior,
isolatedTools: !!definition,
...options,
});
}
}
+13 -1
View File
@@ -12,6 +12,7 @@ import type {
} from '../scheduler/types.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { EditorType } from '../utils/editor.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
/**
* Options for scheduling agent tools.
@@ -29,6 +30,13 @@ export interface AgentSchedulingOptions {
getPreferredEditor?: () => EditorType | undefined;
/** Optional function to be notified when the scheduler is waiting for user confirmation. */
onWaitingForConfirmation?: (waiting: boolean) => void;
/**
* Optional message bus override.
* If provided, the scheduler will broadcast to this bus.
* If explicitly null, broadcasting is disabled.
* If omitted, the global config message bus is used.
*/
messageBus?: MessageBus | null;
}
/**
@@ -51,6 +59,7 @@ export async function scheduleAgentTools(
signal,
getPreferredEditor,
onWaitingForConfirmation,
messageBus,
} = options;
// Create a proxy/override of the config to provide the agent-specific tool registry.
@@ -59,7 +68,10 @@ export async function scheduleAgentTools(
const scheduler = new Scheduler({
config: agentConfig,
messageBus: config.getMessageBus(),
messageBus:
messageBus === undefined
? config.getMessageBus()
: (messageBus ?? undefined),
getPreferredEditor: getPreferredEditor ?? (() => undefined),
schedulerId,
parentCallId,
@@ -363,4 +363,171 @@ 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,
},
});
});
});
});
+153
View File
@@ -15,6 +15,7 @@ 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';
@@ -39,11 +40,29 @@ 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 =
@@ -95,6 +114,66 @@ 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'),
@@ -102,6 +181,7 @@ const remoteAgentSchema = z
description: z.string().optional(),
display_name: z.string().optional(),
agent_card_url: z.string().url(),
auth: authConfigSchema.optional(),
})
.strict();
@@ -238,6 +318,76 @@ 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.
*
@@ -270,6 +420,9 @@ 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,17 +9,33 @@ 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;
/**
* Default: retry on 401/403 with fresh headers.
* Subclasses with cached tokens must override to force-refresh to avoid infinite retries.
* 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
*/
async shouldRetryWithHeaders(
_req: RequestInit,
@@ -32,10 +48,15 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
this.authRetryCount++;
return this.headers();
}
// Reset on success
// Reset count if not an auth error
this.authRetryCount = 0;
return undefined;
}
async initialize(): Promise<void> {}
/**
* Initialize the provider. Override in subclasses that need async setup.
*/
async initialize(): Promise<void> {
// Default: no-op
}
}
@@ -0,0 +1,136 @@
/**
* @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('****');
});
});
});
@@ -0,0 +1,106 @@
/**
* @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(
`[AgentHarness] [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(
`[AgentHarness] [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)}`;
}
+611
View File
@@ -0,0 +1,611 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Content,
type Part,
type FunctionDeclaration,
Type,
} from '@google/genai';
import { type Config } from '../config/config.js';
import {
type Turn,
type ServerGeminiStreamEvent,
GeminiEventType,
} from '../core/turn.js';
import {
AgentTerminateMode,
type LocalAgentDefinition,
type AgentInputs,
} from './types.js';
import { getCoreSystemPrompt } from '../core/prompts.js';
import {
getInitialChatHistory,
getDirectoryContextString,
} from '../utils/environmentContext.js';
import { templateString } from './utils.js';
import { getVersion } from '../utils/version.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { Schema } from '@google/genai';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { partToString } from '../utils/partUtils.js';
import { ideContextStore } from '../ide/ideContext.js';
import { type IdeContext } from '../ide/types.js';
import { promptIdContext } from '../utils/promptIdContext.js';
import { logRecoveryAttempt } from '../telemetry/loggers.js';
import { RecoveryAttemptEvent } from '../telemetry/types.js';
import { DeadlineTimer } from '../utils/deadlineTimer.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { ToolCallResponseInfo } from '../scheduler/types.js';
const TASK_COMPLETE_TOOL_NAME = 'complete_task';
const GRACE_PERIOD_MS = 60 * 1000;
/**
* Defines the extension points for the unified ReAct loop in AgentHarness.
*/
export interface AgentBehavior {
/** The unique ID for this agent instance. */
readonly agentId: string;
/** The human-readable name of the agent. */
readonly name: string;
/** The definition of the agent, if applicable. */
readonly definition?: LocalAgentDefinition;
/** Initializes any state needed for the agent. */
initialize(toolRegistry: ToolRegistry): Promise<void>;
/** Returns the system instruction for the chat. */
getSystemInstruction(): Promise<string | undefined>;
/** Returns the initial chat history. */
getInitialHistory(): Promise<Content[]>;
/**
* Prepares the tools list for the current turn.
* @param baseTools The tools from the tool registry.
*/
prepareTools(baseTools: FunctionDeclaration[]): FunctionDeclaration[];
/**
* Performs any environment synchronization (e.g., IDE context) before a turn.
*/
syncEnvironment(history: Content[]): Promise<{ additionalParts?: Part[] }>;
/**
* Fires the "Before Agent" hooks if applicable.
*/
fireBeforeAgent(request: Part[]): Promise<{
stop?: boolean;
reason?: string;
systemMessage?: string;
additionalContext?: string;
}>;
/**
* Fires the "After Agent" hooks if applicable.
*/
fireAfterAgent(
request: Part[],
response: string,
turn: Turn,
): Promise<{
stop?: boolean;
reason?: string;
systemMessage?: string;
contextCleared?: boolean;
shouldContinue?: boolean;
}>;
/**
* Transforms the initial request if needed (e.g. subagent 'Start' templating).
*/
transformRequest(request: Part[]): Promise<Part[]>;
/**
* Determines if the current tool results signify that the agent's goal is met.
* (e.g., Subagents checking for 'complete_task')
*/
isGoalReached(
toolResults: Array<{
name: string;
part: Part;
result: ToolCallResponseInfo;
}>,
): boolean;
/**
* Checks if the agent should continue executing after a model turn with no tool calls.
* (e.g., Main agent running next_speaker check)
*/
getContinuationRequest(
turn: Turn,
signal: AbortSignal,
): Promise<Part[] | null>;
/**
* Attempts to recover from a termination state (e.g., Subagent "Final Warning").
* Returns a stream of events if recovery is attempted.
*/
executeRecovery(
turn: Turn,
reason: AgentTerminateMode,
signal: AbortSignal,
): AsyncGenerator<ServerGeminiStreamEvent, boolean>;
/**
* Returns a final failure message for a given termination reason.
*/
getFinalFailureMessage(
reason: AgentTerminateMode,
maxTurns: number,
maxTime: number,
): string;
}
/**
* Behavior for the main CLI agent.
*/
export class MainAgentBehavior implements AgentBehavior {
readonly agentId: string;
readonly name = 'main';
private lastSentIdeContext: IdeContext | undefined;
private forceFullIdeContext = true;
constructor(
private readonly config: Config,
parentPromptId?: string,
) {
const randomIdPart = Math.random().toString(36).slice(2, 8);
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
this.agentId = `${parentPrefix}main-${randomIdPart}`;
}
async initialize(_toolRegistry: ToolRegistry) {
debugLogger.debug(
`[AgentHarness] [${this.name}:${this.agentId}] Initialized`,
);
}
async getSystemInstruction() {
const systemMemory = this.config.getUserMemory();
return getCoreSystemPrompt(this.config, systemMemory);
}
async getInitialHistory() {
return getInitialChatHistory(this.config);
}
prepareTools(baseTools: FunctionDeclaration[]) {
return baseTools;
}
async syncEnvironment(history: Content[]) {
if (!this.config.getIdeMode()) return {};
const lastMessage =
history.length > 0 ? history[history.length - 1] : undefined;
const hasPendingToolCall =
!!lastMessage &&
lastMessage.role === 'model' &&
(lastMessage.parts?.some((p) => 'functionCall' in p) || false);
if (hasPendingToolCall) return {};
const currentIdeContext = ideContextStore.get();
if (!currentIdeContext) return {};
let contextParts: string[] = [];
if (
this.forceFullIdeContext ||
this.lastSentIdeContext === undefined ||
history.length === 0
) {
contextParts = this.getFullIdeContextParts(currentIdeContext);
} else {
contextParts = this.getDeltaIdeContextParts(
currentIdeContext,
this.lastSentIdeContext,
);
}
if (contextParts.length > 0) {
this.lastSentIdeContext = currentIdeContext;
this.forceFullIdeContext = false;
return { additionalParts: [{ text: contextParts.join('\n') }] };
}
return {};
}
private getFullIdeContextParts(context: IdeContext): string[] {
const openFiles = context.workspaceState?.openFiles || [];
const activeFile = openFiles.find((f) => f.isActive);
const otherOpenFiles = openFiles
.filter((f) => !f.isActive)
.map((f) => f.path);
const contextData: Record<string, unknown> = {};
if (activeFile) {
contextData['activeFile'] = {
path: activeFile.path,
cursor: activeFile.cursor,
selectedText: activeFile.selectedText || undefined,
};
}
if (otherOpenFiles.length > 0)
contextData['otherOpenFiles'] = otherOpenFiles;
if (Object.keys(contextData).length === 0) return [];
return [
"Here is the user's editor context as a JSON object. This is for your information only.",
'```json',
JSON.stringify(contextData, null, 2),
'```',
];
}
private getDeltaIdeContextParts(
_current: IdeContext,
_last: IdeContext,
): string[] {
// Simplified delta logic for now, similar to GeminiClient
const changes: Record<string, unknown> = {};
// ... delta logic ...
if (Object.keys(changes).length === 0) return [];
return [
"Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
'```json',
JSON.stringify({ changes }, null, 2),
'```',
];
}
async fireBeforeAgent(request: Part[]) {
if (!this.config.getEnableHooks()) return {};
const hookOutput = await this.config
.getHookSystem()
?.fireBeforeAgentEvent(partToString(request));
if (!hookOutput) return {};
return {
stop: hookOutput.shouldStopExecution() || hookOutput.isBlockingDecision(),
reason: hookOutput.getEffectiveReason(),
systemMessage: hookOutput.systemMessage,
additionalContext: hookOutput.getAdditionalContext(),
};
}
async fireAfterAgent(request: Part[], response: string, turn: Turn) {
if (!this.config.getEnableHooks()) return {};
if (turn.pendingToolCalls.length > 0) return {};
const hookOutput = await this.config
.getHookSystem()
?.fireAfterAgentEvent(partToString(request), response);
if (!hookOutput) return {};
return {
stop: hookOutput.shouldStopExecution(),
shouldContinue: hookOutput.isBlockingDecision(),
reason: hookOutput.getEffectiveReason(),
systemMessage: hookOutput.systemMessage,
contextCleared: hookOutput.shouldClearContext(),
};
}
async transformRequest(request: Part[]) {
return request;
}
isGoalReached() {
return false;
}
async getContinuationRequest(turn: Turn, signal: AbortSignal) {
const nextSpeaker = await checkNextSpeaker(
turn.chat,
this.config.getBaseLlmClient(),
signal,
this.agentId,
);
if (nextSpeaker?.next_speaker === 'model') {
return [{ text: 'Please continue.' }];
}
return null;
}
async *executeRecovery(): AsyncGenerator<ServerGeminiStreamEvent, boolean> {
if (this.agentId === 'never') yield { type: GeminiEventType.Retry };
return false;
}
getFinalFailureMessage() {
return 'Execution terminated.';
}
}
/**
* Behavior for subagents.
*/
export class SubagentBehavior implements AgentBehavior {
readonly agentId: string;
readonly name: string;
constructor(
private readonly config: Config,
readonly definition: LocalAgentDefinition,
private readonly inputs?: AgentInputs,
parentPromptId?: string,
) {
this.name = definition.name;
const randomIdPart = Math.random().toString(36).slice(2, 8);
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
this.agentId = `${parentPrefix}${this.name}-${randomIdPart}`;
}
async initialize(toolRegistry: ToolRegistry) {
debugLogger.debug(
`[AgentHarness] [${this.name}:${this.agentId}] Initializing tool registry`,
);
const parentToolRegistry = this.config.getToolRegistry();
if (this.definition.toolConfig) {
for (const toolRef of this.definition.toolConfig.tools) {
if (typeof toolRef === 'string') {
const tool = parentToolRegistry.getTool(toolRef);
if (tool) toolRegistry.registerTool(tool);
} else if (typeof toolRef === 'object' && 'build' in toolRef) {
toolRegistry.registerTool(toolRef);
}
}
} else {
for (const toolName of parentToolRegistry.getAllToolNames()) {
const tool = parentToolRegistry.getTool(toolName);
if (tool) toolRegistry.registerTool(tool);
}
}
toolRegistry.sortTools();
}
async getSystemInstruction() {
const augmentedInputs = {
...this.inputs,
cliVersion: await getVersion(),
activeModel: this.config.getActiveModel(),
today: new Date().toLocaleDateString(),
};
let prompt = templateString(
this.definition.promptConfig.systemPrompt || '',
augmentedInputs,
);
const dirContext = await getDirectoryContextString(this.config);
prompt += `\n\n# Environment Context\n${dirContext}`;
prompt += `\n\nImportant Rules:\n* You are running in a non-interactive mode. You CANNOT ask the user for input or clarification.\n* Work systematically using available tools to complete your task.\n* Always use absolute paths for file operations.`;
const hasOutput = !!this.definition.outputConfig;
prompt += `\n* When you have completed your task, you MUST call the \`${TASK_COMPLETE_TOOL_NAME}\` tool${hasOutput ? ' with your structured output' : ''}.`;
return prompt;
}
async getInitialHistory() {
const initialMessages = this.definition.promptConfig.initialMessages ?? [];
if (this.inputs) {
return initialMessages.map((content) => ({
...content,
parts: (content.parts ?? []).map((part) =>
'text' in part && part.text
? { text: templateString(part.text, this.inputs!) }
: part,
),
}));
}
return initialMessages;
}
prepareTools(baseTools: FunctionDeclaration[]) {
const completeTool: FunctionDeclaration = {
name: TASK_COMPLETE_TOOL_NAME,
description:
'Call this tool to submit your final answer and complete the task.',
parameters: { type: Type.OBJECT, properties: {}, required: [] },
};
if (this.definition.outputConfig) {
const schema = zodToJsonSchema(this.definition.outputConfig.schema);
const {
$schema: _,
definitions: __,
...cleanSchema
} = schema as Record<string, unknown>;
completeTool.parameters!.properties![
this.definition.outputConfig.outputName
] = cleanSchema as Schema;
completeTool.parameters!.required!.push(
this.definition.outputConfig.outputName,
);
} else {
completeTool.parameters!.properties!['result'] = {
type: Type.STRING,
description: 'Your final results or findings.',
};
completeTool.parameters!.required!.push('result');
}
return [...baseTools, completeTool];
}
async syncEnvironment() {
return {};
}
async fireBeforeAgent() {
return {};
}
async fireAfterAgent() {
return {};
}
async transformRequest(request: Part[]): Promise<Part[]> {
if (
request.length === 1 &&
'text' in request[0] &&
request[0].text === 'Start'
) {
return [
{
text: this.definition.promptConfig.query
? templateString(
this.definition.promptConfig.query,
this.inputs || {},
)
: 'Get Started!',
},
];
}
return request;
}
isGoalReached(
toolResults: Array<{
name: string;
part: Part;
result: ToolCallResponseInfo;
}>,
) {
const completeCall = toolResults.find(
(r) => r.name === TASK_COMPLETE_TOOL_NAME,
);
if (completeCall) {
// If there's an error in the call, we don't treat it as reached (model should retry)
return !completeCall.part.functionResponse?.response?.['error'];
}
return false;
}
async getContinuationRequest() {
return null;
}
async *executeRecovery(
turn: Turn,
reason: AgentTerminateMode,
signal: AbortSignal,
): AsyncGenerator<ServerGeminiStreamEvent, boolean> {
debugLogger.debug(
`[AgentHarness] [${this.name}:${this.agentId}] Entering recovery mode. Reason: ${reason}`,
);
const recoveryStartTime = Date.now();
let success = false;
const graceTimeoutController = new DeadlineTimer(
GRACE_PERIOD_MS,
'Grace period timed out.',
);
const combinedSignal = AbortSignal.any([
signal,
graceTimeoutController.signal,
]);
try {
const recoveryMessage: Part[] = [
{ text: this.getFinalWarningMessage(reason) },
];
const promptId = `${this.agentId}#recovery`;
const recoveryStream = promptIdContext.run(promptId, () =>
turn.run(
{ model: this.config.getActiveModel() },
recoveryMessage,
combinedSignal,
),
);
for await (const event of recoveryStream) {
yield event;
}
// Check if they called complete_task in the recovery turn
const completeCall = turn.pendingToolCalls.find(
(c) => c.name === TASK_COMPLETE_TOOL_NAME,
);
if (completeCall) {
success = true;
// Capture the result in the turn object explicitly
const outputName = this.definition.outputConfig?.outputName || 'result';
const rawFindings =
completeCall.args[outputName] || completeCall.args['result'];
if (rawFindings) {
turn.submittedOutput =
typeof rawFindings === 'object'
? JSON.stringify(rawFindings, null, 2)
: String(rawFindings);
debugLogger.debug(
`[AgentHarness] [${this.name}:${this.agentId}] Captured findings from recovery complete_task. Length: ${String(turn.submittedOutput).length}`,
);
}
}
} finally {
graceTimeoutController.abort();
logRecoveryAttempt(
this.config,
new RecoveryAttemptEvent(
this.agentId,
this.name,
reason,
Date.now() - recoveryStartTime,
success,
0,
),
);
}
return success;
}
private getFinalWarningMessage(reason: AgentTerminateMode): string {
let explanation = '';
switch (reason) {
case AgentTerminateMode.TIMEOUT:
explanation = 'You have exceeded the time limit.';
break;
case AgentTerminateMode.MAX_TURNS:
explanation = 'You have exceeded the maximum number of turns.';
break;
case AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL:
explanation = 'You have stopped calling tools without finishing.';
break;
default:
explanation = 'Execution was interrupted.';
}
return `${explanation} You have one final chance to provide your findings. You MUST call \`${TASK_COMPLETE_TOOL_NAME}\` immediately with your best synthesis and conclusion for the main agent. Do not call any other tools.`;
}
getFinalFailureMessage(
reason: AgentTerminateMode,
maxTurns: number,
maxTime: number,
) {
switch (reason) {
case AgentTerminateMode.TIMEOUT:
return `Agent timed out after ${maxTime} minutes.`;
case AgentTerminateMode.MAX_TURNS:
return `Agent reached max turns limit (${maxTurns}).`;
case AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL:
return `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}'.`;
default:
return 'Agent execution was terminated before completion.';
}
}
}
@@ -109,8 +109,8 @@ export const CodebaseInvestigatorAgent = (
},
runConfig: {
maxTimeMinutes: 3,
maxTurns: 10,
maxTimeMinutes: 10,
maxTurns: 50,
},
toolConfig: {
@@ -0,0 +1,398 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { HarnessSubagentInvocation } from './harness-invocation.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { AgentFactory } from './agent-factory.js';
import { type Turn } from '../core/turn.js';
import { type Config } from '../config/config.js';
import { type MessageBus } from '../confirmation-bus/message-bus.js';
import type { z } from 'zod';
import type { Part } from '@google/genai';
import { type LocalAgentDefinition } from './types.js';
vi.mock('../core/geminiChat.js', () => ({
GeminiChat: vi.fn(),
}));
vi.mock('./agent-factory.js', () => ({
AgentFactory: {
createHarness: vi.fn(),
},
}));
describe('HarnessSubagentInvocation', () => {
let mockConfig: Config;
let mockMessageBus: MessageBus;
let definition: LocalAgentDefinition<z.ZodUnknown>;
beforeEach(() => {
mockConfig = makeFakeConfig();
mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
} as unknown as MessageBus;
definition = {
kind: 'local',
name: 'test-agent',
displayName: 'Test Agent',
description: 'A test agent',
inputConfig: {
inputSchema: { type: 'object', properties: {}, required: [] },
},
modelConfig: { model: 'test-model' },
runConfig: { maxTurns: 5 },
promptConfig: { systemPrompt: 'Test' },
};
vi.clearAllMocks();
});
it('extracts result from complete_task tool call arguments', async () => {
const invocation = new HarnessSubagentInvocation(
definition,
mockConfig,
{},
mockMessageBus,
);
const mockHarness = {
run: vi.fn().mockReturnValue(
(async function* () {
// No intermediate events
yield* [];
})(),
),
};
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
const mockChat = {
getHistory: vi.fn().mockReturnValue([
{
role: 'model',
parts: [
{
functionCall: {
name: 'complete_task',
args: { result: 'Extracted Finding' },
},
},
],
},
]),
};
const mockTurn = {
getResponseText: vi.fn().mockReturnValue(''), // Text is empty
chat: mockChat,
} as unknown as Turn;
// Simulate the generator returning the final turn
mockHarness.run.mockReturnValue(
(async function* () {
yield* [];
return mockTurn;
})(),
);
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['result']).toBe('Extracted Finding');
expect((result.llmContent as Part[])?.[0]).toEqual({
text: `Subagent 'test-agent' finished.
Termination Reason: goal
Result:
Extracted Finding`,
});
expect(result.returnDisplay).toContain('Extracted Finding');
});
it('prefers direct text response over complete_task arguments if available', async () => {
const invocation = new HarnessSubagentInvocation(
definition,
mockConfig,
{},
mockMessageBus,
);
const mockHarness = {
run: vi.fn(),
};
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
const mockChat = {
getHistory: vi.fn().mockReturnValue([
{
role: 'model',
parts: [{ text: 'Textual Result' }],
},
]),
};
const mockTurn = {
getResponseText: vi.fn().mockReturnValue('Textual Result'),
chat: mockChat,
} as unknown as Turn;
mockHarness.run.mockReturnValue(
(async function* () {
yield* [];
return mockTurn;
})(),
);
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['result']).toBe('Textual Result');
expect((result.llmContent as Part[])?.[0]).toEqual({
text: `Subagent 'test-agent' finished.
Termination Reason: goal
Result:
Textual Result`,
});
expect(result.returnDisplay).toContain('Textual Result');
});
it('falls back to a default message if no result is found', async () => {
const invocation = new HarnessSubagentInvocation(
definition,
mockConfig,
{},
mockMessageBus,
);
const mockHarness = {
run: vi.fn(),
};
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
const mockChat = {
getHistory: vi.fn().mockReturnValue([]),
};
const mockTurn = {
getResponseText: vi.fn().mockReturnValue(''),
chat: mockChat,
} as unknown as Turn;
mockHarness.run.mockReturnValue(
(async function* () {
yield* [];
return mockTurn;
})(),
);
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['result']).toBe('Task completed.');
expect(result.returnDisplay).toContain('Task completed.');
});
it('finds the LAST relevant model message if multiple exist', async () => {
const invocation = new HarnessSubagentInvocation(
definition,
mockConfig,
{},
mockMessageBus,
);
const mockHarness = {
run: vi.fn(),
};
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
const mockChat = {
getHistory: vi.fn().mockReturnValue([
{
role: 'model',
parts: [{ text: 'Old Result' }],
},
{
role: 'user',
parts: [{ text: 'Keep going' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'complete_task',
args: { result: 'Newest Result' },
},
},
],
},
]),
};
const mockTurn = {
getResponseText: vi.fn().mockReturnValue(''),
chat: mockChat,
} as unknown as Turn;
mockHarness.run.mockReturnValue(
(async function* () {
yield* [];
return mockTurn;
})(),
);
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['result']).toBe('Newest Result');
expect(result.returnDisplay).toContain('Newest Result');
});
it('handles model messages with only thoughts and no result-bearing parts', async () => {
const invocation = new HarnessSubagentInvocation(
definition,
mockConfig,
{},
mockMessageBus,
);
const mockHarness = {
run: vi.fn(),
};
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
const mockChat = {
getHistory: vi.fn().mockReturnValue([
{
role: 'model',
parts: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ thought: true, text: 'Thinking about finishing...' } as any,
],
},
]),
};
const mockTurn = {
getResponseText: vi.fn().mockReturnValue(''),
chat: mockChat,
} as unknown as Turn;
mockHarness.run.mockReturnValue(
(async function* () {
yield* [];
return mockTurn;
})(),
);
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['result']).toBe('Task completed.');
expect(result.returnDisplay).toContain('Task completed.');
});
it('extracts result using the custom outputName from outputConfig', async () => {
const customDefinition: LocalAgentDefinition = {
...definition,
outputConfig: {
outputName: 'report',
description: 'A custom report',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: { type: 'string' } as any,
},
};
const invocation = new HarnessSubagentInvocation(
customDefinition,
mockConfig,
{},
mockMessageBus,
);
const mockHarness = {
run: vi.fn(),
};
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
const mockChat = {
getHistory: vi.fn().mockReturnValue([
{
role: 'model',
parts: [
{
functionCall: {
name: 'complete_task',
args: { report: 'The custom report content' },
},
},
],
},
]),
};
const mockTurn = {
getResponseText: vi.fn().mockReturnValue(''),
chat: mockChat,
} as unknown as Turn;
mockHarness.run.mockReturnValue(
(async function* () {
yield* [];
return mockTurn;
})(),
);
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['report']).toBe('The custom report content');
expect(result.returnDisplay).toContain('The custom report content');
});
it('prioritizes complete_task args over whitespace-only text', async () => {
const invocation = new HarnessSubagentInvocation(
definition,
mockConfig,
{},
mockMessageBus,
);
const mockHarness = {
run: vi.fn(),
};
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
const mockChat = {
getHistory: vi.fn().mockReturnValue([
{
role: 'model',
parts: [
{ text: ' \n ' },
{
functionCall: {
name: 'complete_task',
args: { result: 'Actual Result' },
},
},
],
},
]),
};
const mockTurn = {
getResponseText: vi.fn().mockReturnValue(' \n '),
chat: mockChat,
} as unknown as Turn;
mockHarness.run.mockReturnValue(
(async function* () {
yield* [];
return mockTurn;
})(),
);
const result = await invocation.execute(new AbortController().signal);
expect(result.data?.['result']).toBe('Actual Result');
expect(result.returnDisplay).toContain('Actual Result');
});
});
@@ -0,0 +1,297 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { BaseToolInvocation, type ToolResult } from '../tools/tools.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { LocalAgentDefinition, AgentInputs } from './types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import { AgentFactory } from './agent-factory.js';
import { type Turn, GeminiEventType } from '../core/turn.js';
import { promptIdContext } from '../utils/promptIdContext.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
/**
* A specialized invocation for running subagents within the AgentHarness.
* COMPLETELY FORKED from LocalSubagentInvocation to ensure isolated logic.
*/
export class HarnessSubagentInvocation extends BaseToolInvocation<
AgentInputs,
ToolResult
> {
constructor(
private readonly definition: LocalAgentDefinition,
private readonly config: Config,
params: AgentInputs,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
) {
super(
params,
messageBus,
_toolName ?? definition.name,
_toolDisplayName ?? definition.displayName,
);
}
getDescription(): string {
const inputSummary = Object.entries(this.params)
.map(
([key, value]) =>
`${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
)
.join(', ');
return `Running harness subagent '${this.definition.name}' with inputs: { ${inputSummary} }`.slice(
0,
DESCRIPTION_MAX_LENGTH,
);
}
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
): Promise<ToolResult> {
try {
if (updateOutput) {
updateOutput(`Subagent ${this.definition.name} starting (Harness Mode)...
`);
}
const harness = AgentFactory.createHarness(this.config, this.definition, {
inputs: this.params,
parentPromptId: promptIdContext.getStore(),
});
const initialRequest = [{ text: 'Start' }];
const stream = harness.run(
initialRequest,
signal,
this.definition.runConfig?.maxTurns,
);
let turn: Turn | undefined;
let lastThought = '';
while (true) {
const { value, done } = await stream.next();
if (done) {
turn = value;
break;
}
const event = value;
if (updateOutput) {
if (event.type === GeminiEventType.Thought && 'value' in event) {
lastThought = event.value.subject;
updateOutput(`🤖💭 ${lastThought}\n`);
// Also publish to message bus so UI hooks can see it regardless of where they listen
void this.messageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
activity: {
agentName: this.definition.name,
type: 'THOUGHT',
data: { subject: lastThought },
},
});
} else if (
event.type === GeminiEventType.SubagentActivity &&
'value' in event
) {
if (event.value.type === 'TOOL_CALL_START') {
const toolName = String(event.value.data['name'] || 'a tool');
updateOutput(`🛠️ Calling ${toolName}...\n`);
}
// Forward the core activity to the global bus
void this.messageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
activity: event.value as any,
});
}
}
}
if (!turn) {
throw new Error('Agent failed to return a valid turn.');
}
// 1. Initialize result with the explicit submitted output if available
let finalResultRaw: unknown = turn.submittedOutput;
// 2. Fallback: If no explicit output, try textual response
if (finalResultRaw === undefined) {
const output = turn.getResponseText();
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] Initial response text: "${output}"`,
);
if (output.trim()) {
finalResultRaw = output;
}
}
const outputName = this.definition.outputConfig?.outputName || 'result';
// 3. Fallback: If still no result, extract from 'complete_task' tool call arguments (Directly from the turn)
if (finalResultRaw === undefined) {
const completeCall = turn.pendingToolCalls?.find(
(c) => c.name === 'complete_task',
);
if (completeCall) {
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] Found 'complete_task' call in pending tool calls.`,
);
finalResultRaw =
completeCall.args[outputName] || completeCall.args['result'];
if (finalResultRaw !== undefined) {
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] Extracted raw result from complete_task args (${outputName}).`,
);
}
}
}
// 4. Fallback: If no result yet, look for any definitive findings in the history
if (finalResultRaw === undefined) {
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] No direct result found, checking history...`,
);
const history = turn.chat.getHistory();
// Find the last model message that has either non-thought text or a complete_task call
const lastMsgWithResult = history.findLast(
(m) =>
m.role === 'model' &&
m.parts &&
(m.parts.some(
(p) =>
!('thought' in p && p.thought) && 'text' in p && p.text?.trim(),
) ||
m.parts.some(
(p) =>
'functionCall' in p &&
p.functionCall &&
p.functionCall.name === 'complete_task',
)),
);
if (lastMsgWithResult?.parts) {
// Check for text part first (likely injected by Harness)
const textPart = lastMsgWithResult.parts.find(
(p) =>
!('thought' in p && p.thought) && 'text' in p && p.text?.trim(),
);
if (textPart && 'text' in textPart && textPart.text) {
finalResultRaw = textPart.text;
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] Extracted result from history text part.`,
);
} else {
// Check for complete_task call in history (what the tests use)
const callPart = lastMsgWithResult.parts.find(
(p) =>
'functionCall' in p && p.functionCall?.name === 'complete_task',
);
if (
callPart &&
'functionCall' in callPart &&
callPart.functionCall
) {
finalResultRaw =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(callPart.functionCall.args as Record<string, unknown>)?.[
outputName
] ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(callPart.functionCall.args as Record<string, unknown>)?.[
'result'
];
if (finalResultRaw !== undefined) {
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] Extracted result from history function call.`,
);
}
}
}
}
}
const finalResultString =
typeof finalResultRaw === 'object'
? JSON.stringify(finalResultRaw, null, 2)
: String(finalResultRaw ?? 'Task completed.');
const displayContent = `
Subagent ${this.definition.name} Finished (Harness Mode)
Result:
${finalResultString}
`;
if (updateOutput) {
updateOutput(displayContent);
}
// Parse as JSON if it's a string that looks like an object, to satisfy schema requirements
let finalResultData = finalResultRaw ?? 'Task completed.';
if (
typeof finalResultData === 'string' &&
finalResultData.trim().startsWith('{')
) {
try {
finalResultData = JSON.parse(finalResultData);
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] Parsed string result into JSON object.`,
);
} catch (_e) {
// Not valid JSON, keep as string
}
}
debugLogger.debug(
`[AgentHarness] [Invocation:${this.definition.name}] Returning data to parent: ${JSON.stringify(
finalResultData,
).slice(0, 500)}...`,
);
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: goal
Result:
${finalResultString}`;
return {
llmContent: [{ text: resultContent }],
returnDisplay: displayContent,
data: {
[outputName]: finalResultData,
result: finalResultData,
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
llmContent: [],
returnDisplay: `Subagent Failed: ${this.definition.name}
Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
},
};
}
}
}
+447
View File
@@ -0,0 +1,447 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AgentHarness } from './harness.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import { GeminiEventType, type ServerGeminiStreamEvent } from '../core/turn.js';
import { z } from 'zod';
import { AgentTerminateMode, type LocalAgentDefinition } from './types.js';
import { scheduleAgentTools } from './agent-scheduler.js';
import { logAgentFinish } from '../telemetry/loggers.js';
import { type Config } from '../config/config.js';
import { MainAgentBehavior, SubagentBehavior } from './behavior.js';
vi.mock('../telemetry/loggers.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../telemetry/loggers.js')>();
return {
...actual,
logAgentStart: vi.fn(),
logAgentFinish: vi.fn(),
};
});
vi.mock('../core/geminiChat.js', () => ({
GeminiChat: vi.fn(),
StreamEventType: {
CHUNK: 'chunk',
},
}));
vi.mock('./agent-scheduler.js', () => ({
scheduleAgentTools: vi.fn(),
}));
describe('AgentHarness', () => {
let mockConfig: Config;
beforeEach(() => {
mockConfig = makeFakeConfig();
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getTool: vi.fn(),
getAllToolNames: vi.fn().mockReturnValue([]),
getFunctionDeclarations: vi.fn().mockReturnValue([]),
registerTool: vi.fn(),
sortTools: vi.fn(),
});
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getAllDefinitions: vi.fn().mockReturnValue([]),
});
mockConfig.getEnableHooks = vi.fn().mockReturnValue(false);
mockConfig.getHookSystem = vi.fn().mockReturnValue(null);
mockConfig.getIdeMode = vi.fn().mockReturnValue(false);
mockConfig.getBaseLlmClient = vi.fn().mockReturnValue({});
mockConfig.getModelRouterService = vi.fn().mockReturnValue({
route: vi.fn().mockResolvedValue({
model: 'gemini-test-model',
metadata: { source: 'test' },
}),
});
vi.clearAllMocks();
});
describe('SubagentBehavior', () => {
it('executes a subagent and finishes when complete_task is called', async () => {
const definition: LocalAgentDefinition<z.ZodUnknown> = {
kind: 'local',
name: 'test-agent',
displayName: 'Test Agent',
description: 'A test agent',
inputConfig: {
inputSchema: { type: 'object', properties: {}, required: [] },
},
modelConfig: { model: 'gemini-test-model' },
runConfig: { maxTurns: 5, maxTimeMinutes: 5 },
promptConfig: { systemPrompt: 'You are a test agent.' },
outputConfig: {
outputName: 'result',
description: 'The final result.',
schema: z.unknown(),
},
};
const behavior = new SubagentBehavior(mockConfig, definition);
const harness = new AgentHarness({
config: mockConfig,
behavior,
isolatedTools: true,
});
const mockChat = {
sendMessageStream: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
addHistory: vi.fn(),
setSystemInstruction: vi.fn(),
maybeIncludeSchemaDepthContext: vi.fn(),
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
} as unknown as GeminiChat;
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
// Mock model response with complete_task call
(mockChat.sendMessageStream as Mock).mockResolvedValue(
(async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Done!' }] },
finishReason: 'STOP',
},
],
functionCalls: [
{
name: 'complete_task',
args: { result: 'Success' },
id: 'call_1',
},
],
},
};
})(),
);
// Mock tool execution
(scheduleAgentTools as unknown as Mock).mockResolvedValue([
{
request: {
name: 'complete_task',
args: { result: 'Success' },
callId: 'call_1',
},
status: 'success',
response: {
responseParts: [
{
functionResponse: {
name: 'complete_task',
response: { status: 'OK' },
id: 'call_1',
},
},
],
},
},
]);
const events: ServerGeminiStreamEvent[] = [];
const run = harness.run(
[{ text: 'Start' }],
new AbortController().signal,
);
while (true) {
const { value, done } = await run.next();
if (done) break;
events.push(value);
}
expect(
events.some(
(e) =>
e.type === GeminiEventType.ToolCallRequest &&
e.value.name === 'complete_task',
),
).toBe(true);
expect(vi.mocked(logAgentFinish)).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ terminate_reason: AgentTerminateMode.GOAL }),
);
});
it('handles multiple turns and model routing', async () => {
const definition: LocalAgentDefinition<z.ZodUnknown> = {
kind: 'local',
name: 'multi-turn-agent',
description: 'Testing multiple turns',
inputConfig: {
inputSchema: { type: 'object', properties: {}, required: [] },
},
modelConfig: { model: 'initial-model' },
runConfig: { maxTurns: 5 },
promptConfig: { systemPrompt: 'Test' },
};
const behavior = new SubagentBehavior(mockConfig, definition);
const harness = new AgentHarness({ config: mockConfig, behavior });
const mockChat = {
sendMessageStream: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
addHistory: vi.fn(),
setSystemInstruction: vi.fn(),
maybeIncludeSchemaDepthContext: vi.fn(),
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
} as unknown as GeminiChat;
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
// Turn 1: Model calls a tool
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
(async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Thinking...' }] },
finishReason: 'STOP',
},
],
functionCalls: [{ name: 'tool_1', args: {}, id: 'c1' }],
},
};
})(),
);
// Turn 2: Model finishes with complete_task
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
(async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Done' }] },
finishReason: 'STOP',
},
],
functionCalls: [
{
name: 'complete_task',
args: { result: 'Success' },
id: 'c2',
},
],
},
};
})(),
);
(scheduleAgentTools as unknown as Mock).mockResolvedValue([
{
request: { name: 'tool_1', callId: 'c1' },
status: 'success',
response: {
responseParts: [
{ functionResponse: { name: 'tool_1', response: {}, id: 'c1' } },
],
},
},
]);
const run = harness.run(
[{ text: 'Start' }],
new AbortController().signal,
);
while (true) {
const { done } = await run.next();
if (done) break;
}
// Should have called LLM twice
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockConfig.getModelRouterService().route).toHaveBeenCalled();
});
it('attempts recovery when max turns is reached', async () => {
const definition: LocalAgentDefinition<z.ZodUnknown> = {
kind: 'local',
name: 'unproductive-agent',
description: 'Reaches max turns',
inputConfig: {
inputSchema: { type: 'object', properties: {}, required: [] },
},
modelConfig: { model: 'test' },
runConfig: { maxTurns: 1 },
promptConfig: { systemPrompt: 'Test' },
};
const behavior = new SubagentBehavior(mockConfig, definition);
const harness = new AgentHarness({ config: mockConfig, behavior });
const mockChat = {
sendMessageStream: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
addHistory: vi.fn(),
setSystemInstruction: vi.fn(),
maybeIncludeSchemaDepthContext: vi.fn(),
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
} as unknown as GeminiChat;
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
// Turn 1: Model does nothing (just content) -> reaches limit
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
(async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Thinking...' }] },
finishReason: 'STOP',
},
],
},
};
})(),
);
// Turn 2 (Recovery): Model yields complete_task
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
(async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Final Answer' }] },
finishReason: 'STOP',
},
],
functionCalls: [
{
name: 'complete_task',
args: { result: 'Recovered' },
id: 'rec',
},
],
},
};
})(),
);
const run = harness.run(
[{ text: 'Start' }],
new AbortController().signal,
);
while (true) {
const { done } = await run.next();
if (done) break;
}
// Expect goal to be reached via recovery
expect(vi.mocked(logAgentFinish)).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ terminate_reason: AgentTerminateMode.GOAL }),
);
});
});
describe('MainAgentBehavior', () => {
it('fires BeforeAgent hooks and handles blocking', async () => {
const behavior = new MainAgentBehavior(mockConfig);
const harness = new AgentHarness({ config: mockConfig, behavior });
const mockHookSystem = {
fireBeforeAgentEvent: vi.fn().mockResolvedValue({
shouldStopExecution: () => true,
isBlockingDecision: () => true,
getEffectiveReason: () => 'Blocked by hook',
systemMessage: 'Access denied',
getAdditionalContext: () => undefined,
}),
};
mockConfig.getHookSystem = vi.fn().mockReturnValue(mockHookSystem);
mockConfig.getEnableHooks = vi.fn().mockReturnValue(true);
const events: ServerGeminiStreamEvent[] = [];
const run = harness.run(
[{ text: 'Hello' }],
new AbortController().signal,
);
while (true) {
const { value, done } = await run.next();
if (done) break;
events.push(value);
}
expect(
events.some(
(e) =>
e.type === GeminiEventType.Error &&
e.value.error.message === 'Access denied',
),
).toBe(true);
expect(vi.mocked(logAgentFinish)).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
terminate_reason: AgentTerminateMode.ABORTED,
}),
);
});
it('syncs IDE context when IDE mode is enabled', async () => {
const behavior = new MainAgentBehavior(mockConfig);
const harness = new AgentHarness({ config: mockConfig, behavior });
mockConfig.getIdeMode = vi.fn().mockReturnValue(true);
const mockChat = {
sendMessageStream: vi.fn().mockResolvedValue(
(async function* () {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Response' }] },
finishReason: 'STOP',
},
],
},
};
})(),
),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
addHistory: vi.fn(),
setSystemInstruction: vi.fn(),
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
} as unknown as GeminiChat;
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
const syncSpy = vi.spyOn(behavior, 'syncEnvironment');
const run = harness.run(
[{ text: 'Hello' }],
new AbortController().signal,
);
while (true) {
const { done } = await run.next();
if (done) break;
}
expect(syncSpy).toHaveBeenCalled();
});
});
});
+645
View File
@@ -0,0 +1,645 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Part,
type FunctionDeclaration,
type Content,
} from '@google/genai';
import { type Config } from '../config/config.js';
import { GeminiChat } from '../core/geminiChat.js';
import {
Turn,
GeminiEventType,
type ServerGeminiStreamEvent,
CompressionStatus,
} from '../core/turn.js';
import {
AgentTerminateMode,
type AgentInputs,
DEFAULT_MAX_TURNS,
DEFAULT_MAX_TIME_MINUTES,
} from './types.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import { ToolOutputMaskingService } from '../services/toolOutputMaskingService.js';
import { resolveModel } from '../config/models.js';
import { type RoutingContext } from '../routing/routingStrategy.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { SubagentTool } from './subagent-tool.js';
import { scheduleAgentTools } from './agent-scheduler.js';
import {
type ToolCallRequestInfo,
type ToolCallResponseInfo,
ROOT_SCHEDULER_ID,
} from '../scheduler/types.js';
import { promptIdContext } from '../utils/promptIdContext.js';
import { logAgentStart, logAgentFinish } from '../telemetry/loggers.js';
import { AgentStartEvent, AgentFinishEvent } from '../telemetry/types.js';
import { DeadlineTimer } from '../utils/deadlineTimer.js';
import { type AgentBehavior } from './behavior.js';
import { debugLogger } from '../utils/debugLogger.js';
const TASK_COMPLETE_TOOL_NAME = 'complete_task';
export interface AgentHarnessOptions {
config: Config;
behavior: AgentBehavior;
/** Is this an isolated tool registry (subagents)? If not provided, uses global. */
isolatedTools?: boolean;
/** Inputs for subagent templating. */
inputs?: AgentInputs;
/** If provided, this prompt_id will be used as a prefix. */
parentPromptId?: string;
/** Existing chat history to initialize with (e.g. for main agent turns). */
history?: Content[];
}
/**
* A unified harness for executing agents (both main CLI and subagents).
* Consolidates ReAct loop logic, tool scheduling, and state management.
*
* Uses an AgentBehavior plugin to handle specific personality differences.
*/
export class AgentHarness {
private readonly config: Config;
private readonly behavior: AgentBehavior;
private readonly loopDetector: LoopDetectionService;
private readonly compressionService: ChatCompressionService;
private readonly toolOutputMaskingService: ToolOutputMaskingService;
private readonly toolRegistry: ToolRegistry;
private readonly initialHistory?: Content[];
private chat?: GeminiChat;
private currentSequenceModel: string | null = null;
private turnCounter = 0;
constructor(options: AgentHarnessOptions) {
this.config = options.config;
this.behavior = options.behavior;
this.initialHistory = options.history;
this.loopDetector = new LoopDetectionService(this.config);
this.compressionService = new ChatCompressionService();
this.toolOutputMaskingService = new ToolOutputMaskingService();
// Use an isolated tool registry for subagents, or the global one for the main agent.
this.toolRegistry = options.isolatedTools
? new ToolRegistry(this.config, this.config.getMessageBus())
: this.config.getToolRegistry();
}
/**
* Initializes the harness, creating the underlying chat object.
*/
async initialize(): Promise<void> {
await this.behavior.initialize(this.toolRegistry);
this.chat = await this.createChat();
}
private async createChat(): Promise<GeminiChat> {
const systemInstruction = await this.behavior.getSystemInstruction();
const history =
this.initialHistory ?? (await this.behavior.getInitialHistory());
const tools = this.prepareToolsList();
return new GeminiChat(
this.config,
systemInstruction,
[{ functionDeclarations: tools }],
history,
);
}
private prepareToolsList(): FunctionDeclaration[] {
const modelId = this.currentSequenceModel ?? undefined;
const baseTools = this.toolRegistry.getFunctionDeclarations(modelId);
return this.behavior.prepareTools(baseTools);
}
/**
* Runs the agent with the given request.
*/
async *run(
request: Part[],
signal: AbortSignal,
maxTurns?: number,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
const startTime = Date.now();
const maxTurnsLimit = maxTurns ?? DEFAULT_MAX_TURNS;
const maxTimeMinutes = DEFAULT_MAX_TIME_MINUTES;
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Starting unified ReAct loop. maxTurns: ${maxTurnsLimit}, maxTime: ${maxTimeMinutes}m`,
);
const deadlineTimer = new DeadlineTimer(
maxTimeMinutes * 60 * 1000,
'Agent timed out.',
);
// Track time spent waiting for user confirmation
const onWaitingForConfirmation = (waiting: boolean) => {
if (waiting) {
deadlineTimer.pause();
} else {
deadlineTimer.resume();
}
};
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
logAgentStart(
this.config,
new AgentStartEvent(this.behavior.agentId, this.behavior.name),
);
if (!this.chat) {
await this.initialize();
}
let turn = new Turn(this.chat!, this.behavior.agentId);
let currentRequest = await this.behavior.transformRequest(request);
let terminateReason = AgentTerminateMode.ABORTED;
try {
while (this.turnCounter < maxTurnsLimit) {
const promptId = `${this.behavior.agentId}#${this.turnCounter}`;
const historySize = this.chat?.getHistory().length || 0;
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Starting turn ${this.turnCounter} (promptId: ${promptId}). History size: ${historySize} messages.`,
);
if (combinedSignal.aborted) {
terminateReason = deadlineTimer.signal.aborted
? AgentTerminateMode.TIMEOUT
: AgentTerminateMode.ABORTED;
if (terminateReason === AgentTerminateMode.ABORTED) {
yield { type: GeminiEventType.UserCancelled };
}
break;
}
// 1. Hook: Before Agent
const beforeResult =
await this.behavior.fireBeforeAgent(currentRequest);
if (beforeResult.stop) {
terminateReason = AgentTerminateMode.ABORTED;
if (beforeResult.systemMessage) {
yield {
type: GeminiEventType.Error,
value: { error: { message: beforeResult.systemMessage } },
};
}
break;
}
if (beforeResult.additionalContext) {
currentRequest.push({
text: `<hook_context>${beforeResult.additionalContext}</hook_context>`,
});
}
// 2. Sync Environment (IDE Context etc)
const envSync = await this.behavior.syncEnvironment(
this.chat!.getHistory(),
);
if (envSync.additionalParts) {
currentRequest.push(...envSync.additionalParts);
}
// 3. Compression
const compressionResult = await this.tryCompressChat(promptId);
if (
compressionResult.compressionStatus === CompressionStatus.COMPRESSED
) {
yield {
type: GeminiEventType.ChatCompressed,
value: compressionResult,
};
}
await this.toolOutputMaskingService.mask(
this.chat!.getHistory(),
this.config,
);
// 4. Loop Detection
if (await this.loopDetector.turnStarted(combinedSignal)) {
terminateReason = AgentTerminateMode.LOOP_DETECTED;
yield { type: GeminiEventType.LoopDetected };
return turn;
}
// 5. Model Selection/Routing
const modelToUse = await this.selectModel(
currentRequest,
combinedSignal,
);
if (!this.currentSequenceModel) {
yield { type: GeminiEventType.ModelInfo, value: modelToUse };
this.currentSequenceModel = modelToUse;
}
// 6. Update tools for this model
this.chat!.setTools([
{ functionDeclarations: this.prepareToolsList() },
]);
// 7. Run the turn
const turnStream = promptIdContext.run(promptId, () =>
turn.run({ model: modelToUse }, currentRequest, combinedSignal),
);
let hasError = false;
let cumulativeResponse = '';
for await (const event of turnStream) {
yield event;
if (event.type === GeminiEventType.Error) hasError = true;
if (event.type === GeminiEventType.Content && event.value) {
cumulativeResponse += event.value;
}
// Subagent activity reporting
if (this.behavior.name !== 'main') {
const displayName =
this.behavior.definition?.displayName || this.behavior.name;
if (event.type === GeminiEventType.Thought) {
yield {
type: GeminiEventType.SubagentActivity,
value: {
agentName: displayName,
type: 'THOUGHT',
data: { subject: event.value.subject },
},
};
}
if (event.type === GeminiEventType.ToolCallRequest) {
yield {
type: GeminiEventType.SubagentActivity,
value: {
agentName: displayName,
type: 'TOOL_CALL_START',
data: { name: event.value.name, args: event.value.args },
},
};
}
}
}
if (hasError) {
terminateReason = AgentTerminateMode.ERROR;
return turn;
}
// 8. Hook: After Agent
const afterResult = await this.behavior.fireAfterAgent(
currentRequest,
cumulativeResponse,
turn,
);
if (afterResult.stop) {
terminateReason = AgentTerminateMode.GOAL;
if (afterResult.contextCleared) {
await this.initialize();
}
break;
}
if (afterResult.shouldContinue) {
currentRequest = [{ text: afterResult.reason || 'Continue' }];
this.turnCounter++;
turn = new Turn(this.chat!, this.behavior.agentId);
continue;
}
if (combinedSignal.aborted) {
terminateReason = deadlineTimer.signal.aborted
? AgentTerminateMode.TIMEOUT
: AgentTerminateMode.ABORTED;
break;
}
// 9. Handle tool calls or termination
if (turn.pendingToolCalls.length > 0) {
const toolResults = await this.executeTools(
turn.pendingToolCalls,
combinedSignal,
onWaitingForConfirmation,
);
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Received ${toolResults.length} tool results. Names: ${toolResults.map((tr) => tr.name).join(', ')}`,
);
// Yield responses so UI knows they are done
for (const result of toolResults) {
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Tool ${result.name} finished. Display length: ${String(result.result?.resultDisplay).length}`,
);
if (result.result) {
yield {
type: GeminiEventType.ToolCallResponse,
value: result.result,
};
// Subagent activity reporting
if (this.behavior.name !== 'main') {
yield {
type: GeminiEventType.SubagentActivity,
value: {
agentName: this.behavior.name,
type: 'TOOL_CALL_END',
data: {
name: result.name,
output: result.result.resultDisplay,
},
},
};
}
const tool = this.toolRegistry.getTool(result.name);
if (tool instanceof SubagentTool) {
yield {
type: GeminiEventType.SubagentActivity,
value: {
agentName: this.behavior.name,
type: 'TOOL_CALL_END',
data: {
name: result.name,
output: result.result.resultDisplay,
},
},
};
}
}
}
const goalReached = this.behavior.isGoalReached(toolResults);
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] isGoalReached check: ${goalReached}`,
);
if (goalReached) {
terminateReason = AgentTerminateMode.GOAL;
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Goal reached. Processing findings for ${toolResults.length} tool results.`,
);
// Extract results from the 'complete_task' tool call arguments
for (const r of toolResults) {
const completeCall = turn.pendingToolCalls.find(
(c) => c.name === TASK_COMPLETE_TOOL_NAME,
);
let findingsText: string | undefined;
if (r.name === TASK_COMPLETE_TOOL_NAME && completeCall) {
const outputName =
this.behavior.definition?.outputConfig?.outputName ||
'result';
const args = completeCall.args;
const rawFindings = args[outputName] || args['result'];
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Extracting from complete_task args (${outputName}). Found: ${!!rawFindings}`,
);
if (rawFindings !== undefined) {
// CAPTURE RAW DATA: Don't stringify if it's an object/array,
// we need to preserve structure for the parent model.
turn.submittedOutput = rawFindings;
findingsText =
typeof rawFindings === 'object'
? JSON.stringify(rawFindings, null, 2)
: String(rawFindings);
}
} else {
const findings =
r.result?.data?.['result'] || r.result?.resultDisplay;
if (findings !== undefined) {
findingsText = String(findings);
// Also capture as raw if not already set
if (turn.submittedOutput === undefined) {
turn.submittedOutput = findings;
}
}
}
if (findingsText) {
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Captured findings text. Length: ${findingsText.length}`,
);
}
}
return turn;
}
currentRequest = toolResults.map((r) => r.part);
this.turnCounter++;
if (this.turnCounter >= maxTurnsLimit) {
terminateReason = AgentTerminateMode.MAX_TURNS;
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Reached turn limit (${maxTurnsLimit}).`,
);
break;
}
turn = new Turn(this.chat!, this.behavior.agentId);
// Only yield TurnFinished if we are the main agent.
// Nested subagent turns should be internal and not trigger UI flushes in the parent.
if (this.behavior.name === 'main') {
yield { type: GeminiEventType.TurnFinished };
}
} else {
// No tool calls. Check for continuation.
const nextParts = await this.behavior.getContinuationRequest(
turn,
combinedSignal,
);
if (nextParts) {
currentRequest = nextParts;
this.turnCounter++;
if (this.turnCounter >= maxTurnsLimit) {
terminateReason = AgentTerminateMode.MAX_TURNS;
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Reached turn limit (${maxTurnsLimit}) during continuation.`,
);
break;
}
turn = new Turn(this.chat!, this.behavior.agentId);
if (this.behavior.name === 'main') {
yield { type: GeminiEventType.TurnFinished };
}
continue;
}
if (this.behavior.name !== 'main') {
terminateReason = AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL;
} else {
terminateReason = AgentTerminateMode.GOAL;
}
break;
}
}
// FINALIZATION & RECOVERY
if (
terminateReason !== AgentTerminateMode.GOAL &&
terminateReason !== AgentTerminateMode.ABORTED
) {
if (this.turnCounter >= maxTurnsLimit)
terminateReason = AgentTerminateMode.MAX_TURNS;
const recoverySuccess = yield* this.behavior.executeRecovery(
turn,
terminateReason,
signal,
);
if (recoverySuccess) {
terminateReason = AgentTerminateMode.GOAL;
return turn;
}
if (this.behavior.name !== 'main') {
yield {
type: GeminiEventType.Error,
value: {
error: {
message: this.behavior.getFinalFailureMessage(
terminateReason,
maxTurnsLimit,
maxTimeMinutes,
),
},
},
};
}
}
} finally {
deadlineTimer.abort();
const duration = Date.now() - startTime;
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Finished. Outcome: ${terminateReason}, Duration: ${duration}ms, Turns: ${this.turnCounter}`,
);
logAgentFinish(
this.config,
new AgentFinishEvent(
this.behavior.agentId,
this.behavior.name,
duration,
this.turnCounter,
terminateReason,
),
);
}
return turn;
}
private async tryCompressChat(promptId: string) {
const model =
this.currentSequenceModel ?? resolveModel(this.config.getActiveModel());
const { info } = await this.compressionService.compress(
this.chat!,
promptId,
false,
model,
this.config,
false,
);
return info;
}
private async selectModel(
request: Part[],
signal: AbortSignal,
): Promise<string> {
if (this.currentSequenceModel) return this.currentSequenceModel;
const routingContext: RoutingContext = {
history: this.chat!.getHistory(true),
request,
signal,
requestedModel: this.config.getModel(),
};
const decision = await this.config
.getModelRouterService()
.route(routingContext);
return decision.model;
}
private async executeTools(
calls: ToolCallRequestInfo[],
signal: AbortSignal,
onWaitingForConfirmation?: (waiting: boolean) => void,
): Promise<
Array<{ name: string; part: Part; result: ToolCallResponseInfo }>
> {
const taskCompleteCalls = calls.filter(
(c) => c.name === TASK_COMPLETE_TOOL_NAME,
);
const otherCalls = calls.filter((c) => c.name !== TASK_COMPLETE_TOOL_NAME);
debugLogger.debug(
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Executing ${calls.length} tool calls (${otherCalls.length} scheduled)`,
);
let completedCalls: Array<{
request: ToolCallRequestInfo;
response: ToolCallResponseInfo;
}> = [];
if (otherCalls.length > 0) {
const schedulerId =
this.behavior.name === 'main'
? ROOT_SCHEDULER_ID
: this.behavior.agentId;
completedCalls = await scheduleAgentTools(this.config, otherCalls, {
schedulerId,
toolRegistry: this.toolRegistry,
signal,
onWaitingForConfirmation,
});
}
const results = completedCalls.map((call) => ({
name: call.request.name,
part: call.response.responseParts[0],
result: call.response,
}));
for (const call of taskCompleteCalls) {
const response: ToolCallResponseInfo = {
callId: call.callId,
responseParts: [
{
functionResponse: {
name: TASK_COMPLETE_TOOL_NAME,
response: { result: 'Task completed locally' },
id: call.callId,
},
},
],
resultDisplay: 'Task completed locally',
error: undefined,
errorType: undefined,
contentLength: 'Task completed locally'.length,
};
results.push({
name: TASK_COMPLETE_TOOL_NAME,
part: response.responseParts[0],
result: response,
});
}
return results;
}
}
+24 -3
View File
@@ -235,6 +235,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
onWaitingForConfirmation?: (waiting: boolean) => void,
): Promise<AgentTurnResult> {
const promptId = `${this.agentId}#${turnCounter}`;
debugLogger.debug(
`[LegacySubagent] [${this.definition.name}:${this.agentId}] Starting turn ${turnCounter} (promptId: ${promptId})`,
);
await this.tryCompressChat(chat, promptId);
@@ -242,6 +245,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.callModel(chat, currentMessage, combinedSignal, promptId),
);
if (functionCalls.length > 0) {
debugLogger.debug(
`[LegacySubagent] [${this.definition.name}:${this.agentId}] Model made ${
functionCalls.length
} function calls: ${functionCalls.map((fc) => fc.name).join(', ')}`,
);
}
if (combinedSignal.aborted) {
const terminateReason = timeoutSignal.aborted
? AgentTerminateMode.TIMEOUT
@@ -296,7 +307,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
reason:
| AgentTerminateMode.TIMEOUT
| AgentTerminateMode.MAX_TURNS
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL,
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL
| AgentTerminateMode.LOOP_DETECTED,
): string {
let explanation = '';
switch (reason) {
@@ -327,7 +339,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
reason:
| AgentTerminateMode.TIMEOUT
| AgentTerminateMode.MAX_TURNS
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL,
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL
| AgentTerminateMode.LOOP_DETECTED,
externalSignal: AbortSignal, // The original signal passed to run()
onWaitingForConfirmation?: (waiting: boolean) => void,
): Promise<string | null> {
@@ -441,6 +454,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// Combine the external signal with the internal timeout signal.
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
debugLogger.debug(
`[LocalAgentExecutor] [${this.definition.name}:${this.agentId}] Starting agent run`,
);
logAgentStart(
this.runtimeContext,
new AgentStartEvent(this.agentId, this.definition.name),
@@ -612,12 +629,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
throw error; // Re-throw other errors or external aborts.
} finally {
deadlineTimer.abort();
const duration = Date.now() - startTime;
debugLogger.debug(
`[LocalAgentExecutor] [${this.definition.name}:${this.agentId}] Finished. Outcome: ${terminateReason}, Duration: ${duration}ms, Turns: ${turnCounter}`,
);
logAgentFinish(
this.runtimeContext,
new AgentFinishEvent(
this.agentId,
this.definition.name,
Date.now() - startTime,
duration,
turnCounter,
terminateReason,
),
@@ -126,6 +126,10 @@ ${output.result}
return {
llmContent: [{ text: resultContent }],
returnDisplay: displayContent,
data: {
result: output.result,
terminate_reason: output.terminate_reason,
},
};
} catch (error) {
const errorMessage =
@@ -13,6 +13,7 @@ import {
import type { Config } from '../config/config.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { HarnessSubagentInvocation } from './harness-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -79,6 +80,17 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
);
}
if (this.config.isAgentHarnessEnabled()) {
return new HarnessSubagentInvocation(
definition,
this.config,
params,
effectiveMessageBus,
_toolName,
_toolDisplayName,
);
}
return new LocalSubagentInvocation(
definition,
this.config,
+2 -1
View File
@@ -25,6 +25,7 @@ export enum AgentTerminateMode {
MAX_TURNS = 'MAX_TURNS',
ABORTED = 'ABORTED',
ERROR_NO_COMPLETE_TASK_CALL = 'ERROR_NO_COMPLETE_TASK_CALL',
LOOP_DETECTED = 'LOOP_DETECTED',
}
/**
@@ -43,7 +44,7 @@ export const DEFAULT_QUERY_STRING = 'Get Started!';
/**
* The default maximum number of conversational turns for an agent.
*/
export const DEFAULT_MAX_TURNS = 15;
export const DEFAULT_MAX_TURNS = 40;
/**
* The default maximum execution time for an agent in minutes.
@@ -58,7 +58,7 @@ describe('Fallback Integration', () => {
);
});
it('should NOT fallback if config is NOT in AUTO mode', () => {
it('should fallback for Gemini 3 models even 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 stay on Pro (because single model chain)
expect(result.model).toBe(PREVIEW_GEMINI_MODEL);
// 5. Expect it to fallback to Flash (because Gemini 3 uses PREVIEW_CHAIN)
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
});
@@ -115,6 +115,19 @@ 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,6 +24,7 @@ import {
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
isAutoModel,
isGemini3Model,
resolveModel,
} from '../config/models.js';
import type { ModelSelectionResult } from './modelAvailabilityService.js';
@@ -46,17 +47,32 @@ 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 (isAutoPreferred || isAutoConfigured) {
const previewEnabled =
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled,
userTier: config.getUserTier(),
});
} 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 {
chain = createSingleModelChain(modelFromConfig);
}
+7 -14
View File
@@ -470,6 +470,7 @@ export interface ConfigParameters {
disabledHooks?: string[];
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
enableAgents?: boolean;
enableAgentHarness?: boolean;
enableEventDrivenScheduler?: boolean;
skillsSupport?: boolean;
disabledSkills?: string[];
@@ -477,8 +478,6 @@ export interface ConfigParameters {
experimentalJitContext?: boolean;
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean;
sessionLearnings?: boolean;
sessionLearningsOutputPath?: string;
plan?: boolean;
onModelChange?: (model: string) => void;
mcpEnabled?: boolean;
@@ -656,6 +655,7 @@ export class Config {
| undefined;
private readonly enableAgents: boolean;
private readonly enableAgentHarness: boolean;
private agents: AgentSettings;
private readonly enableEventDrivenScheduler: boolean;
private readonly skillsSupport: boolean;
@@ -664,8 +664,6 @@ 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;
@@ -752,10 +750,9 @@ export class Config {
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false;
this.enableAgentHarness = params.enableAgentHarness ?? 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;
@@ -1959,14 +1956,6 @@ export class Config {
return this.disableLLMCorrection;
}
isSessionLearningsEnabled(): boolean {
return this.sessionLearnings;
}
getSessionLearningsOutputPath(): string | undefined {
return this.sessionLearningsOutputPath;
}
isPlanEnabled(): boolean {
return this.planEnabled;
}
@@ -1983,6 +1972,10 @@ export class Config {
return this.enableAgents;
}
isAgentHarnessEnabled(): boolean {
return this.enableAgentHarness;
}
isEventDrivenSchedulerEnabled(): boolean {
return this.enableEventDrivenScheduler;
}
@@ -96,6 +96,12 @@ 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: {
@@ -151,7 +157,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
},
'web-search': {
extends: 'gemini-2.5-flash-base',
extends: 'gemini-3-flash-base',
modelConfig: {
generateContentConfig: {
tools: [{ googleSearch: {} }],
@@ -159,7 +165,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
},
'web-fetch': {
extends: 'gemini-2.5-flash-base',
extends: 'gemini-3-flash-base',
modelConfig: {
generateContentConfig: {
tools: [{ urlContext: {} }],
@@ -168,25 +174,25 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
// TODO(joshualitt): During cleanup, make modelConfig optional.
'web-fetch-fallback': {
extends: 'gemini-2.5-flash-base',
extends: 'gemini-3-flash-base',
modelConfig: {},
},
'loop-detection': {
extends: 'gemini-2.5-flash-base',
extends: 'gemini-3-flash-base',
modelConfig: {},
},
'loop-detection-double-check': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-pro',
model: 'gemini-3-pro-preview',
},
},
'llm-edit-fixer': {
extends: 'gemini-2.5-flash-base',
extends: 'gemini-3-flash-base',
modelConfig: {},
},
'next-speaker-checker': {
extends: 'gemini-2.5-flash-base',
extends: 'gemini-3-flash-base',
modelConfig: {},
},
'chat-compression-3-pro': {
@@ -216,7 +222,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
'chat-compression-default': {
modelConfig: {
model: 'gemini-2.5-pro',
model: 'gemini-3-pro-preview',
},
},
},
@@ -42,7 +42,10 @@ export class MessageBus extends EventEmitter {
async publish(message: Message): Promise<void> {
if (this.debug) {
debugLogger.debug(`[MESSAGE_BUS] publish: ${safeJsonStringify(message)}`);
const json = safeJsonStringify(message);
debugLogger.debug(
`[MESSAGE_BUS] publish: ${json.length > 500 ? json.substring(0, 500) + '...' : json}`,
);
}
try {
if (!this.isValidMessage(message)) {
+12 -1
View File
@@ -19,10 +19,20 @@ export enum MessageBusType {
TOOL_EXECUTION_FAILURE = 'tool-execution-failure',
UPDATE_POLICY = 'update-policy',
TOOL_CALLS_UPDATE = 'tool-calls-update',
SUBAGENT_ACTIVITY = 'subagent-activity',
ASK_USER_REQUEST = 'ask-user-request',
ASK_USER_RESPONSE = 'ask-user-response',
}
export interface SubagentActivityMessage {
type: MessageBusType.SUBAGENT_ACTIVITY;
activity: {
agentName: string;
type: 'THOUGHT' | 'TOOL_CALL_START';
data: Record<string, unknown>;
};
}
export interface ToolCallsUpdateMessage {
type: MessageBusType.TOOL_CALLS_UPDATE;
toolCalls: ToolCall[];
@@ -180,4 +190,5 @@ export type Message =
| UpdatePolicy
| AskUserRequest
| AskUserResponse
| ToolCallsUpdateMessage;
| ToolCallsUpdateMessage
| SubagentActivityMessage;
@@ -519,6 +519,10 @@ 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.
@@ -646,6 +650,10 @@ 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.
@@ -738,6 +746,10 @@ 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.
@@ -1299,6 +1311,10 @@ 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.
@@ -1422,6 +1438,10 @@ 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.
@@ -1536,6 +1556,10 @@ 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.
@@ -1650,6 +1674,10 @@ 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.
@@ -1760,6 +1788,10 @@ 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.
@@ -1870,6 +1902,10 @@ 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.
@@ -2219,6 +2255,10 @@ 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.
@@ -2329,6 +2369,10 @@ 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.
@@ -2550,6 +2594,10 @@ 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.
@@ -2660,6 +2708,10 @@ 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.
+1
View File
@@ -243,6 +243,7 @@ describe('Gemini Client (client.ts)', () => {
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
getContinueOnFailedApiCall: vi.fn(),
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
isAgentHarnessEnabled: vi.fn().mockReturnValue(false),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/test/temp'),
},
+53 -1
View File
@@ -7,6 +7,7 @@
import type {
GenerateContentConfig,
PartListUnion,
Part,
Content,
Tool,
GenerateContentResponse,
@@ -62,8 +63,10 @@ import {
} from '../availability/policyHelpers.js';
import { resolveModel } from '../config/models.js';
import type { RetryAvailabilityContext } from '../utils/retry.js';
import { partToString } from '../utils/partUtils.js';
import { partToString, toPartArray } from '../utils/partUtils.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import { AgentFactory } from '../agents/agent-factory.js';
import { type AgentHarness } from '../agents/harness.js';
const MAX_TURNS = 100;
@@ -90,6 +93,7 @@ export class GeminiClient {
private currentSequenceModel: string | null = null;
private lastSentIdeContext: IdeContext | undefined;
private forceFullIdeContext = true;
private harness?: AgentHarness;
/**
* At any point in this conversation, was compression triggered without
@@ -556,6 +560,9 @@ export class GeminiClient {
let turn = new Turn(this.getChat(), prompt_id);
this.sessionTurnCount++;
debugLogger.debug(
`[LegacyLoop] processTurn started. sessionTurnCount: ${this.sessionTurnCount}, prompt_id: ${prompt_id}`,
);
if (
this.config.getMaxSessionTurns() > 0 &&
this.sessionTurnCount > this.config.getMaxSessionTurns()
@@ -788,10 +795,55 @@ export class GeminiClient {
isInvalidStreamRetry: boolean = false,
displayContent?: PartListUnion,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
debugLogger.debug(
`[LegacyLoop] sendMessageStream started. prompt_id: ${prompt_id}, turns left: ${turns}`,
);
if (!isInvalidStreamRetry) {
this.config.resetTurn();
}
if (this.config.isAgentHarnessEnabled()) {
debugLogger.debug(
'[GeminiClient] Using AgentHarness for message execution.',
);
this.sessionTurnCount++;
if (
this.config.getMaxSessionTurns() > 0 &&
this.sessionTurnCount > this.config.getMaxSessionTurns()
) {
yield { type: GeminiEventType.MaxSessionTurns };
return new Turn(this.getChat(), prompt_id);
}
if (!this.harness || this.lastPromptId !== prompt_id) {
this.harness = AgentFactory.createHarness(this.config, undefined, {
parentPromptId: prompt_id,
history: this.getChat().getHistory(),
});
this.lastPromptId = prompt_id;
}
const requestParts: Part[] = toPartArray(request);
const stream = this.harness.run(requestParts, signal, turns);
let turn: Turn | undefined;
while (true) {
const { value, done } = await stream.next();
if (done) {
turn = value;
break;
}
yield value;
}
if (turn) {
// Sync history back to GeminiClient's chat for transcript persistence
this.getChat().setHistory(turn.chat.getHistory());
return turn;
}
return new Turn(this.getChat(), prompt_id);
}
const hooksEnabled = this.config.getEnableHooks();
const messageBus = this.config.getMessageBus();
+20 -2
View File
@@ -68,8 +68,23 @@ export enum GeminiEventType {
ModelInfo = 'model_info',
AgentExecutionStopped = 'agent_execution_stopped',
AgentExecutionBlocked = 'agent_execution_blocked',
SubagentActivity = 'subagent_activity',
TurnFinished = 'turn_finished',
}
export type ServerGeminiSubagentActivityEvent = {
type: GeminiEventType.SubagentActivity;
value: {
agentName: string;
type: string;
data: Record<string, unknown>;
};
};
export type ServerGeminiTurnFinishedEvent = {
type: GeminiEventType.TurnFinished;
};
export type ServerGeminiRetryEvent = {
type: GeminiEventType.Retry;
};
@@ -229,7 +244,9 @@ export type ServerGeminiStreamEvent =
| ServerGeminiInvalidStreamEvent
| ServerGeminiModelInfoEvent
| ServerGeminiAgentExecutionStoppedEvent
| ServerGeminiAgentExecutionBlockedEvent;
| ServerGeminiAgentExecutionBlockedEvent
| ServerGeminiSubagentActivityEvent
| ServerGeminiTurnFinishedEvent;
// A turn manages the agentic loop turn within the server context.
export class Turn {
@@ -239,9 +256,10 @@ export class Turn {
private debugResponses: GenerateContentResponse[] = [];
private pendingCitations = new Set<string>();
finishReason: FinishReason | undefined = undefined;
submittedOutput: unknown;
constructor(
private readonly chat: GeminiChat,
readonly chat: GeminiChat,
private readonly prompt_id: string,
) {}
@@ -74,7 +74,6 @@ 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);
@@ -280,21 +279,6 @@ 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', () => {
+1 -29
View File
@@ -6,12 +6,7 @@
import type { Config } from '../config/config.js';
import type { HookDefinition, HookConfig } from './types.js';
import {
HookEventName,
ConfigSource,
HOOKS_CONFIG_FIELDS,
HookType,
} from './types.js';
import { HookEventName, ConfigSource, HOOKS_CONFIG_FIELDS } from './types.js';
import { debugLogger } from '../utils/debugLogger.js';
import { TrustedHooksManager } from './trustedHooks.js';
import { coreEvents } from '../utils/events.js';
@@ -142,8 +137,6 @@ 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) {
@@ -168,27 +161,6 @@ 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
*/
@@ -1,109 +0,0 @@
/**
* @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();
});
});
+7 -71
View File
@@ -6,7 +6,7 @@
import { spawn } from 'node:child_process';
import type { HookConfig } from './types.js';
import { HookEventName, ConfigSource, HookType } from './types.js';
import { HookEventName, ConfigSource } from './types.js';
import type { Config } from '../config/config.js';
import type {
HookInput,
@@ -16,9 +16,7 @@ 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';
@@ -27,7 +25,6 @@ import {
getShellConfiguration,
type ShellType,
} from '../utils/shell-utils.js';
import { SessionLearningsService } from '../services/sessionLearningsService.js';
/**
* Default timeout for hook execution (60 seconds)
@@ -46,11 +43,9 @@ 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);
}
/**
@@ -81,25 +76,12 @@ export class HookRunner {
}
try {
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}`,
);
}
return await this.executeCommandHook(
hookConfig,
eventName,
input,
startTime,
);
} catch (error) {
const duration = Date.now() - startTime;
const hookId = hookConfig.name || hookConfig.command || 'unknown';
@@ -249,52 +231,6 @@ 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
*/
+3 -14
View File
@@ -62,16 +62,7 @@ export interface CommandHookConfig {
env?: Record<string, string>;
}
export interface BuiltinHookConfig {
type: HookType.Builtin;
builtin_id: string;
name?: string;
description?: string;
timeout?: number;
source?: ConfigSource;
}
export type HookConfig = CommandHookConfig | BuiltinHookConfig;
export type HookConfig = CommandHookConfig;
/**
* Hook definition with matcher
@@ -87,7 +78,6 @@ export interface HookDefinition {
*/
export enum HookType {
Command = 'command',
Builtin = 'builtin',
}
/**
@@ -95,9 +85,8 @@ export enum HookType {
*/
export function getHookKey(hook: HookConfig): string {
const name = hook.name || '';
const identifier =
hook.type === HookType.Command ? hook.command : hook.builtin_id;
return `${name}:${identifier}`;
const command = hook.command || '';
return `${name}:${command}`;
}
/**
+1
View File
@@ -138,6 +138,7 @@ export * from './prompts/mcp-prompts.js';
// Export agent definitions
export * from './agents/types.js';
export * from './agents/agent-factory.js';
export * from './agents/agentLoader.js';
export * from './agents/local-executor.js';
+4
View File
@@ -164,6 +164,10 @@ 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.
@@ -16,6 +16,7 @@ import {
} from 'vitest';
import { EventEmitter } from 'node:events';
import { awaitConfirmation, resolveConfirmation } from './confirmation.js';
import * as EditorUtils from '../utils/editor.js';
import {
MessageBusType,
type ToolConfirmationResponse,
@@ -34,6 +35,8 @@ import type { Config } from '../config/config.js';
import type { EditorType } from '../utils/editor.js';
import { randomUUID } from 'node:crypto';
import { randomUUID } from 'node:crypto';
// Mock Dependencies
vi.mock('node:crypto', () => ({
randomUUID: vi.fn(),
@@ -123,6 +126,7 @@ describe('confirmation.ts', () => {
let toolMock: Mocked<AnyDeclarativeTool>;
beforeEach(() => {
vi.spyOn(EditorUtils, 'resolveEditorAsync').mockResolvedValue('vim');
signal = new AbortController().signal;
mockState = {
@@ -219,7 +219,11 @@ describe('Scheduler (Orchestrator)', () => {
let capturedTerminalHandler: TerminalCallHandler | undefined;
vi.mocked(SchedulerStateManager).mockImplementation(
(_messageBus, _schedulerId, onTerminalCall) => {
(
_messageBus: MessageBus | undefined,
_schedulerId: string | undefined,
onTerminalCall: TerminalCallHandler | undefined,
) => {
capturedTerminalHandler = onTerminalCall;
return mockStateManager as unknown as SchedulerStateManager;
},
+13 -9
View File
@@ -47,7 +47,7 @@ interface SchedulerQueueItem {
export interface SchedulerOptions {
config: Config;
messageBus: MessageBus;
messageBus?: MessageBus;
getPreferredEditor: () => EditorType | undefined;
schedulerId: string;
parentCallId?: string;
@@ -87,7 +87,7 @@ export class Scheduler {
private readonly executor: ToolExecutor;
private readonly modifier: ToolModificationHandler;
private readonly config: Config;
private readonly messageBus: MessageBus;
private readonly messageBus?: MessageBus;
private readonly getPreferredEditor: () => EditorType | undefined;
private readonly schedulerId: string;
private readonly parentCallId?: string;
@@ -112,11 +112,13 @@ export class Scheduler {
this.executor = new ToolExecutor(this.config);
this.modifier = new ToolModificationHandler();
this.setupMessageBusListener(this.messageBus);
if (this.messageBus) {
this.setupMessageBusListener(this.messageBus);
}
}
private setupMessageBusListener(messageBus: MessageBus): void {
if (Scheduler.subscribedMessageBuses.has(messageBus)) {
if (!messageBus || Scheduler.subscribedMessageBuses.has(messageBus)) {
return;
}
@@ -432,7 +434,7 @@ export class Scheduler {
let outcome = ToolConfirmationOutcome.ProceedOnce;
let lastDetails: SerializableConfirmationDetails | undefined;
if (decision === PolicyDecision.ASK_USER) {
if (decision === PolicyDecision.ASK_USER && this.messageBus) {
const result = await resolveConfirmation(toolCall, signal, {
config: this.config,
messageBus: this.messageBus,
@@ -449,10 +451,12 @@ export class Scheduler {
}
// Handle Policy Updates
await updatePolicy(toolCall.tool, outcome, lastDetails, {
config: this.config,
messageBus: this.messageBus,
});
if (this.messageBus) {
await updatePolicy(toolCall.tool, outcome, lastDetails, {
config: this.config,
messageBus: this.messageBus,
});
}
// Handle cancellation (cascades to entire batch)
if (outcome === ToolConfirmationOutcome.Cancel) {
+5 -1
View File
@@ -46,7 +46,7 @@ export class SchedulerStateManager {
private _completedBatch: CompletedToolCall[] = [];
constructor(
private readonly messageBus: MessageBus,
private readonly messageBus: MessageBus | undefined,
private readonly schedulerId: string = ROOT_SCHEDULER_ID,
private readonly onTerminalCall?: TerminalCallHandler,
) {}
@@ -210,6 +210,10 @@ export class SchedulerStateManager {
}
private emitUpdate() {
if (!this.messageBus) {
return;
}
const snapshot = this.getSnapshot();
// Fire and forget - The message bus handles the publish and error handling.
@@ -46,9 +46,6 @@ 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,9 +103,6 @@ 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',
],
);
@@ -1,182 +0,0 @@
/**
* @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();
});
});
@@ -1,158 +0,0 @@
/**
* @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,6 +104,13 @@
"topP": 1
}
},
"gemini-3-flash-base": {
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"classifier": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
@@ -153,7 +160,7 @@
}
},
"web-search": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
@@ -165,7 +172,7 @@
}
},
"web-fetch": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
@@ -177,35 +184,35 @@
}
},
"web-fetch-fallback": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"loop-detection": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"loop-detection-double-check": {
"model": "gemini-2.5-pro",
"model": "gemini-3-pro-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"llm-edit-fixer": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"next-speaker-checker": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
@@ -232,7 +239,7 @@
"generateContentConfig": {}
},
"chat-compression-default": {
"model": "gemini-2.5-pro",
"model": "gemini-3-pro-preview",
"generateContentConfig": {}
}
}
@@ -104,6 +104,13 @@
"topP": 1
}
},
"gemini-3-flash-base": {
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"classifier": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
@@ -153,7 +160,7 @@
}
},
"web-search": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
@@ -165,7 +172,7 @@
}
},
"web-fetch": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
@@ -177,35 +184,35 @@
}
},
"web-fetch-fallback": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"loop-detection": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"loop-detection-double-check": {
"model": "gemini-2.5-pro",
"model": "gemini-3-pro-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"llm-edit-fixer": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"next-speaker-checker": {
"model": "gemini-2.5-flash",
"model": "gemini-3-flash-preview",
"generateContentConfig": {
"temperature": 0,
"topP": 1
@@ -232,7 +239,7 @@
"generateContentConfig": {}
},
"chat-compression-default": {
"model": "gemini-2.5-pro",
"model": "gemini-3-pro-preview",
"generateContentConfig": {}
}
}
@@ -45,6 +45,10 @@ 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",
@@ -54,6 +58,10 @@ 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",
@@ -254,6 +262,10 @@ 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",
@@ -263,6 +275,10 @@ 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,7 +5,6 @@
*/
import type { ToolDefinition } from './types.js';
import { Type } from '@google/genai';
import * as os from 'node:os';
// Centralized tool names to avoid circular dependencies
@@ -25,21 +24,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: Type.OBJECT,
type: 'object',
properties: {
file_path: {
description: 'The path to the file to read.',
type: Type.STRING,
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: Type.NUMBER,
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: Type.NUMBER,
type: 'number',
},
},
required: ['file_path'],
@@ -58,15 +57,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: Type.OBJECT,
type: 'object',
properties: {
file_path: {
description: 'The path to the file to write to.',
type: Type.STRING,
type: 'string',
},
content: {
description: 'The content to write to the file.',
type: Type.STRING,
type: 'string',
},
},
required: ['file_path', 'content'],
@@ -84,31 +83,41 @@ export const GREP_DEFINITION: ToolDefinition = {
description:
'Searches for a regular expression pattern within file contents. Max 100 matches.',
parametersJsonSchema: {
type: Type.OBJECT,
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: Type.STRING,
type: 'string',
},
dir_path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: Type.STRING,
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: Type.STRING,
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',
},
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: Type.INTEGER,
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: Type.INTEGER,
type: 'integer',
minimum: 1,
},
},
@@ -127,32 +136,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: Type.OBJECT,
type: 'object',
properties: {
pattern: {
description:
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
type: Type.STRING,
type: 'string',
},
dir_path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
type: Type.STRING,
type: 'string',
},
case_sensitive: {
description:
'Optional: Whether the search should be case-sensitive. Defaults to false.',
type: Type.BOOLEAN,
type: 'boolean',
},
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
type: Type.BOOLEAN,
type: 'boolean',
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when finding files. Defaults to true.',
type: Type.BOOLEAN,
type: 'boolean',
},
},
required: ['pattern'],
@@ -170,33 +179,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: Type.OBJECT,
type: 'object',
properties: {
dir_path: {
description: 'The path to the directory to list',
type: Type.STRING,
type: 'string',
},
ignore: {
description: 'List of glob patterns to ignore',
items: {
type: Type.STRING,
type: 'string',
},
type: Type.ARRAY,
type: 'array',
},
file_filtering_options: {
description:
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
type: Type.OBJECT,
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: Type.BOOLEAN,
type: 'boolean',
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
type: Type.BOOLEAN,
type: 'boolean',
},
},
},
@@ -274,24 +283,24 @@ export function getShellDefinition(
enableEfficiency,
),
parametersJsonSchema: {
type: Type.OBJECT,
type: 'object',
properties: {
command: {
type: Type.STRING,
type: 'string',
description: getCommandDescription(),
},
description: {
type: Type.STRING,
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: Type.STRING,
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: Type.BOOLEAN,
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.',
},
+35
View File
@@ -498,6 +498,41 @@ 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', () => {
+45
View File
@@ -49,6 +49,16 @@ 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.
*/
@@ -225,6 +235,7 @@ 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,
@@ -280,6 +291,16 @@ 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) {
@@ -354,6 +375,7 @@ 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;
@@ -362,12 +384,18 @@ 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'));
@@ -400,6 +428,9 @@ 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;
@@ -467,6 +498,9 @@ 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;
@@ -528,6 +562,9 @@ 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) ||
@@ -637,6 +674,14 @@ 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
+2 -71
View File
@@ -1623,7 +1623,7 @@ describe('mcp-client', () => {
{
command: 'test-command',
args: ['--foo', 'bar'],
env: { GEMINI_CLI_FOO: 'bar' },
env: { FOO: 'bar' },
cwd: 'test/cwd',
},
false,
@@ -1634,80 +1634,11 @@ describe('mcp-client', () => {
command: 'test-command',
args: ['--foo', 'bar'],
cwd: 'test/cwd',
env: expect.objectContaining({ GEMINI_CLI_FOO: 'bar' }),
env: expect.objectContaining({ 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')
+5 -36
View File
@@ -34,11 +34,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import { parse } from 'shell-quote';
import type {
Config,
GeminiCLIExtension,
MCPServerConfig,
} from '../config/config.js';
import type { Config, 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';
@@ -1902,23 +1898,10 @@ export async function createTransport(
command: mcpServerConfig.command,
args: mcpServerConfig.args || [],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
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>,
env: {
...sanitizeEnvironment(process.env, sanitizationConfig),
...(mcpServerConfig.env || {}),
} as Record<string, string>,
cwd: mcpServerConfig.cwd,
stderr: 'pipe',
});
@@ -1993,17 +1976,3 @@ 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;
}
+79
View File
@@ -1930,6 +1930,85 @@ 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');
});
});
});
+53 -1
View File
@@ -102,6 +102,16 @@ 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.
*/
@@ -244,6 +254,7 @@ 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,
@@ -299,6 +310,16 @@ 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) {
@@ -330,6 +351,7 @@ class GrepToolInvocation extends BaseToolInvocation<
pattern: string;
path: string;
include?: string;
exclude_pattern?: string;
case_sensitive?: boolean;
fixed_strings?: boolean;
context?: number;
@@ -344,6 +366,7 @@ class GrepToolInvocation extends BaseToolInvocation<
pattern,
path: absolutePath,
include,
exclude_pattern,
case_sensitive,
fixed_strings,
context,
@@ -423,9 +446,18 @@ 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++;
@@ -527,7 +559,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
super(
RipGrepTool.Name,
'SearchText',
'Searches for a regular expression pattern within file contents. Max 100 matches.',
'Searches for a regular expression pattern within file contents.',
Kind.Search,
{
properties: {
@@ -546,6 +578,16 @@ 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.',
@@ -565,11 +607,13 @@ 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:
@@ -618,6 +662,14 @@ 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
+14
View File
@@ -168,3 +168,17 @@ export function appendToLastTextPart(
return newPrompt;
}
/**
* Normalizes a PartListUnion into an array of Parts.
*/
export function toPartArray(value: PartListUnion): Part[] {
if (!value) return [];
const items = Array.isArray(value) ? value : [value];
return items.map((item) => {
if (typeof item === 'string') {
return { text: item };
}
return item;
});
}
File diff suppressed because one or more lines are too long