Compare commits

..

17 Commits

Author SHA1 Message Date
mkorwel a0f32ff7d6 chore: fix circular dependency and complete dynamic model configuration migration 2026-03-13 17:43:18 +00:00
Kevin Wang 1693afe027 pull and merge latest changes 2026-03-13 09:29:33 +00:00
kevinjwang1 d179e3673b Merge branch 'main' into splitModelConfigurability 2026-03-13 02:02:55 -07:00
Kevin Wang e4d6a5cc7c Make ensure default definitions are merged with user defined definitions 2026-03-13 08:53:20 +00:00
Kevin Wang fc4fdc3cbe regenerate docs 2026-03-13 08:38:05 +00:00
Kevin Wang bdbb996a2e Add ModelDefinitions to ModelConfigService 2026-03-13 08:25:03 +00:00
Abhi 2d05396dd2 refactor(ui): extract pure session browser utilities (#22256) 2026-03-13 06:22:52 +00:00
Sandy Tao 7b4a822b0e feat(core): instrument file system tools for JIT context discovery (#22082) 2026-03-13 03:44:42 +00:00
Bryan Morgan d44615ac2f feat(core): increase sub-agent turn and time limits (#22196) 2026-03-13 02:39:49 +00:00
joshualitt de656f01d7 feat(core): Fully migrate packages/core to AgentLoopContext. (#22115) 2026-03-13 01:56:31 +00:00
Abhi 1d2585dba6 fix(core): explicitly pass messageBus to policy engine for MCP tool saves (#22255) 2026-03-13 01:31:13 +00:00
Christian Gunderman 97bc3f28c5 build(ci): fix false positive evals trigger on merge commits (#22237) 2026-03-13 00:38:54 +00:00
gemini-cli-robot 3038fdce2e Changelog for v0.33.1 (#22235)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-13 00:12:06 +00:00
ruomeng bb060d7a98 docs(plan): document hooks with plan mode (#22197) 2026-03-12 23:35:56 +00:00
Qiaochu Hu 9a73aa4072 docs: clarify that tools.core is an allowlist for ALL built-in tools (#18813)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: hobostay <hobostay@users.noreply.github.com>
2026-03-12 22:40:05 +00:00
Yuna Seol d7d53981f3 feat(core): add trajectoryId to ConversationOffered telemetry (#22214)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-12 22:12:08 +00:00
krishdef7 19e0b1ff7d fix(cli): escape @ symbols on paste to prevent unintended file expansion (#21239) 2026-03-12 22:05:12 +00:00
108 changed files with 2562 additions and 525 deletions
-1
View File
@@ -391,4 +391,3 @@ instructions.
<p align="center">
Built with ❤️ by Google and the open source community
</p>
unrelated change
+6 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.33.0
# Latest stable release: v0.33.1
Released: March 11, 2026
Released: March 12, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -29,6 +29,9 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version
v0.33.0 and create version 0.33.1 by @gemini-cli-robot in
[#22206](https://github.com/google-gemini/gemini-cli/pull/22206)
- Docs: Update model docs to remove Preview Features. by @jkcinouye in
[#20084](https://github.com/google-gemini/gemini-cli/pull/20084)
- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in
@@ -228,4 +231,4 @@ npm install -g @google/gemini-cli
[#21952](https://github.com/google-gemini/gemini-cli/pull/21952)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.0
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.1
+71 -10
View File
@@ -109,16 +109,6 @@ switch back to another mode.
- **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode.
- **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning."
## Customization and best practices
Plan Mode is secure by default, but you can adapt it to fit your specific
workflows. You can customize how Gemini CLI plans by using skills, adjusting
safety policies, or changing where plans are stored.
## Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Tool Restrictions
Plan Mode enforces strict safety policies to prevent accidental changes.
@@ -146,6 +136,12 @@ These are the only allowed tools:
- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized
instructions and resources in a read-only manner)
## Customization and best practices
Plan Mode is secure by default, but you can adapt it to fit your specific
workflows. You can customize how Gemini CLI plans by using skills, adjusting
safety policies, changing where plans are stored, or adding hooks.
### Custom planning with skills
You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI
@@ -294,6 +290,71 @@ modes = ["plan"]
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
### Using hooks with Plan Mode
You can use the [hook system](../hooks/writing-hooks.md) to automate parts of
the planning workflow or enforce additional checks when Gemini CLI transitions
into or out of Plan Mode.
Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the
`enter_plan_mode` and `exit_plan_mode` tool calls.
> [!WARNING] When hooks are triggered by **tool executions**, they do **not**
> run when you manually toggle Plan Mode using the `/plan` command or the
> `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes,
> ensure the transition is initiated by the agent (e.g., by asking "start a plan
> for...").
#### Example: Archive approved plans to GCS (`AfterTool`)
If your organizational policy requires a record of all execution plans, you can
use an `AfterTool` hook to securely copy the plan artifact to Google Cloud
Storage whenever Gemini CLI exits Plan Mode to start the implementation.
**`.gemini/hooks/archive-plan.sh`:**
```bash
#!/usr/bin/env bash
# Extract the plan path from the tool input JSON
plan_path=$(jq -r '.tool_input.plan_path // empty')
if [ -f "$plan_path" ]; then
# Generate a unique filename using a timestamp
filename="$(date +%s)_$(basename "$plan_path")"
# Upload the plan to GCS in the background so it doesn't block the CLI
gsutil cp "$plan_path" "gs://my-audit-bucket/gemini-plans/$filename" > /dev/null 2>&1 &
fi
# AfterTool hooks should generally allow the flow to continue
echo '{"decision": "allow"}'
```
To register this `AfterTool` hook, add it to your `settings.json`:
```json
{
"hooks": {
"AfterTool": [
{
"matcher": "exit_plan_mode",
"hooks": [
{
"name": "archive-plan",
"type": "command",
"command": "./.gemini/hooks/archive-plan.sh"
}
]
}
]
}
}
```
## Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Planning workflows
Plan Mode provides building blocks for structured research and design. These are
+1
View File
@@ -55,6 +55,7 @@ they appear in the UI.
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
+146
View File
@@ -245,6 +245,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hide helpful tips in the UI
- **Default:** `false`
- **`ui.escapePastedAtSymbols`** (boolean):
- **Description:** When enabled, @ symbols in pasted text are escaped to
prevent unintended @path expansion.
- **Default:** `false`
- **`ui.showShortcutsHint`** (boolean):
- **Description:** Show the "? for shortcuts" hint above the input.
- **Default:** `true`
@@ -672,6 +677,141 @@ their corresponding top-level category object in your `settings.json` file.
used.
- **Default:** `[]`
- **`modelConfigs.modelDefinitions`** (object):
- **Description:** Registry of model metadata, including tier, family, and
features.
- **Default:**
```json
{
"gemini-3.1-pro-preview": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3.1-pro-preview-customtools": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3-pro-preview": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3-flash-preview": {
"tier": "flash",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": true
}
},
"gemini-2.5-pro": {
"tier": "pro",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"gemini-2.5-flash": {
"tier": "flash",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"gemini-2.5-flash-lite": {
"tier": "flash-lite",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"auto": {
"tier": "auto",
"isPreview": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"pro": {
"tier": "pro",
"isPreview": false,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"flash": {
"tier": "flash",
"isPreview": false,
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"flash-lite": {
"tier": "flash-lite",
"isPreview": false,
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"auto-gemini-3": {
"displayName": "Auto (Gemini 3)",
"tier": "auto",
"isPreview": true,
"dialogLocation": "main",
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"auto-gemini-2.5": {
"displayName": "Auto (Gemini 2.5)",
"tier": "auto",
"isPreview": false,
"dialogLocation": "main",
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
"features": {
"thinking": false,
"multimodalToolUse": false
}
}
}
```
- **Requires restart:** Yes
#### `agents`
- **`agents.overrides`** (object):
@@ -1063,6 +1203,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.dynamicModelConfiguration`** (boolean):
- **Description:** Enable dynamic model configuration (definitions,
resolutions, and chains) via settings.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.enabled`** (boolean):
- **Description:** Enable the Gemma Model Router (experimental). Requires a
local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
+3 -1
View File
@@ -342,7 +342,9 @@ policies, as it is much more robust than manually writing Fully Qualified Names
**1. Targeting a specific tool on a server**
Combine `mcpName` and `toolName` to target a single operation.
Combine `mcpName` and `toolName` to target a single operation. When using
`mcpName`, the `toolName` field should strictly be the simple name of the tool
(e.g., `search`), **not** the Fully Qualified Name (e.g., `mcp_server_search`).
```toml
# Allows the `search` tool on the `my-jira-server` MCP
+8
View File
@@ -120,6 +120,14 @@ tools to detect if they are being run from within the Gemini CLI.
## Command restrictions
<!-- prettier-ignore -->
> [!WARNING]
> The `tools.core` setting is an **allowlist for _all_ built-in
> tools**, not just shell commands. When you set `tools.core` to any value,
> _only_ the tools explicitly listed will be enabled. This includes all built-in
> tools like `read_file`, `write_file`, `glob`, `grep_search`, `list_directory`,
> `replace`, etc.
You can restrict the commands that can be executed by the `run_shell_command`
tool by using the `tools.core` and `tools.exclude` settings in your
configuration file.
@@ -26,7 +26,7 @@ describe('Task Event-Driven Scheduler', () => {
mockConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
}) as Config;
messageBus = mockConfig.getMessageBus();
messageBus = mockConfig.messageBus;
mockEventBus = {
publish: vi.fn(),
on: vi.fn(),
@@ -360,7 +360,7 @@ describe('Task Event-Driven Scheduler', () => {
isEventDrivenSchedulerEnabled: () => true,
getApprovalMode: () => ApprovalMode.YOLO,
}) as Config;
const yoloMessageBus = yoloConfig.getMessageBus();
const yoloMessageBus = yoloConfig.messageBus;
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
+14 -7
View File
@@ -5,6 +5,7 @@
*/
import {
type AgentLoopContext,
Scheduler,
type GeminiClient,
GeminiEventType,
@@ -114,7 +115,8 @@ export class Task {
this.scheduler = this.setupEventDrivenScheduler();
this.geminiClient = this.config.getGeminiClient();
const loopContext: AgentLoopContext = this.config;
this.geminiClient = loopContext.geminiClient;
this.pendingToolConfirmationDetails = new Map();
this.taskState = 'submitted';
this.eventBus = eventBus;
@@ -143,7 +145,8 @@ export class Task {
// process. This is not scoped to the individual task but reflects the global connection
// state managed within the @gemini-cli/core module.
async getMetadata(): Promise<TaskMetadata> {
const toolRegistry = this.config.getToolRegistry();
const loopContext: AgentLoopContext = this.config;
const toolRegistry = loopContext.toolRegistry;
const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
const serverStatuses = getAllMCPServerStatuses();
const servers = Object.keys(mcpServers).map((serverName) => ({
@@ -376,7 +379,8 @@ export class Task {
private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
private setupEventDrivenScheduler(): Scheduler {
const messageBus = this.config.getMessageBus();
const loopContext: AgentLoopContext = this.config;
const messageBus = loopContext.messageBus;
const scheduler = new Scheduler({
schedulerId: this.id,
context: this.config,
@@ -395,9 +399,11 @@ export class Task {
dispose(): void {
if (this.messageBusListener) {
this.config
.getMessageBus()
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener);
const loopContext: AgentLoopContext = this.config;
loopContext.messageBus.unsubscribe(
MessageBusType.TOOL_CALLS_UPDATE,
this.messageBusListener,
);
this.messageBusListener = undefined;
}
@@ -948,7 +954,8 @@ export class Task {
try {
if (correlationId) {
await this.config.getMessageBus().publish({
const loopContext: AgentLoopContext = this.config;
await loopContext.messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed:
@@ -59,6 +59,9 @@ describe('a2a-server memory commands', () => {
} as unknown as ToolRegistry;
mockConfig = {
get toolRegistry() {
return mockToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
} as unknown as Config;
@@ -168,7 +171,6 @@ describe('a2a-server memory commands', () => {
]);
expect(mockAddMemory).toHaveBeenCalledWith(fact);
expect(mockConfig.getToolRegistry).toHaveBeenCalled();
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory');
expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith(
{ fact },
+3 -1
View File
@@ -15,6 +15,7 @@ import type {
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { AgentLoopContext } from '@google/gemini-cli-core';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
@@ -95,7 +96,8 @@ export class AddMemoryCommand implements Command {
return { name: this.name, data: result.content };
}
const toolRegistry = context.config.getToolRegistry();
const loopContext: AgentLoopContext = context.config;
const toolRegistry = loopContext.toolRegistry;
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
+21 -5
View File
@@ -16,6 +16,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
type MessageBus,
PolicyDecision,
tmpdir,
type Config,
@@ -31,9 +32,27 @@ export function createMockConfig(
const tmpDir = tmpdir();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
get toolRegistry(): ToolRegistry {
get config() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (this as unknown as Config).getToolRegistry();
return this as unknown as Config;
},
get toolRegistry() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = this as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return config.getToolRegistry?.() as unknown as ToolRegistry;
},
get messageBus() {
return (
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(this as unknown as Config).getMessageBus?.() as unknown as MessageBus
);
},
get geminiClient() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = this as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return config.getGeminiClient?.() as unknown as GeminiClient;
},
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn(),
@@ -81,9 +100,6 @@ export function createMockConfig(
...overrides,
} as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).config =
mockConfig;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
'test-prompt-id';
+1
View File
@@ -857,6 +857,7 @@ export async function loadCliConfig(
disableLLMCorrection: settings.tools?.disableLLMCorrection,
rawOutput: argv.rawOutput,
acceptRawOutputRisk: argv.acceptRawOutputRisk,
dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: settings.hooksConfig.enabled,
+53
View File
@@ -540,6 +540,16 @@ const SETTINGS_SCHEMA = {
description: 'Hide helpful tips in the UI',
showInDialog: true,
},
escapePastedAtSymbols: {
type: 'boolean',
label: 'Escape Pasted @ Symbols',
category: 'UI',
requiresRestart: false,
default: false,
description:
'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.',
showInDialog: true,
},
showShortcutsHint: {
type: 'boolean',
label: 'Show Shortcuts Hint',
@@ -1029,6 +1039,20 @@ const SETTINGS_SCHEMA = {
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
showInDialog: false,
},
modelDefinitions: {
type: 'object',
label: 'Model Definitions',
category: 'Model',
requiresRestart: true,
default: DEFAULT_MODEL_CONFIGS.modelDefinitions,
description:
'Registry of model metadata, including tier, family, and features.',
showInDialog: false,
additionalProperties: {
type: 'object',
ref: 'ModelDefinition',
},
},
},
},
@@ -1900,6 +1924,16 @@ const SETTINGS_SCHEMA = {
'Enable web fetch behavior that bypasses LLM summarization.',
showInDialog: true,
},
dynamicModelConfiguration: {
type: 'boolean',
label: 'Dynamic Model Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
showInDialog: false,
},
gemmaModelRouter: {
type: 'object',
label: 'Gemma Model Router',
@@ -2716,6 +2750,25 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
ModelDefinition: {
type: 'object',
description: 'Model metadata registry entry.',
properties: {
displayName: { type: 'string' },
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
family: { type: 'string' },
isPreview: { type: 'boolean' },
dialogLocation: { enum: ['main', 'manual'] },
dialogDescription: { type: 'string' },
features: {
type: 'object',
properties: {
thinking: { type: 'boolean' },
multimodalToolUse: { type: 'boolean' },
},
},
},
},
};
export function getSettingsSchema(): SettingsSchemaType {
+1 -1
View File
@@ -263,8 +263,8 @@ export async function runNonInteractive({
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
escapePastedAtSymbols: false,
});
if (error || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
+16 -3
View File
@@ -11,6 +11,7 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import {
type TextBuffer,
@@ -515,7 +516,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
stdout.write('\x1b]52;c;?\x07');
} else {
const textToInsert = await clipboardy.read();
buffer.insert(textToInsert, { paste: true });
const escapedText = settings.ui?.escapePastedAtSymbols
? escapeAtSymbols(textToInsert)
: textToInsert;
buffer.insert(escapedText, { paste: true });
if (isLargePaste(textToInsert)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -750,8 +755,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null;
}, 40);
}
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
if (settings.ui?.escapePastedAtSymbols) {
buffer.handleInput({
...key,
sequence: escapeAtSymbols(key.sequence || ''),
});
} else {
buffer.handleInput(key);
}
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -1291,6 +1303,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
forceShowShellSuggestions,
keyMatchers,
isHelpDismissKey,
settings,
],
);
@@ -13,9 +13,8 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import type { Config } from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import {
cleanMessage,
formatRelativeTime,
getSessionFiles,
} from '../../utils/sessionUtils.js';
@@ -150,124 +149,7 @@ const SessionBrowserEmpty = (): React.JSX.Element => (
</Box>
);
/**
* Sorts an array of sessions by the specified criteria.
* @param sessions - Array of sessions to sort
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
* @returns New sorted array of sessions
*/
const sortSessions = (
sessions: SessionInfo[],
sortBy: 'date' | 'messages' | 'name',
reverse: boolean,
): SessionInfo[] => {
const sorted = [...sessions].sort((a, b) => {
switch (sortBy) {
case 'date':
return (
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
);
case 'messages':
return b.messageCount - a.messageCount;
case 'name':
return a.displayName.localeCompare(b.displayName);
default:
return 0;
}
});
return reverse ? sorted.reverse() : sorted;
};
/**
* Finds all text matches for a search query within conversation messages.
* Creates TextMatch objects with context (10 chars before/after) and role information.
* @param messages - Array of messages to search through
* @param query - Search query string (case-insensitive)
* @returns Array of TextMatch objects containing match context and metadata
*/
const findTextMatches = (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
query: string,
): TextMatch[] => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
const matches: TextMatch[] = [];
for (const message of messages) {
const m = cleanMessage(message.content);
const lowerContent = m.toLowerCase();
let startIndex = 0;
while (true) {
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const contextStart = Math.max(0, matchIndex - 10);
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
const snippet = m.slice(contextStart, contextEnd);
const relativeMatchStart = matchIndex - contextStart;
const relativeMatchEnd = relativeMatchStart + query.length;
let before = snippet.slice(0, relativeMatchStart);
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
let after = snippet.slice(relativeMatchEnd);
if (contextStart > 0) before = '…' + before;
if (contextEnd < m.length) after = after + '…';
matches.push({ before, match, after, role: message.role });
startIndex = matchIndex + 1;
}
}
return matches;
};
/**
* Filters sessions based on a search query, checking titles, IDs, and full content.
* Also populates matchSnippets and matchCount for sessions with content matches.
* @param sessions - Array of sessions to filter
* @param query - Search query string (case-insensitive)
* @returns Filtered array of sessions that match the query
*/
const filterSessions = (
sessions: SessionInfo[],
query: string,
): SessionInfo[] => {
if (!query.trim()) {
return sessions.map((session) => ({
...session,
matchSnippets: undefined,
matchCount: undefined,
}));
}
const lowerQuery = query.toLowerCase();
return sessions.filter((session) => {
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
?.toLowerCase()
.includes(lowerQuery);
if (titleMatch || contentMatch) {
if (session.messages) {
session.matchSnippets = findTextMatches(session.messages, query);
session.matchCount = session.matchSnippets.length;
}
return true;
}
return false;
});
};
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* Search input display component.
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { sortSessions, findTextMatches, filterSessions } from './utils.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
describe('SessionBrowser utils', () => {
const createTestSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
id: 'test-id',
file: 'test-file',
fileName: 'test-file.json',
startTime: '2025-01-01T10:00:00Z',
lastUpdated: '2025-01-01T10:00:00Z',
messageCount: 1,
displayName: 'Test Session',
firstUserMessage: 'Hello',
isCurrentSession: false,
index: 0,
...overrides,
});
describe('sortSessions', () => {
it('sorts by date ascending/descending', () => {
const older = createTestSession({
id: '1',
lastUpdated: '2025-01-01T10:00:00Z',
});
const newer = createTestSession({
id: '2',
lastUpdated: '2025-01-02T10:00:00Z',
});
const desc = sortSessions([older, newer], 'date', false);
expect(desc[0].id).toBe('2');
const asc = sortSessions([older, newer], 'date', true);
expect(asc[0].id).toBe('1');
});
it('sorts by message count ascending/descending', () => {
const more = createTestSession({ id: '1', messageCount: 10 });
const less = createTestSession({ id: '2', messageCount: 2 });
const desc = sortSessions([more, less], 'messages', false);
expect(desc[0].id).toBe('1');
const asc = sortSessions([more, less], 'messages', true);
expect(asc[0].id).toBe('2');
});
it('sorts by name ascending/descending', () => {
const apple = createTestSession({ id: '1', displayName: 'Apple' });
const banana = createTestSession({ id: '2', displayName: 'Banana' });
const asc = sortSessions([apple, banana], 'name', true);
expect(asc[0].id).toBe('2'); // Reversed alpha
const desc = sortSessions([apple, banana], 'name', false);
expect(desc[0].id).toBe('1');
});
});
describe('findTextMatches', () => {
it('returns empty array if query is practically empty', () => {
expect(
findTextMatches([{ role: 'user', content: 'hello world' }], ' '),
).toEqual([]);
});
it('finds simple matches with surrounding context', () => {
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: 'What is the capital of France?' },
];
const matches = findTextMatches(messages, 'capital');
expect(matches.length).toBe(1);
expect(matches[0].match).toBe('capital');
expect(matches[0].before.endsWith('the ')).toBe(true);
expect(matches[0].after.startsWith(' of')).toBe(true);
expect(matches[0].role).toBe('user');
});
it('finds multiple matches in a single message', () => {
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
{ role: 'user', content: 'test here test there' },
];
const matches = findTextMatches(messages, 'test');
expect(matches.length).toBe(2);
});
});
describe('filterSessions', () => {
it('returns all sessions when query is blank and clears existing snippets', () => {
const sessions = [createTestSession({ id: '1', matchCount: 5 })];
const result = filterSessions(sessions, ' ');
expect(result.length).toBe(1);
expect(result[0].matchCount).toBeUndefined();
});
it('filters by displayName', () => {
const session1 = createTestSession({
id: '1',
displayName: 'Cats and Dogs',
});
const session2 = createTestSession({ id: '2', displayName: 'Fish' });
const result = filterSessions([session1, session2], 'cat');
expect(result.length).toBe(1);
expect(result[0].id).toBe('1');
});
it('populates match snippets if it matches content inside messages array', () => {
const sessionWithMessages = createTestSession({
id: '1',
displayName: 'Unrelated Title',
fullContent: 'This mentions a giraffe',
messages: [{ role: 'user', content: 'This mentions a giraffe' }],
});
const result = filterSessions([sessionWithMessages], 'giraffe');
expect(result.length).toBe(1);
expect(result[0].matchCount).toBe(1);
expect(result[0].matchSnippets?.[0].match).toBe('giraffe');
});
});
});
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
cleanMessage,
type SessionInfo,
type TextMatch,
} from '../../../utils/sessionUtils.js';
/**
* Sorts an array of sessions by the specified criteria.
* @param sessions - Array of sessions to sort
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
* @returns New sorted array of sessions
*/
export const sortSessions = (
sessions: SessionInfo[],
sortBy: 'date' | 'messages' | 'name',
reverse: boolean,
): SessionInfo[] => {
const sorted = [...sessions].sort((a, b) => {
switch (sortBy) {
case 'date':
return (
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
);
case 'messages':
return b.messageCount - a.messageCount;
case 'name':
return a.displayName.localeCompare(b.displayName);
default:
return 0;
}
});
return reverse ? sorted.reverse() : sorted;
};
/**
* Finds all text matches for a search query within conversation messages.
* Creates TextMatch objects with context (10 chars before/after) and role information.
* @param messages - Array of messages to search through
* @param query - Search query string (case-insensitive)
* @returns Array of TextMatch objects containing match context and metadata
*/
export const findTextMatches = (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
query: string,
): TextMatch[] => {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
const matches: TextMatch[] = [];
for (const message of messages) {
const m = cleanMessage(message.content);
const lowerContent = m.toLowerCase();
let startIndex = 0;
while (true) {
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const contextStart = Math.max(0, matchIndex - 10);
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
const snippet = m.slice(contextStart, contextEnd);
const relativeMatchStart = matchIndex - contextStart;
const relativeMatchEnd = relativeMatchStart + query.length;
let before = snippet.slice(0, relativeMatchStart);
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
let after = snippet.slice(relativeMatchEnd);
if (contextStart > 0) before = '…' + before;
if (contextEnd < m.length) after = after + '…';
matches.push({ before, match, after, role: message.role });
startIndex = matchIndex + 1;
}
}
return matches;
};
/**
* Filters sessions based on a search query, checking titles, IDs, and full content.
* Also populates matchSnippets and matchCount for sessions with content matches.
* @param sessions - Array of sessions to filter
* @param query - Search query string (case-insensitive)
* @returns Filtered array of sessions that match the query
*/
export const filterSessions = (
sessions: SessionInfo[],
query: string,
): SessionInfo[] => {
if (!query.trim()) {
return sessions.map((session) => ({
...session,
matchSnippets: undefined,
matchCount: undefined,
}));
}
const lowerQuery = query.toLowerCase();
return sessions.filter((session) => {
const titleMatch =
session.displayName.toLowerCase().includes(lowerQuery) ||
session.id.toLowerCase().includes(lowerQuery) ||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
const contentMatch = session.fullContent
?.toLowerCase()
.includes(lowerQuery);
if (titleMatch || contentMatch) {
if (session.messages) {
session.matchSnippets = findTextMatches(session.messages, query);
session.matchCount = session.matchSnippets.length;
}
return true;
}
return false;
});
};
@@ -27,6 +27,7 @@ import {
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
type Config,
type RetrieveUserQuotaResponse,
isActiveModel,
getDisplayString,
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
// Logic for building the unified list of table rows
const buildModelRows = (
models: Record<string, ModelMetrics>,
config: Config,
quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useCustomToolModel = false,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set(
Object.keys(models).map(getBaseModelName).map(getDisplayString),
Object.keys(models)
.map(getBaseModelName)
.map((name) => getDisplayString(name, config)),
);
// 1. Models with active usage
@@ -104,7 +108,7 @@ const buildModelRows = (
const inputTokens = metrics.tokens.input;
return {
key: name,
modelName: getDisplayString(modelName),
modelName: getDisplayString(modelName, config),
requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(),
inputTokens: inputTokens.toLocaleString(),
@@ -121,11 +125,11 @@ const buildModelRows = (
(b) =>
b.modelId &&
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(getDisplayString(b.modelId)),
!usedModelNames.has(getDisplayString(b.modelId, config)),
)
.map((bucket) => ({
key: bucket.modelId!,
modelName: getDisplayString(bucket.modelId!),
modelName: getDisplayString(bucket.modelId!, config),
requests: '-',
cachedTokens: '-',
inputTokens: '-',
@@ -139,6 +143,7 @@ const buildModelRows = (
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
config: Config;
quotas?: RetrieveUserQuotaResponse;
cacheEfficiency: number;
totalCachedTokens: number;
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
useCustomToolModel?: boolean;
}> = ({
models,
config,
quotas,
cacheEfficiency,
totalCachedTokens,
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
}) => {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
const rows = buildModelRows(
models,
config,
quotas,
useGemini3_1,
useCustomToolModel,
);
if (rows.length === 0) {
return null;
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
</Section>
<ModelUsageTable
models={models}
config={config}
quotas={quotas}
cacheEfficiency={computed.cacheEfficiency}
totalCachedTokens={computed.totalCachedTokens}
@@ -13,7 +13,11 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { handleAtCommand } from './atCommandProcessor.js';
import {
handleAtCommand,
escapeAtSymbols,
unescapeLiteralAt,
} from './atCommandProcessor.js';
import {
FileDiscoveryService,
GlobTool,
@@ -1481,3 +1485,56 @@ describe('handleAtCommand', () => {
);
});
});
describe('escapeAtSymbols', () => {
it('escapes a bare @ symbol', () => {
expect(escapeAtSymbols('test@domain.com')).toBe('test\\@domain.com');
});
it('escapes a leading @ symbol', () => {
expect(escapeAtSymbols('@scope/pkg')).toBe('\\@scope/pkg');
});
it('escapes multiple @ symbols', () => {
expect(escapeAtSymbols('a@b and c@d')).toBe('a\\@b and c\\@d');
});
it('does not double-escape an already escaped @', () => {
expect(escapeAtSymbols('test\\@domain.com')).toBe('test\\@domain.com');
});
it('returns text with no @ unchanged', () => {
expect(escapeAtSymbols('hello world')).toBe('hello world');
});
it('returns empty string unchanged', () => {
expect(escapeAtSymbols('')).toBe('');
});
});
describe('unescapeLiteralAt', () => {
it('unescapes \\@ to @', () => {
expect(unescapeLiteralAt('test\\@domain.com')).toBe('test@domain.com');
});
it('unescapes a leading \\@', () => {
expect(unescapeLiteralAt('\\@scope/pkg')).toBe('@scope/pkg');
});
it('unescapes multiple \\@ sequences', () => {
expect(unescapeLiteralAt('a\\@b and c\\@d')).toBe('a@b and c@d');
});
it('returns text with no \\@ unchanged', () => {
expect(unescapeLiteralAt('hello world')).toBe('hello world');
});
it('returns empty string unchanged', () => {
expect(unescapeLiteralAt('')).toBe('');
});
it('roundtrips correctly with escapeAtSymbols', () => {
const input = 'user@example.com and @scope/pkg';
expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input);
});
});
@@ -30,6 +30,26 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
/**
* Escapes unescaped @ symbols so they are not interpreted as @path commands.
*/
export function escapeAtSymbols(text: string): string {
return text.replace(/(?<!\\)@/g, '\\@');
}
/**
* Unescapes \@ back to @ correctly, preserving \\@ sequences.
*/
export function unescapeLiteralAt(text: string): string {
return text.replace(/\\@/g, (match, offset, full) => {
let backslashCount = 0;
for (let i = offset - 1; i >= 0 && full[i] === '\\'; i--) {
backslashCount++;
}
return backslashCount % 2 === 0 ? '@' : '\\@';
});
}
/**
* Regex source for the path/command part of an @ reference.
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
@@ -49,6 +69,7 @@ interface HandleAtCommandParams {
onDebugMessage: (message: string) => void;
messageId: number;
signal: AbortSignal;
escapePastedAtSymbols?: boolean;
}
interface HandleAtCommandResult {
@@ -65,7 +86,10 @@ interface AtCommandPart {
* Parses a query string to find all '@<path>' commands and text segments.
* Handles \ escaped spaces within paths.
*/
function parseAllAtCommands(query: string): AtCommandPart[] {
function parseAllAtCommands(
query: string,
escapePastedAtSymbols = false,
): AtCommandPart[] {
const parts: AtCommandPart[] = [];
let lastIndex = 0;
@@ -85,7 +109,9 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
if (matchIndex > lastIndex) {
parts.push({
type: 'text',
content: query.substring(lastIndex, matchIndex),
content: escapePastedAtSymbols
? unescapeLiteralAt(query.substring(lastIndex, matchIndex))
: query.substring(lastIndex, matchIndex),
});
}
@@ -98,7 +124,12 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
// Add remaining text
if (lastIndex < query.length) {
parts.push({ type: 'text', content: query.substring(lastIndex) });
parts.push({
type: 'text',
content: escapePastedAtSymbols
? unescapeLiteralAt(query.substring(lastIndex))
: query.substring(lastIndex),
});
}
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
@@ -635,8 +666,9 @@ export async function handleAtCommand({
onDebugMessage,
messageId: userMessageTimestamp,
signal,
escapePastedAtSymbols = false,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const commandParts = parseAllAtCommands(query);
const commandParts = parseAllAtCommands(query, escapePastedAtSymbols);
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
commandParts,
+2 -1
View File
@@ -837,8 +837,8 @@ export const useGeminiStream = (
onDebugMessage,
messageId: userMessageTimestamp,
signal: abortSignal,
escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
});
if (atCommandResult.error) {
onDebugMessage(atCommandResult.error);
return { queryToSend: null, shouldProceed: false };
@@ -874,6 +874,7 @@ export const useGeminiStream = (
logger,
shellModeActive,
scheduleToolCalls,
settings,
],
);
+1 -1
View File
@@ -25,7 +25,7 @@ export type HighlightToken = {
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
const HIGHLIGHT_REGEX = new RegExp(
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
`(^/[a-zA-Z0-9_-]+|(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
'g',
);
@@ -28,10 +28,10 @@ describe('agent-scheduler', () => {
mockMessageBus = {} as Mocked<MessageBus>;
mockToolRegistry = {
getTool: vi.fn(),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
} as unknown as Mocked<ToolRegistry>;
mockConfig = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
(mockConfig as unknown as { messageBus: MessageBus }).messageBus =
@@ -42,7 +42,7 @@ describe('agent-scheduler', () => {
it('should create a scheduler with agent-specific config', async () => {
const mockConfig = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
@@ -87,11 +87,11 @@ describe('agent-scheduler', () => {
const mainRegistry = { _id: 'main' } as unknown as Mocked<ToolRegistry>;
const agentRegistry = {
_id: 'agent',
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
} as unknown as Mocked<ToolRegistry>;
const config = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
messageBus: mockMessageBus,
} as unknown as Mocked<Config>;
Object.defineProperty(config, 'toolRegistry', {
get: () => mainRegistry,
+2 -2
View File
@@ -60,7 +60,7 @@ export async function scheduleAgentTools(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const agentConfig: Config = Object.create(config);
agentConfig.getToolRegistry = () => toolRegistry;
agentConfig.getMessageBus = () => toolRegistry.getMessageBus();
agentConfig.getMessageBus = () => toolRegistry.messageBus;
// Override toolRegistry property so AgentLoopContext reads the agent-specific registry.
Object.defineProperty(agentConfig, 'toolRegistry', {
get: () => toolRegistry,
@@ -69,7 +69,7 @@ export async function scheduleAgentTools(
const scheduler = new Scheduler({
context: agentConfig,
messageBus: toolRegistry.getMessageBus(),
messageBus: toolRegistry.messageBus,
getPreferredEditor: getPreferredEditor ?? (() => undefined),
schedulerId,
subagent,
@@ -109,7 +109,7 @@ export const BrowserAgentDefinition = (
): LocalAgentDefinition<typeof BrowserTaskResultSchema> => {
// Use Preview Flash model if the main model is any of the preview models.
// If the main model is not a preview model, use the default flash model.
const model = isPreviewModel(config.getModel())
const model = isPreviewModel(config.getModel(), config)
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
+3 -3
View File
@@ -7,8 +7,8 @@
import type { AgentDefinition } from './types.js';
import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js';
import { z } from 'zod';
import type { Config } from '../config/config.js';
import { GetInternalDocsTool } from '../tools/get-internal-docs.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
const CliHelpReportSchema = z.object({
answer: z
@@ -24,7 +24,7 @@ const CliHelpReportSchema = z.object({
* using its own documentation and runtime state.
*/
export const CliHelpAgent = (
config: Config,
context: AgentLoopContext,
): AgentDefinition<typeof CliHelpReportSchema> => ({
name: 'cli_help',
kind: 'local',
@@ -69,7 +69,7 @@ export const CliHelpAgent = (
},
toolConfig: {
tools: [new GetInternalDocsTool(config.getMessageBus())],
tools: [new GetInternalDocsTool(context.messageBus)],
},
promptConfig: {
@@ -22,9 +22,19 @@ describe('GeneralistAgent', () => {
it('should create a valid generalist agent definition', () => {
const config = makeFakeConfig();
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
const mockToolRegistry = {
getAllToolNames: () => ['tool1', 'tool2', 'agent-tool'],
} as unknown as ToolRegistry);
} as unknown as ToolRegistry;
vi.spyOn(config, 'getToolRegistry').mockReturnValue(mockToolRegistry);
Object.defineProperty(config, 'toolRegistry', {
get: () => mockToolRegistry,
});
Object.defineProperty(config, 'config', {
get() {
return this;
},
});
vi.spyOn(config, 'getAgentRegistry').mockReturnValue({
getDirectoryContext: () => 'mock directory context',
getAllAgentNames: () => ['agent-tool'],
+4 -4
View File
@@ -5,7 +5,7 @@
*/
import { z } from 'zod';
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { getCoreSystemPrompt } from '../core/prompts.js';
import type { LocalAgentDefinition } from './types.js';
@@ -18,7 +18,7 @@ const GeneralistAgentSchema = z.object({
* It uses the same core system prompt as the main agent but in a non-interactive mode.
*/
export const GeneralistAgent = (
config: Config,
context: AgentLoopContext,
): LocalAgentDefinition<typeof GeneralistAgentSchema> => ({
kind: 'local',
name: 'generalist',
@@ -46,7 +46,7 @@ export const GeneralistAgent = (
model: 'inherit',
},
get toolConfig() {
const tools = config.getToolRegistry().getAllToolNames();
const tools = context.toolRegistry.getAllToolNames();
return {
tools,
};
@@ -54,7 +54,7 @@ export const GeneralistAgent = (
get promptConfig() {
return {
systemPrompt: getCoreSystemPrompt(
config,
context.config,
/*useMemory=*/ undefined,
/*interactiveOverride=*/ false,
),
@@ -313,12 +313,9 @@ describe('LocalAgentExecutor', () => {
get: () => 'test-prompt-id',
configurable: true,
});
parentToolRegistry = new ToolRegistry(
mockConfig,
mockConfig.getMessageBus(),
);
parentToolRegistry = new ToolRegistry(mockConfig, mockConfig.messageBus);
parentToolRegistry.registerTool(
new LSTool(mockConfig, mockConfig.getMessageBus()),
new LSTool(mockConfig, mockConfig.messageBus),
);
parentToolRegistry.registerTool(
new MockTool({ name: READ_FILE_TOOL_NAME }),
@@ -524,7 +521,7 @@ describe('LocalAgentExecutor', () => {
toolName,
'description',
{},
mockConfig.getMessageBus(),
mockConfig.messageBus,
);
// Mock getTool to return our real DiscoveredMCPTool instance
+4 -4
View File
@@ -43,12 +43,12 @@ 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 = 30;
/**
* The default maximum execution time for an agent in minutes.
*/
export const DEFAULT_MAX_TIME_MINUTES = 5;
export const DEFAULT_MAX_TIME_MINUTES = 10;
/**
* Represents the validated input parameters passed to an agent upon invocation.
@@ -223,12 +223,12 @@ export interface OutputConfig<T extends z.ZodTypeAny> {
export interface RunConfig {
/**
* The maximum execution time for the agent in minutes.
* If not specified, defaults to DEFAULT_MAX_TIME_MINUTES (5).
* If not specified, defaults to DEFAULT_MAX_TIME_MINUTES (10).
*/
maxTimeMinutes?: number;
/**
* The maximum number of conversational turns.
* If not specified, defaults to DEFAULT_MAX_TURNS (15).
* If not specified, defaults to DEFAULT_MAX_TURNS (30).
*/
maxTurns?: number;
}
@@ -54,19 +54,21 @@ export function resolvePolicyChain(
useCustomToolModel,
hasAccessToPreview,
);
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
const isAutoConfigured = isAutoModel(configuredModel);
const isAutoPreferred = preferredModel
? isAutoModel(preferredModel, config)
: false;
const isAutoConfigured = isAutoModel(configuredModel, config);
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (
isGemini3Model(resolvedModel) ||
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel) ||
isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
@@ -208,6 +208,7 @@ describe('CodeAssistServer', () => {
traceId: 'test-trace-id',
status: ActionStatus.ACTION_STATUS_NO_ERROR,
initiationMethod: InitiationMethod.COMMAND,
trajectoryId: 'test-session',
streamingLatency: expect.objectContaining({
totalLatency: expect.stringMatching(/\d+s/),
firstMessageLatency: expect.stringMatching(/\d+s/),
@@ -277,6 +278,7 @@ describe('CodeAssistServer', () => {
conversationOffered: expect.objectContaining({
traceId: 'stream-trace-id',
initiationMethod: InitiationMethod.COMMAND,
trajectoryId: 'test-session',
}),
timestamp: expect.stringMatching(
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/,
+2
View File
@@ -153,6 +153,7 @@ export class CodeAssistServer implements ContentGenerator {
translatedResponse,
streamingLatency,
req.config?.abortSignal,
server.sessionId, // Use sessionId as trajectoryId
);
if (response.consumedCredits) {
@@ -223,6 +224,7 @@ export class CodeAssistServer implements ContentGenerator {
translatedResponse,
streamingLatency,
req.config?.abortSignal,
this.sessionId, // Use sessionId as trajectoryId
);
if (response.remainingCredits) {
@@ -92,6 +92,7 @@ describe('telemetry', () => {
traceId,
undefined,
streamingLatency,
'trajectory-id',
);
expect(result).toEqual({
@@ -102,6 +103,7 @@ describe('telemetry', () => {
streamingLatency,
isAgentic: true,
initiationMethod: InitiationMethod.COMMAND,
trajectoryId: 'trajectory-id',
});
});
@@ -124,6 +126,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
'trajectory-id',
);
expect(result).toBeUndefined();
});
@@ -140,6 +143,7 @@ describe('telemetry', () => {
'trace-id',
signal,
{},
'trajectory-id',
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_CANCELLED);
@@ -155,6 +159,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
'trajectory-id',
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_ERROR_UNKNOWN);
@@ -177,6 +182,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
'trajectory-id',
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_ERROR_UNKNOWN);
@@ -194,6 +200,7 @@ describe('telemetry', () => {
'trace-id',
undefined,
{},
undefined,
);
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_EMPTY);
@@ -214,7 +221,13 @@ describe('telemetry', () => {
true,
[{ name: 'replace', args: {} }],
);
const result = createConversationOffered(response, 'id', undefined, {});
const result = createConversationOffered(
response,
'id',
undefined,
{},
undefined,
);
expect(result?.includedCode).toBe(true);
});
@@ -231,7 +244,13 @@ describe('telemetry', () => {
true,
[{ name: 'replace', args: {} }],
);
const result = createConversationOffered(response, 'id', undefined, {});
const result = createConversationOffered(
response,
'id',
undefined,
{},
undefined,
);
expect(result?.includedCode).toBe(false);
});
});
@@ -260,6 +279,7 @@ describe('telemetry', () => {
response,
streamingLatency,
undefined,
undefined,
);
expect(serverMock.recordConversationOffered).toHaveBeenCalledWith(
@@ -283,6 +303,7 @@ describe('telemetry', () => {
response,
{},
undefined,
undefined,
);
expect(serverMock.recordConversationOffered).not.toHaveBeenCalled();
@@ -36,6 +36,7 @@ export async function recordConversationOffered(
response: GenerateContentResponse,
streamingLatency: StreamingLatency,
abortSignal: AbortSignal | undefined,
trajectoryId: string | undefined,
): Promise<void> {
try {
if (traceId) {
@@ -44,6 +45,7 @@ export async function recordConversationOffered(
traceId,
abortSignal,
streamingLatency,
trajectoryId,
);
if (offered) {
await server.recordConversationOffered(offered);
@@ -87,6 +89,7 @@ export function createConversationOffered(
traceId: string,
signal: AbortSignal | undefined,
streamingLatency: StreamingLatency,
trajectoryId: string | undefined,
): ConversationOffered | undefined {
// Only send conversation offered events for responses that contain edit
// function calls. Non-edit function calls don't represent file modifications.
@@ -107,6 +110,7 @@ export function createConversationOffered(
streamingLatency,
isAgentic: true,
initiationMethod: InitiationMethod.COMMAND,
trajectoryId,
};
}
+1
View File
@@ -315,6 +315,7 @@ export interface ConversationOffered {
streamingLatency?: StreamingLatency;
isAgentic?: boolean;
initiationMethod?: InitiationMethod;
trajectoryId?: string;
}
export interface StreamingLatency {
+11 -5
View File
@@ -67,6 +67,7 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
} from './models.js';
import { Storage } from './storage.js';
import type { AgentLoopContext } from './agent-loop-context.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -641,8 +642,9 @@ describe('Server Config (config.ts)', () => {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
const loopContext: AgentLoopContext = config;
expect(
config.getGeminiClient().stripThoughtsFromHistory,
loopContext.geminiClient.stripThoughtsFromHistory,
).toHaveBeenCalledWith();
});
@@ -660,8 +662,9 @@ describe('Server Config (config.ts)', () => {
await config.refreshAuth(AuthType.USE_VERTEX_AI);
const loopContext: AgentLoopContext = config;
expect(
config.getGeminiClient().stripThoughtsFromHistory,
loopContext.geminiClient.stripThoughtsFromHistory,
).toHaveBeenCalledWith();
});
@@ -679,8 +682,9 @@ describe('Server Config (config.ts)', () => {
await config.refreshAuth(AuthType.USE_GEMINI);
const loopContext: AgentLoopContext = config;
expect(
config.getGeminiClient().stripThoughtsFromHistory,
loopContext.geminiClient.stripThoughtsFromHistory,
).not.toHaveBeenCalledWith();
});
});
@@ -3059,7 +3063,8 @@ describe('Config JIT Initialization', () => {
await config.initialize();
const skillManager = config.getSkillManager();
const toolRegistry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const toolRegistry = loopContext.toolRegistry;
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
vi.spyOn(skillManager, 'setDisabledSkills');
@@ -3095,7 +3100,8 @@ describe('Config JIT Initialization', () => {
await config.initialize();
const skillManager = config.getSkillManager();
const toolRegistry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const toolRegistry = loopContext.toolRegistry;
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
vi.spyOn(toolRegistry, 'registerTool');
+55 -27
View File
@@ -601,6 +601,7 @@ export interface ConfigParameters {
disableYoloMode?: boolean;
rawOutput?: boolean;
acceptRawOutputRisk?: boolean;
dynamicModelConfiguration?: boolean;
modelConfigServiceConfig?: ModelConfigServiceConfig;
enableHooks?: boolean;
enableHooksUI?: boolean;
@@ -799,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly disableYoloMode: boolean;
private readonly rawOutput: boolean;
private readonly acceptRawOutputRisk: boolean;
private readonly dynamicModelConfiguration: boolean;
private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean;
private readonly enableHooksUI: boolean;
@@ -987,7 +989,7 @@ export class Config implements McpContext, AgentLoopContext {
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
this.useWriteTodos = isPreviewModel(this.model)
this.useWriteTodos = isPreviewModel(this.model, this)
? false
: (params.useWriteTodos ?? true);
this.workspacePoliciesDir = params.workspacePoliciesDir;
@@ -1036,7 +1038,7 @@ export class Config implements McpContext, AgentLoopContext {
// Register Conseca if enabled
if (this.enableConseca) {
debugLogger.log('[SAFETY] Registering Conseca Safety Checker');
ConsecaSafetyChecker.getInstance().setConfig(this);
ConsecaSafetyChecker.getInstance().setContext(this);
}
this._messageBus = new MessageBus(this.policyEngine, this.debugMode);
@@ -1062,6 +1064,7 @@ export class Config implements McpContext, AgentLoopContext {
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
if (params.hooks) {
this.hooks = params.hooks;
@@ -1111,18 +1114,23 @@ export class Config implements McpContext, AgentLoopContext {
// remove this hack.
let modelConfigServiceConfig = params.modelConfigServiceConfig;
if (modelConfigServiceConfig) {
if (!modelConfigServiceConfig.aliases) {
modelConfigServiceConfig = {
...modelConfigServiceConfig,
aliases: DEFAULT_MODEL_CONFIGS.aliases,
};
}
if (!modelConfigServiceConfig.overrides) {
modelConfigServiceConfig = {
...modelConfigServiceConfig,
overrides: DEFAULT_MODEL_CONFIGS.overrides,
};
}
// Ensure user-defined model definitions augment, not replace, the defaults.
const mergedModelDefinitions = {
...DEFAULT_MODEL_CONFIGS.modelDefinitions,
...modelConfigServiceConfig.modelDefinitions,
};
modelConfigServiceConfig = {
// Preserve other user settings like customAliases
...modelConfigServiceConfig,
// Apply defaults for aliases and overrides if they are not provided
aliases:
modelConfigServiceConfig.aliases ?? DEFAULT_MODEL_CONFIGS.aliases,
overrides:
modelConfigServiceConfig.overrides ?? DEFAULT_MODEL_CONFIGS.overrides,
// Use the merged model definitions
modelDefinitions: mergedModelDefinitions,
};
}
this.modelConfigService = new ModelConfigService(
@@ -1225,8 +1233,8 @@ export class Config implements McpContext, AgentLoopContext {
// Re-register ActivateSkillTool to update its schema with the discovered enabled skill enums
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.registerTool(
new ActivateSkillTool(this, this.messageBus),
);
}
@@ -1325,7 +1333,10 @@ export class Config implements McpContext, AgentLoopContext {
// Only reset when we have explicit "no access" (hasAccessToPreviewModel === false).
// When null (quota not fetched) or true, we preserve the saved model.
if (isPreviewModel(this.model) && this.hasAccessToPreviewModel === false) {
if (
isPreviewModel(this.model, this) &&
this.hasAccessToPreviewModel === false
) {
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
}
@@ -1397,14 +1408,26 @@ export class Config implements McpContext, AgentLoopContext {
return this._sessionId;
}
/**
* @deprecated Do not access directly on Config.
* Use the injected AgentLoopContext instead.
*/
get toolRegistry(): ToolRegistry {
return this._toolRegistry;
}
/**
* @deprecated Do not access directly on Config.
* Use the injected AgentLoopContext instead.
*/
get messageBus(): MessageBus {
return this._messageBus;
}
/**
* @deprecated Do not access directly on Config.
* Use the injected AgentLoopContext instead.
*/
get geminiClient(): GeminiClient {
return this._geminiClient;
}
@@ -1581,7 +1604,7 @@ export class Config implements McpContext, AgentLoopContext {
const isPreview =
model === PREVIEW_GEMINI_MODEL_AUTO ||
isPreviewModel(this.getActiveModel());
isPreviewModel(this.getActiveModel(), this);
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
const flashModel = isPreview
? PREVIEW_GEMINI_FLASH_MODEL
@@ -1779,8 +1802,9 @@ export class Config implements McpContext, AgentLoopContext {
}
const hasAccess =
quota.buckets?.some((b) => b.modelId && isPreviewModel(b.modelId)) ??
false;
quota.buckets?.some(
(b) => b.modelId && isPreviewModel(b.modelId, this),
) ?? false;
this.setHasAccessToPreviewModel(hasAccess);
return quota;
} catch (e) {
@@ -2172,6 +2196,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.acceptRawOutputRisk;
}
getExperimentalDynamicModelConfiguration(): boolean {
return this.dynamicModelConfiguration;
}
getPendingIncludeDirectories(): string[] {
return this.pendingIncludeDirectories;
}
@@ -2243,7 +2271,7 @@ export class Config implements McpContext, AgentLoopContext {
* Whenever the user memory (GEMINI.md files) is updated.
*/
updateSystemInstructionIfInitialized(): void {
const geminiClient = this.getGeminiClient();
const geminiClient = this.geminiClient;
if (geminiClient?.isInitialized()) {
geminiClient.updateSystemInstruction();
}
@@ -2709,16 +2737,16 @@ export class Config implements McpContext, AgentLoopContext {
// Re-register ActivateSkillTool to update its schema with the newly discovered skills
if (this.getSkillManager().getSkills().length > 0) {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.getToolRegistry().registerTool(
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.registerTool(
new ActivateSkillTool(this, this.messageBus),
);
} else {
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
}
} else {
this.getSkillManager().clearSkills();
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
}
// Notify the client that system instructions might need updating
@@ -3054,7 +3082,7 @@ export class Config implements McpContext, AgentLoopContext {
for (const definition of definitions) {
try {
const tool = new SubagentTool(definition, this, this.getMessageBus());
const tool = new SubagentTool(definition, this, this.messageBus);
registry.registerTool(tool);
} catch (e: unknown) {
debugLogger.warn(
@@ -3159,7 +3187,7 @@ export class Config implements McpContext, AgentLoopContext {
this.registerSubAgentTools(this._toolRegistry);
}
// Propagate updates to the active chat session
const client = this.getGeminiClient();
const client = this.geminiClient;
if (client?.isInitialized()) {
await client.setTools();
client.updateSystemInstruction();
@@ -249,4 +249,94 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
},
],
modelDefinitions: {
// Concrete Models
'gemini-3.1-pro-preview': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3.1-pro-preview-customtools': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3-pro-preview': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3-flash-preview': {
tier: 'flash',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
'gemini-2.5-flash': {
tier: 'flash',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
'gemini-2.5-flash-lite': {
tier: 'flash-lite',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
// Aliases
auto: {
tier: 'auto',
isPreview: true,
features: { thinking: true, multimodalToolUse: false },
},
pro: {
tier: 'pro',
isPreview: false,
features: { thinking: true, multimodalToolUse: false },
},
flash: {
tier: 'flash',
isPreview: false,
features: { thinking: false, multimodalToolUse: false },
},
'flash-lite': {
tier: 'flash-lite',
isPreview: false,
features: { thinking: false, multimodalToolUse: false },
},
'auto-gemini-3': {
displayName: 'Auto (Gemini 3)',
tier: 'auto',
isPreview: true,
dialogLocation: 'main',
dialogDescription:
'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash',
features: { thinking: true, multimodalToolUse: false },
},
'auto-gemini-2.5': {
displayName: 'Auto (Gemini 2.5)',
tier: 'auto',
isPreview: false,
dialogLocation: 'main',
dialogDescription:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
features: { thinking: false, multimodalToolUse: false },
},
},
};
+115
View File
@@ -36,6 +36,121 @@ import {
VALID_GEMINI_MODELS,
VALID_ALIASES,
} from './models.js';
import type { Config } from './config.js';
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
const modelConfigService = new ModelConfigService(DEFAULT_MODEL_CONFIGS);
const dynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
const legacyConfig = {
getExperimentalDynamicModelConfiguration: () => false,
modelConfigService,
} as unknown as Config;
describe('Dynamic Configuration Parity', () => {
const modelsToTest = [
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
'custom-model',
];
it('getDisplayString should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = getDisplayString(model, legacyConfig);
const dynamic = getDisplayString(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isPreviewModel should match legacy behavior', () => {
const allModels = [
...modelsToTest,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
for (const model of allModels) {
const legacy = isPreviewModel(model, legacyConfig);
const dynamic = isPreviewModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isProModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isProModel(model, legacyConfig);
const dynamic = isProModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isGemini3Model should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isGemini3Model(model, legacyConfig);
const dynamic = isGemini3Model(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isGemini2Model should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isGemini2Model(model, legacyConfig);
const dynamic = isGemini2Model(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isCustomModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isCustomModel(model, legacyConfig);
const dynamic = isCustomModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('supportsModernFeatures should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = supportsModernFeatures(model, legacyConfig);
const dynamic = supportsModernFeatures(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isActiveModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isActiveModel(model, false, false, legacyConfig);
const dynamic = isActiveModel(model, false, false, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isValidModelOrAlias should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isValidModelOrAlias(model, legacyConfig);
const dynamic = isValidModelOrAlias(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('supportsMultimodalFunctionResponse should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = supportsMultimodalFunctionResponse(model, legacyConfig);
const dynamic = supportsMultimodalFunctionResponse(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
});
describe('isPreviewModel', () => {
it('should return true for preview models', () => {
+137 -13
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ModelConfigService } from '../services/modelConfigService.js';
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
@@ -46,6 +48,15 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops.
export const DEFAULT_THINKING_MODE = 8192;
/**
* Represents the minimal configuration needed to resolve models dynamically.
* This breaks circular dependencies by avoiding an import of the full Config class.
*/
export interface ModelResolutionContext {
getExperimentalDynamicModelConfiguration?: () => boolean;
modelConfigService: ModelConfigService;
}
/**
* Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite')
* to a concrete model name.
@@ -148,7 +159,17 @@ export function resolveClassifierModel(
}
return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
}
export function getDisplayString(model: string) {
export function getDisplayString(
model: string,
config?: ModelResolutionContext,
) {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const definition = config.modelConfigService.getModelDefinition(model);
if (definition?.displayName) {
return definition.displayName;
}
}
switch (model) {
case PREVIEW_GEMINI_MODEL_AUTO:
return 'Auto (Gemini 3)';
@@ -169,9 +190,19 @@ export function getDisplayString(model: string) {
* Checks if the model is a preview model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a preview model.
*/
export function isPreviewModel(model: string): boolean {
export function isPreviewModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return (
config.modelConfigService.getModelDefinition(model)?.isPreview === true
);
}
return (
model === PREVIEW_GEMINI_MODEL ||
model === PREVIEW_GEMINI_3_1_MODEL ||
@@ -186,9 +217,16 @@ export function isPreviewModel(model: string): boolean {
* Checks if the model is a Pro model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Pro model.
*/
export function isProModel(model: string): boolean {
export function isProModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.getModelDefinition(model)?.tier === 'pro';
}
return model.toLowerCase().includes('pro');
}
@@ -196,9 +234,22 @@ export function isProModel(model: string): boolean {
* Checks if the model is a Gemini 3 model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Gemini 3 model.
*/
export function isGemini3Model(model: string): boolean {
export function isGemini3Model(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior resolves the model first.
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.family ===
'gemini-3'
);
}
const resolved = resolveModel(model);
return /^gemini-3(\.|-|$)/.test(resolved);
}
@@ -207,9 +258,20 @@ export function isGemini3Model(model: string): boolean {
* Checks if the model is a Gemini 2.x model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Gemini-2.x model.
*/
export function isGemini2Model(model: string): boolean {
export function isGemini2Model(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior does NOT resolve the model first for Gemini 2 check.
return (
config.modelConfigService.getModelDefinition(model)?.family ===
'gemini-2.5'
);
}
return /^gemini-2(\.|$)/.test(model);
}
@@ -217,9 +279,20 @@ export function isGemini2Model(model: string): boolean {
* Checks if the model is a "custom" model (not Gemini branded).
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is not a Gemini branded model.
*/
export function isCustomModel(model: string): boolean {
export function isCustomModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.tier ===
'custom' || !resolved.startsWith('gemini-')
);
}
const resolved = resolveModel(model);
return !resolved.startsWith('gemini-');
}
@@ -229,20 +302,31 @@ export function isCustomModel(model: string): boolean {
* This includes Gemini 3 models and any custom models.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model supports modern features like thoughts.
*/
export function supportsModernFeatures(model: string): boolean {
if (isGemini3Model(model)) return true;
return isCustomModel(model);
export function supportsModernFeatures(
model: string,
config?: ModelResolutionContext,
): boolean {
if (isGemini3Model(model, config)) return true;
return isCustomModel(model, config);
}
/**
* Checks if the model is an auto model.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is an auto model.
*/
export function isAutoModel(model: string): boolean {
export function isAutoModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.getModelDefinition(model)?.tier === 'auto';
}
return (
model === GEMINI_MODEL_ALIAS_AUTO ||
model === PREVIEW_GEMINI_MODEL_AUTO ||
@@ -255,9 +339,23 @@ export function isAutoModel(model: string): boolean {
* This is supported in Gemini 3.
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model supports multimodal function responses.
*/
export function supportsMultimodalFunctionResponse(model: string): boolean {
export function supportsMultimodalFunctionResponse(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (VALID_ALIASES.has(model)) {
return false;
}
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.features
?.multimodalToolUse === true
);
}
return model.startsWith('gemini-3-');
}
@@ -266,16 +364,32 @@ export function supportsMultimodalFunctionResponse(model: string): boolean {
*
* @param model The model name to check.
* @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is active.
*/
export function isActiveModel(
model: string,
useGemini3_1: boolean = false,
useCustomToolModel: boolean = false,
config?: ModelResolutionContext,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (VALID_ALIASES.has(model)) {
return false;
}
const definition = config.modelConfigService.getModelDefinition(model);
if (!definition) {
return false;
}
// Legacy logic only considers explicit Gemini models as "active".
if (definition.tier === 'custom' && !model.startsWith('gemini-')) {
return false;
}
} else if (!VALID_GEMINI_MODELS.has(model)) {
return false;
}
if (useGemini3_1) {
if (model === PREVIEW_GEMINI_MODEL) {
return false;
@@ -297,9 +411,19 @@ export function isActiveModel(
* Checks if the model name is valid (either a valid model or a valid alias).
*
* @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is valid.
*/
export function isValidModelOrAlias(model: string): boolean {
export function isValidModelOrAlias(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (config.modelConfigService.getModelDefinition(model)) {
return true;
}
}
// Check if it's a valid alias
if (VALID_ALIASES.has(model)) {
return true;
@@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
import { Config } from './config.js';
import { TRACKER_CREATE_TASK_TOOL_NAME } from '../tools/tool-names.js';
import * as os from 'node:os';
import type { AgentLoopContext } from './agent-loop-context.js';
describe('Config Tracker Feature Flag', () => {
const baseParams = {
@@ -21,7 +22,8 @@ describe('Config Tracker Feature Flag', () => {
it('should not register tracker tools by default', async () => {
const config = new Config(baseParams);
await config.initialize();
const registry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const registry = loopContext.toolRegistry;
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
});
@@ -31,7 +33,8 @@ describe('Config Tracker Feature Flag', () => {
tracker: true,
});
await config.initialize();
const registry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const registry = loopContext.toolRegistry;
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeDefined();
});
@@ -41,7 +44,8 @@ describe('Config Tracker Feature Flag', () => {
tracker: false,
});
await config.initialize();
const registry = config.getToolRegistry();
const loopContext: AgentLoopContext = config;
const registry = loopContext.toolRegistry;
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
});
});
+25 -1
View File
@@ -52,6 +52,7 @@ import * as policyCatalog from '../availability/policyCatalog.js';
import { LlmRole, LoopType } from '../telemetry/types.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents } from '../utils/events.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();
@@ -216,6 +217,7 @@ describe('Gemini Client (client.ts)', () => {
getGlobalMemory: vi.fn().mockReturnValue(''),
getEnvironmentMemory: vi.fn().mockReturnValue(''),
isJitContextEnabled: vi.fn().mockReturnValue(false),
getContextManager: vi.fn().mockReturnValue(undefined),
getToolOutputMaskingEnabled: vi.fn().mockReturnValue(false),
getDisableLoopDetection: vi.fn().mockReturnValue(false),
@@ -284,7 +286,10 @@ describe('Gemini Client (client.ts)', () => {
(
mockConfig as unknown as { toolRegistry: typeof mockToolRegistry }
).toolRegistry = mockToolRegistry;
(mockConfig as unknown as { messageBus: undefined }).messageBus = undefined;
(mockConfig as unknown as { messageBus: MessageBus }).messageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
} as unknown as MessageBus;
(mockConfig as unknown as { config: Config; promptId: string }).config =
mockConfig;
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
@@ -293,6 +298,8 @@ describe('Gemini Client (client.ts)', () => {
client = new GeminiClient(mockConfig as unknown as AgentLoopContext);
await client.initialize();
vi.mocked(mockConfig.getGeminiClient).mockReturnValue(client);
(mockConfig as unknown as { geminiClient: GeminiClient }).geminiClient =
client;
vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
});
@@ -368,6 +375,23 @@ describe('Gemini Client (client.ts)', () => {
expect(newHistory.length).toBe(initialHistory.length);
expect(JSON.stringify(newHistory)).not.toContain('some old message');
});
it('should refresh ContextManager to reset JIT loaded paths', async () => {
const mockRefresh = vi.fn().mockResolvedValue(undefined);
vi.mocked(mockConfig.getContextManager).mockReturnValue({
refresh: mockRefresh,
} as unknown as ReturnType<typeof mockConfig.getContextManager>);
await client.resetChat();
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
it('should not fail when ContextManager is undefined', async () => {
vi.mocked(mockConfig.getContextManager).mockReturnValue(undefined);
await expect(client.resetChat()).resolves.not.toThrow();
});
});
describe('startChat', () => {
+4 -1
View File
@@ -299,6 +299,9 @@ export class GeminiClient {
async resetChat(): Promise<void> {
this.chat = await this.startChat();
this.updateTelemetryTokenCount();
// Reset JIT context loaded paths so subdirectory context can be
// re-discovered in the new session.
await this.config.getContextManager()?.refresh();
}
dispose() {
@@ -866,7 +869,7 @@ export class GeminiClient {
}
const hooksEnabled = this.config.getEnableHooks();
const messageBus = this.config.getMessageBus();
const messageBus = this.context.messageBus;
if (this.lastPromptId !== prompt_id) {
this.loopDetector.reset(prompt_id, partListUnionToString(request));
@@ -318,6 +318,16 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
}) as unknown as PolicyEngine;
}
Object.defineProperty(finalConfig, 'toolRegistry', {
get: () => finalConfig.getToolRegistry?.() || defaultToolRegistry,
});
Object.defineProperty(finalConfig, 'messageBus', {
get: () => finalConfig.getMessageBus?.(),
});
Object.defineProperty(finalConfig, 'geminiClient', {
get: () => finalConfig.getGeminiClient?.(),
});
return finalConfig;
}
@@ -351,7 +361,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -431,7 +441,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -532,7 +542,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -629,7 +639,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -684,7 +694,7 @@ describe('CoreToolScheduler', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -750,7 +760,7 @@ describe('CoreToolScheduler with payload', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -898,7 +908,7 @@ describe('CoreToolScheduler edit cancellation', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -991,7 +1001,7 @@ describe('CoreToolScheduler YOLO mode', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1083,7 +1093,7 @@ describe('CoreToolScheduler request queueing', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1212,7 +1222,7 @@ describe('CoreToolScheduler request queueing', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1320,7 +1330,7 @@ describe('CoreToolScheduler request queueing', () => {
});
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1381,7 +1391,7 @@ describe('CoreToolScheduler request queueing', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1453,7 +1463,7 @@ describe('CoreToolScheduler request queueing', () => {
getAllTools: () => [],
getToolsByServer: () => [],
tools: new Map(),
config: mockConfig,
context: mockConfig,
mcpClientManager: undefined,
getToolByName: () => testTool,
getToolByDisplayName: () => testTool,
@@ -1471,7 +1481,7 @@ describe('CoreToolScheduler request queueing', () => {
> = [];
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate: (toolCalls) => {
onToolCallsUpdate(toolCalls);
@@ -1620,7 +1630,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1725,7 +1735,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1829,7 +1839,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@@ -1894,7 +1904,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
getPreferredEditor: () => 'vscode',
});
@@ -2005,7 +2015,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
getPreferredEditor: () => 'vscode',
});
@@ -2069,7 +2079,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
.mockReturnValue(new HookSystem(mockConfig));
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2138,7 +2148,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2229,7 +2239,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2283,7 +2293,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
getPreferredEditor: () => 'vscode',
});
@@ -2344,7 +2354,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
+14 -16
View File
@@ -13,7 +13,6 @@ import {
ToolConfirmationOutcome,
} from '../tools/tools.js';
import type { EditorType } from '../utils/editor.js';
import type { Config } from '../config/config.js';
import { PolicyDecision } from '../policy/types.js';
import { logToolCall } from '../telemetry/loggers.js';
import { ToolErrorType } from '../tools/tool-error.js';
@@ -50,6 +49,7 @@ import { ToolExecutor } from '../scheduler/tool-executor.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { getPolicyDenialError } from '../scheduler/policy.js';
import { GeminiCliOperation } from '../telemetry/constants.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export type {
ToolCall,
@@ -92,7 +92,7 @@ const createErrorResponse = (
});
interface CoreToolSchedulerOptions {
config: Config;
context: AgentLoopContext;
outputUpdateHandler?: OutputUpdateHandler;
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
onToolCallsUpdate?: ToolCallsUpdateHandler;
@@ -112,7 +112,7 @@ export class CoreToolScheduler {
private onAllToolCallsComplete?: AllToolCallsCompleteHandler;
private onToolCallsUpdate?: ToolCallsUpdateHandler;
private getPreferredEditor: () => EditorType | undefined;
private config: Config;
private context: AgentLoopContext;
private isFinalizingToolCalls = false;
private isScheduling = false;
private isCancelling = false;
@@ -128,19 +128,19 @@ export class CoreToolScheduler {
private toolModifier: ToolModificationHandler;
constructor(options: CoreToolSchedulerOptions) {
this.config = options.config;
this.context = options.context;
this.outputUpdateHandler = options.outputUpdateHandler;
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate;
this.getPreferredEditor = options.getPreferredEditor;
this.toolExecutor = new ToolExecutor(this.config);
this.toolExecutor = new ToolExecutor(this.context.config);
this.toolModifier = new ToolModificationHandler();
// Subscribe to message bus for ASK_USER policy decisions
// Use a static WeakMap to ensure we only subscribe ONCE per MessageBus instance
// This prevents memory leaks when multiple CoreToolScheduler instances are created
// (e.g., on every React render, or for each non-interactive tool call)
const messageBus = this.config.getMessageBus();
const messageBus = this.context.messageBus;
// Check if we've already subscribed a handler to this message bus
if (!CoreToolScheduler.subscribedMessageBuses.has(messageBus)) {
@@ -526,18 +526,16 @@ export class CoreToolScheduler {
);
}
const requestsToProcess = Array.isArray(request) ? request : [request];
const currentApprovalMode = this.config.getApprovalMode();
const currentApprovalMode = this.context.config.getApprovalMode();
this.completedToolCallsForBatch = [];
const newToolCalls: ToolCall[] = requestsToProcess.map(
(reqInfo): ToolCall => {
const toolInstance = this.config
.getToolRegistry()
.getTool(reqInfo.name);
const toolInstance = this.context.toolRegistry.getTool(reqInfo.name);
if (!toolInstance) {
const suggestion = getToolSuggestion(
reqInfo.name,
this.config.getToolRegistry().getAllToolNames(),
this.context.toolRegistry.getAllToolNames(),
);
const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`;
return {
@@ -647,13 +645,13 @@ export class CoreToolScheduler {
: undefined;
const toolAnnotations = toolCall.tool.toolAnnotations;
const { decision, rule } = await this.config
const { decision, rule } = await this.context.config
.getPolicyEngine()
.check(toolCallForPolicy, serverName, toolAnnotations);
if (decision === PolicyDecision.DENY) {
const { errorMessage, errorType } = getPolicyDenialError(
this.config,
this.context.config,
rule,
);
this.setStatusInternal(
@@ -694,7 +692,7 @@ export class CoreToolScheduler {
signal,
);
} else {
if (!this.config.isInteractive()) {
if (!this.context.config.isInteractive()) {
throw new Error(
`Tool execution for "${
toolCall.tool.displayName || toolCall.tool.name
@@ -703,7 +701,7 @@ export class CoreToolScheduler {
}
// Fire Notification hook before showing confirmation to user
const hookSystem = this.config.getHookSystem();
const hookSystem = this.context.config.getHookSystem();
if (hookSystem) {
await hookSystem.fireToolNotificationEvent(confirmationDetails);
}
@@ -988,7 +986,7 @@ export class CoreToolScheduler {
// The active tool is finished. Move it to the completed batch.
const completedCall = activeCall as CompletedToolCall;
this.completedToolCallsForBatch.push(completedCall);
logToolCall(this.config, new ToolCallEvent(completedCall));
logToolCall(this.context.config, new ToolCallEvent(completedCall));
// Clear the active tool slot. This is crucial for the sequential processing.
this.toolCalls = [];
@@ -137,6 +137,10 @@ describe('GeminiChat', () => {
let currentActiveModel = 'gemini-pro';
mockConfig = {
get config() {
return this;
},
promptId: 'test-session-id',
getSessionId: () => 'test-session-id',
getTelemetryLogPromptsEnabled: () => true,
getUsageStatisticsEnabled: () => true,
+37 -28
View File
@@ -25,7 +25,6 @@ import {
getRetryErrorType,
} from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
resolveModel,
isGemini2Model,
@@ -59,6 +58,7 @@ import {
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { coreEvents } from '../utils/events.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export enum StreamEventType {
/** A regular content chunk from the API. */
@@ -251,7 +251,7 @@ export class GeminiChat {
private lastPromptTokenCount: number;
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
private systemInstruction: string = '',
private tools: Tool[] = [],
private history: Content[] = [],
@@ -260,7 +260,7 @@ export class GeminiChat {
kind: 'main' | 'subagent' = 'main',
) {
validateHistory(history);
this.chatRecordingService = new ChatRecordingService(config);
this.chatRecordingService = new ChatRecordingService(context);
this.chatRecordingService.initialize(resumedSessionData, kind);
this.lastPromptTokenCount = estimateTokenCountSync(
this.history.flatMap((c) => c.parts || []),
@@ -315,7 +315,7 @@ export class GeminiChat {
const userContent = createUserContent(message);
const { model } =
this.config.modelConfigService.getResolvedConfig(modelConfigKey);
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
// Record user input - capture complete message with all parts (text, files, images, etc.)
// but skip recording function responses (tool call results) as they should be stored in tool call records
@@ -350,7 +350,7 @@ export class GeminiChat {
this: GeminiChat,
): AsyncGenerator<StreamEvent, void, void> {
try {
const maxAttempts = this.config.getMaxAttempts();
const maxAttempts = this.context.config.getMaxAttempts();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let isConnectionPhase = true;
@@ -412,7 +412,7 @@ export class GeminiChat {
// like ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC or ApiError)
const isRetryable = isRetryableError(
error,
this.config.getRetryFetchErrors(),
this.context.config.getRetryFetchErrors(),
);
const isContentError = error instanceof InvalidStreamError;
@@ -421,7 +421,7 @@ export class GeminiChat {
: getRetryErrorType(error);
if (
(isContentError && isGemini2Model(model)) ||
(isContentError && isGemini2Model(model, this.context.config)) ||
(isRetryable && !signal.aborted)
) {
// The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
@@ -437,12 +437,12 @@ export class GeminiChat {
if (isContentError) {
logContentRetry(
this.config,
this.context.config,
new ContentRetryEvent(attempt, errorType, delayMs, model),
);
} else {
logNetworkRetryAttempt(
this.config,
this.context.config,
new NetworkRetryAttemptEvent(
attempt + 1,
maxAttempts,
@@ -472,7 +472,7 @@ export class GeminiChat {
}
logContentRetryFailure(
this.config,
this.context.config,
new ContentRetryFailureEvent(attempt + 1, errorType, model),
);
@@ -502,7 +502,7 @@ export class GeminiChat {
model: availabilityFinalModel,
config: newAvailabilityConfig,
maxAttempts: availabilityMaxAttempts,
} = applyModelSelection(this.config, modelConfigKey);
} = applyModelSelection(this.context.config, modelConfigKey);
let lastModelToUse = availabilityFinalModel;
let currentGenerateContentConfig: GenerateContentConfig =
@@ -511,26 +511,30 @@ export class GeminiChat {
let lastContentsToUse: Content[] = [...requestContents];
const getAvailabilityContext = createAvailabilityContextProvider(
this.config,
this.context.config,
() => lastModelToUse,
);
// Track initial active model to detect fallback changes
const initialActiveModel = this.config.getActiveModel();
const initialActiveModel = this.context.config.getActiveModel();
const apiCall = async () => {
const useGemini3_1 = (await this.config.getGemini31Launched?.()) ?? false;
const useGemini3_1 =
(await this.context.config.getGemini31Launched?.()) ?? false;
// Default to the last used model (which respects arguments/availability selection)
let modelToUse = resolveModel(lastModelToUse, useGemini3_1);
// If the active model has changed (e.g. due to a fallback updating the config),
// we switch to the new active model.
if (this.config.getActiveModel() !== initialActiveModel) {
modelToUse = resolveModel(this.config.getActiveModel(), useGemini3_1);
if (this.context.config.getActiveModel() !== initialActiveModel) {
modelToUse = resolveModel(
this.context.config.getActiveModel(),
useGemini3_1,
);
}
if (modelToUse !== lastModelToUse) {
const { generateContentConfig: newConfig } =
this.config.modelConfigService.getResolvedConfig({
this.context.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: modelToUse,
});
@@ -547,11 +551,14 @@ export class GeminiChat {
abortSignal,
};
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
let contentsToUse: Content[] = supportsModernFeatures(
modelToUse,
this.context.config,
)
? [...contentsForPreviewModel]
: [...requestContents];
const hookSystem = this.config.getHookSystem();
const hookSystem = this.context.config.getHookSystem();
if (hookSystem) {
const beforeModelResult = await hookSystem.fireBeforeModelEvent({
model: modelToUse,
@@ -619,7 +626,7 @@ export class GeminiChat {
lastConfig = config;
lastContentsToUse = contentsToUse;
return this.config.getContentGenerator().generateContentStream(
return this.context.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
contents: contentsToUse,
@@ -633,12 +640,12 @@ export class GeminiChat {
const onPersistent429Callback = async (
authType?: string,
error?: unknown,
) => handleFallback(this.config, lastModelToUse, authType, error);
) => handleFallback(this.context.config, lastModelToUse, authType, error);
const onValidationRequiredCallback = async (
validationError: ValidationRequiredError,
) => {
const handler = this.config.getValidationHandler();
const handler = this.context.config.getValidationHandler();
if (typeof handler !== 'function') {
// No handler registered, re-throw to show default error message
throw validationError;
@@ -653,15 +660,17 @@ export class GeminiChat {
const streamResponse = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
onValidationRequired: onValidationRequiredCallback,
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
authType: this.context.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.context.config.getRetryFetchErrors(),
signal: abortSignal,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
maxAttempts:
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
getAvailabilityContext,
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
maxAttempts:
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
delayMs,
error: error instanceof Error ? error.message : String(error),
model: lastModelToUse,
@@ -814,7 +823,7 @@ export class GeminiChat {
isSchemaDepthError(error.message) ||
isInvalidArgumentError(error.message)
) {
const tools = this.config.getToolRegistry().getAllTools();
const tools = this.context.toolRegistry.getAllTools();
const cyclicSchemaTools: string[] = [];
for (const tool of tools) {
if (
@@ -881,7 +890,7 @@ export class GeminiChat {
}
}
const hookSystem = this.config.getHookSystem();
const hookSystem = this.context.config.getHookSystem();
if (originalRequest && chunk && hookSystem) {
const hookResult = await hookSystem.fireAfterModelEvent(
originalRequest,
@@ -79,7 +79,20 @@ describe('GeminiChat Network Retries', () => {
// Default mock implementation: execute the function immediately
mockRetryWithBackoff.mockImplementation(async (apiCall) => apiCall());
const mockToolRegistry = { getTool: vi.fn() };
const testMessageBus = { publish: vi.fn(), subscribe: vi.fn() };
mockConfig = {
get config() {
return this;
},
get toolRegistry() {
return mockToolRegistry;
},
get messageBus() {
return testMessageBus;
},
promptId: 'test-session-id',
getSessionId: () => 'test-session-id',
getTelemetryLogPromptsEnabled: () => true,
getUsageStatisticsEnabled: () => true,
@@ -10,6 +10,7 @@ import fs from 'node:fs';
import type { Config } from '../config/config.js';
import type { AgentDefinition } from '../agents/types.js';
import * as toolNames from '../tools/tool-names.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('node:fs');
vi.mock('../utils/gitUtils', () => ({
@@ -22,6 +23,17 @@ describe('Core System Prompt Substitution', () => {
vi.resetAllMocks();
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
mockConfig = {
get config() {
return this;
},
toolRegistry: {
getAllToolNames: vi
.fn()
.mockReturnValue([
toolNames.WRITE_FILE_TOOL_NAME,
toolNames.READ_FILE_TOOL_NAME,
]),
},
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi
.fn()
@@ -131,7 +143,10 @@ describe('Core System Prompt Substitution', () => {
});
it('should not substitute disabled tool names', () => {
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([]);
vi.mocked(
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry
.getAllToolNames,
).mockReturnValue([]);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('Use ${write_file_ToolName}.');
+25 -11
View File
@@ -82,11 +82,12 @@ describe('Core System Prompt (prompts.ts)', () => {
vi.stubEnv('SANDBOX', undefined);
vi.stubEnv('GEMINI_SYSTEM_MD', undefined);
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', undefined);
const mockRegistry = {
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
getAllTools: vi.fn().mockReturnValue([]),
};
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
getAllTools: vi.fn().mockReturnValue([]),
}),
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
@@ -114,6 +115,12 @@ describe('Core System Prompt (prompts.ts)', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTrackerEnabled: vi.fn().mockReturnValue(false),
get config() {
return this;
},
get toolRegistry() {
return mockRegistry;
},
} as unknown as Config;
});
@@ -374,7 +381,7 @@ describe('Core System Prompt (prompts.ts)', () => {
it('should redact grep and glob from the system prompt when they are disabled', () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([]);
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([]);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('`grep_search`');
@@ -390,10 +397,11 @@ describe('Core System Prompt (prompts.ts)', () => {
])(
'should handle CodebaseInvestigator with tools=%s',
(toolNames, expectCodebaseInvestigator) => {
const mockToolRegistry = {
getAllToolNames: vi.fn().mockReturnValue(toolNames),
};
const testConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue(toolNames),
}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
@@ -413,6 +421,12 @@ describe('Core System Prompt (prompts.ts)', () => {
}),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTrackerEnabled: vi.fn().mockReturnValue(false),
get config() {
return this;
},
get toolRegistry() {
return mockToolRegistry;
},
} as unknown as Config;
const prompt = getCoreSystemPrompt(testConfig);
@@ -468,7 +482,7 @@ describe('Core System Prompt (prompts.ts)', () => {
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue(
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
planModeTools,
);
};
@@ -522,7 +536,7 @@ describe('Core System Prompt (prompts.ts)', () => {
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue(
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
subsetTools,
);
@@ -667,7 +681,7 @@ describe('Core System Prompt (prompts.ts)', () => {
it('should include planning phase suggestion when enter_plan_mode tool is enabled', () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([
'enter_plan_mode',
]);
const prompt = getCoreSystemPrompt(mockConfig);
@@ -64,16 +64,22 @@ describe('HookEventHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
const mockGeminiClient = {
getChatRecordingService: vi.fn().mockReturnValue({
getConversationFilePath: vi
.fn()
.mockReturnValue('/test/project/.gemini/tmp/chats/session.json'),
}),
};
mockConfig = {
get config() {
return this;
},
geminiClient: mockGeminiClient,
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getSessionId: vi.fn().mockReturnValue('test-session'),
getWorkingDir: vi.fn().mockReturnValue('/test/project'),
getGeminiClient: vi.fn().mockReturnValue({
getChatRecordingService: vi.fn().mockReturnValue({
getConversationFilePath: vi
.fn()
.mockReturnValue('/test/project/.gemini/tmp/chats/session.json'),
}),
}),
} as unknown as Config;
mockHookPlanner = {
+8 -9
View File
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
import type { HookRunner } from './hookRunner.js';
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
@@ -40,12 +39,13 @@ import { logHookCall } from '../telemetry/loggers.js';
import { HookCallEvent } from '../telemetry/types.js';
import { debugLogger } from '../utils/debugLogger.js';
import { coreEvents } from '../utils/events.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
/**
* Hook event bus that coordinates hook execution across the system
*/
export class HookEventHandler {
private readonly config: Config;
private readonly context: AgentLoopContext;
private readonly hookPlanner: HookPlanner;
private readonly hookRunner: HookRunner;
private readonly hookAggregator: HookAggregator;
@@ -58,12 +58,12 @@ export class HookEventHandler {
private readonly reportedFailures = new WeakMap<object, Set<string>>();
constructor(
config: Config,
context: AgentLoopContext,
hookPlanner: HookPlanner,
hookRunner: HookRunner,
hookAggregator: HookAggregator,
) {
this.config = config;
this.context = context;
this.hookPlanner = hookPlanner;
this.hookRunner = hookRunner;
this.hookAggregator = hookAggregator;
@@ -370,15 +370,14 @@ export class HookEventHandler {
private createBaseInput(eventName: HookEventName): HookInput {
// Get the transcript path from the ChatRecordingService if available
const transcriptPath =
this.config
.getGeminiClient()
this.context.geminiClient
?.getChatRecordingService()
?.getConversationFilePath() ?? '';
return {
session_id: this.config.getSessionId(),
session_id: this.context.config.getSessionId(),
transcript_path: transcriptPath,
cwd: this.config.getWorkingDir(),
cwd: this.context.config.getWorkingDir(),
hook_event_name: eventName,
timestamp: new Date().toISOString(),
};
@@ -457,7 +456,7 @@ export class HookEventHandler {
result.error?.message,
);
logHookCall(this.config, hookCallEvent);
logHookCall(this.context.config, hookCallEvent);
}
// Log individual errors
+7 -4
View File
@@ -669,10 +669,13 @@ export function createPolicyUpdater(
if (message.mcpName) {
newRule.mcpName = message.mcpName;
// Extract simple tool name
newRule.toolName = toolName.startsWith(`${message.mcpName}__`)
? toolName.slice(message.mcpName.length + 2)
: toolName;
const expectedPrefix = `${MCP_TOOL_PREFIX}${message.mcpName}_`;
if (toolName.startsWith(expectedPrefix)) {
newRule.toolName = toolName.slice(expectedPrefix.length);
} else {
newRule.toolName = toolName;
}
} else {
newRule.toolName = toolName;
}
@@ -17,6 +17,7 @@ import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { MockTool } from '../test-utils/mock-tool.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('../tools/memoryTool.js', async (importOriginal) => {
const actual = await importOriginal();
@@ -38,11 +39,20 @@ describe('PromptProvider', () => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', '');
const mockToolRegistry = {
getAllToolNames: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
};
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
}),
get config() {
return this as unknown as Config;
},
get toolRegistry() {
return (
this as { getToolRegistry: () => ToolRegistry }
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
+30 -25
View File
@@ -7,7 +7,6 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import type { Config } from '../config/config.js';
import type { HierarchicalMemory } from '../config/memory.js';
import { GEMINI_DIR } from '../utils/paths.js';
import { ApprovalMode } from '../policy/types.js';
@@ -31,6 +30,7 @@ import {
import { resolveModel, supportsModernFeatures } from '../config/models.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
/**
* Orchestrates prompt generation by gathering context and building options.
@@ -40,7 +40,7 @@ export class PromptProvider {
* Generates the core system prompt.
*/
getCoreSystemPrompt(
config: Config,
context: AgentLoopContext,
userMemory?: string | HierarchicalMemory,
interactiveOverride?: boolean,
): string {
@@ -48,27 +48,29 @@ export class PromptProvider {
process.env['GEMINI_SYSTEM_MD'],
);
const interactiveMode = interactiveOverride ?? config.isInteractive();
const approvalMode = config.getApprovalMode?.() ?? ApprovalMode.DEFAULT;
const interactiveMode =
interactiveOverride ?? context.config.isInteractive();
const approvalMode =
context.config.getApprovalMode?.() ?? ApprovalMode.DEFAULT;
const isPlanMode = approvalMode === ApprovalMode.PLAN;
const isYoloMode = approvalMode === ApprovalMode.YOLO;
const skills = config.getSkillManager().getSkills();
const toolNames = config.getToolRegistry().getAllToolNames();
const skills = context.config.getSkillManager().getSkills();
const toolNames = context.toolRegistry.getAllToolNames();
const enabledToolNames = new Set(toolNames);
const approvedPlanPath = config.getApprovedPlanPath();
const approvedPlanPath = context.config.getApprovedPlanPath();
const desiredModel = resolveModel(
config.getActiveModel(),
config.getGemini31LaunchedSync?.() ?? false,
context.config.getActiveModel(),
context.config.getGemini31LaunchedSync?.() ?? false,
);
const isModernModel = supportsModernFeatures(desiredModel);
const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = config.getToolRegistry().getAllTools();
const allTools = context.toolRegistry.getAllTools();
planModeToolsList = allTools
.map((t) => {
if (t instanceof DiscoveredMCPTool) {
@@ -100,7 +102,7 @@ export class PromptProvider {
);
basePrompt = applySubstitutions(
basePrompt,
config,
context.config,
skillsPrompt,
isModernModel,
);
@@ -124,7 +126,7 @@ export class PromptProvider {
contextFilenames,
})),
subAgents: this.withSection('agentContexts', () =>
config
context.config
.getAgentRegistry()
.getAllDefinitions()
.map((d) => ({
@@ -159,7 +161,7 @@ export class PromptProvider {
approvedPlan: approvedPlanPath
? { path: approvedPlanPath }
: undefined,
taskTracker: config.isTrackerEnabled(),
taskTracker: context.config.isTrackerEnabled(),
}),
!isPlanMode,
),
@@ -167,19 +169,20 @@ export class PromptProvider {
'planningWorkflow',
() => ({
planModeToolsList,
plansDir: config.storage.getPlansDir(),
approvedPlanPath: config.getApprovedPlanPath(),
taskTracker: config.isTrackerEnabled(),
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
taskTracker: context.config.isTrackerEnabled(),
}),
isPlanMode,
),
taskTracker: config.isTrackerEnabled(),
taskTracker: context.config.isTrackerEnabled(),
operationalGuidelines: this.withSection(
'operationalGuidelines',
() => ({
interactive: interactiveMode,
enableShellEfficiency: config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: config.isInteractiveShellEnabled(),
enableShellEfficiency:
context.config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: context.config.isInteractiveShellEnabled(),
}),
),
sandbox: this.withSection('sandbox', () => getSandboxMode()),
@@ -227,14 +230,16 @@ export class PromptProvider {
return sanitizedPrompt;
}
getCompressionPrompt(config: Config): string {
getCompressionPrompt(context: AgentLoopContext): string {
const desiredModel = resolveModel(
config.getActiveModel(),
config.getGemini31LaunchedSync?.() ?? false,
context.config.getActiveModel(),
context.config.getGemini31LaunchedSync?.() ?? false,
);
const isModernModel = supportsModernFeatures(desiredModel);
const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets;
return activeSnippets.getCompressionPrompt(config.getApprovedPlanPath());
return activeSnippets.getCompressionPrompt(
context.config.getApprovedPlanPath(),
);
}
private withSection<T>(
+12 -4
View File
@@ -11,6 +11,7 @@ import {
applySubstitutions,
} from './utils.js';
import type { Config } from '../config/config.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('../utils/paths.js', () => ({
homedir: vi.fn().mockReturnValue('/mock/home'),
@@ -208,6 +209,13 @@ describe('applySubstitutions', () => {
beforeEach(() => {
mockConfig = {
get config() {
return this;
},
toolRegistry: {
getAllToolNames: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
},
getAgentRegistry: vi.fn().mockReturnValue({
getAllDefinitions: vi.fn().mockReturnValue([]),
}),
@@ -256,10 +264,10 @@ describe('applySubstitutions', () => {
});
it('should replace ${AvailableTools} with tool names list', () => {
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry = {
getAllToolNames: vi.fn().mockReturnValue(['read_file', 'write_file']),
getAllTools: vi.fn().mockReturnValue([]),
} as unknown as ReturnType<Config['getToolRegistry']>);
} as unknown as ToolRegistry;
const result = applySubstitutions(
'Tools: ${AvailableTools}',
@@ -280,10 +288,10 @@ describe('applySubstitutions', () => {
});
it('should replace tool-specific ${toolName_ToolName} variables', () => {
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry = {
getAllToolNames: vi.fn().mockReturnValue(['read_file']),
getAllTools: vi.fn().mockReturnValue([]),
} as unknown as ReturnType<Config['getToolRegistry']>);
} as unknown as ToolRegistry;
const result = applySubstitutions(
'Use ${read_file_ToolName} to read',
+4 -4
View File
@@ -8,9 +8,9 @@ import path from 'node:path';
import process from 'node:process';
import { homedir } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { Config } from '../config/config.js';
import * as snippets from './snippets.js';
import * as legacySnippets from './snippets.legacy.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export type ResolvedPath = {
isSwitch: boolean;
@@ -63,7 +63,7 @@ export function resolvePathFromEnv(envVar?: string): ResolvedPath {
*/
export function applySubstitutions(
prompt: string,
config: Config,
context: AgentLoopContext,
skillsPrompt: string,
isGemini3: boolean = false,
): string {
@@ -73,7 +73,7 @@ export function applySubstitutions(
const activeSnippets = isGemini3 ? snippets : legacySnippets;
const subAgentsContent = activeSnippets.renderSubAgents(
config
context.config
.getAgentRegistry()
.getAllDefinitions()
.map((d) => ({
@@ -84,7 +84,7 @@ export function applySubstitutions(
result = result.replace(/\${SubAgents}/g, subAgentsContent);
const toolRegistry = config.getToolRegistry();
const toolRegistry = context.toolRegistry;
const allToolNames = toolRegistry.getAllToolNames();
const availableToolsList =
allToolNames.length > 0
@@ -36,7 +36,7 @@ export class ApprovalModeStrategy implements RoutingStrategy {
const model = context.requestedModel ?? config.getModel();
// This strategy only applies to "auto" models.
if (!isAutoModel(model)) {
if (!isAutoModel(model, config)) {
return null;
}
@@ -139,7 +139,7 @@ export class ClassifierStrategy implements RoutingStrategy {
const model = context.requestedModel ?? config.getModel();
if (
(await config.getNumericalRoutingEnabled()) &&
isGemini3Model(model)
isGemini3Model(model, config)
) {
return null;
}
@@ -109,7 +109,7 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
return null;
}
if (!isGemini3Model(model)) {
if (!isGemini3Model(model, config)) {
return null;
}
@@ -29,7 +29,7 @@ export class OverrideStrategy implements RoutingStrategy {
const overrideModel = context.requestedModel ?? config.getModel();
// If the model is 'auto' we should pass to the next strategy.
if (isAutoModel(overrideModel)) {
if (isAutoModel(overrideModel, config)) {
return null;
}
@@ -36,12 +36,15 @@ describe('ConsecaSafetyChecker', () => {
checker = ConsecaSafetyChecker.getInstance();
mockConfig = {
get config() {
return this;
},
enableConseca: true,
getToolRegistry: vi.fn().mockReturnValue({
getFunctionDeclarations: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
checker.setConfig(mockConfig);
checker.setContext(mockConfig);
vi.clearAllMocks();
// Default mock implementations
@@ -72,9 +75,12 @@ describe('ConsecaSafetyChecker', () => {
it('should return ALLOW if enableConseca is false', async () => {
const disabledConfig = {
get config() {
return this;
},
enableConseca: false,
} as unknown as Config;
checker.setConfig(disabledConfig);
checker.setContext(disabledConfig);
const input: SafetyCheckInput = {
protocolVersion: '1.0.0',
+10 -9
View File
@@ -23,12 +23,13 @@ import type { Config } from '../../config/config.js';
import { generatePolicy } from './policy-generator.js';
import { enforcePolicy } from './policy-enforcer.js';
import type { SecurityPolicy } from './types.js';
import type { AgentLoopContext } from '../../config/agent-loop-context.js';
export class ConsecaSafetyChecker implements InProcessChecker {
private static instance: ConsecaSafetyChecker | undefined;
private currentPolicy: SecurityPolicy | null = null;
private activeUserPrompt: string | null = null;
private config: Config | null = null;
private context: AgentLoopContext | null = null;
/**
* Private constructor to enforce singleton pattern.
@@ -50,8 +51,8 @@ export class ConsecaSafetyChecker implements InProcessChecker {
ConsecaSafetyChecker.instance = undefined;
}
setConfig(config: Config): void {
this.config = config;
setContext(context: AgentLoopContext): void {
this.context = context;
}
async check(input: SafetyCheckInput): Promise<SafetyCheckResult> {
@@ -59,7 +60,7 @@ export class ConsecaSafetyChecker implements InProcessChecker {
`[Conseca] check called. History is: ${JSON.stringify(input.context.history)}`,
);
if (!this.config) {
if (!this.context) {
debugLogger.debug('[Conseca] check failed: Config not initialized');
return {
decision: SafetyCheckDecision.ALLOW,
@@ -67,7 +68,7 @@ export class ConsecaSafetyChecker implements InProcessChecker {
};
}
if (!this.config.enableConseca) {
if (!this.context.config.enableConseca) {
debugLogger.debug('[Conseca] check skipped: Conseca is not enabled.');
return {
decision: SafetyCheckDecision.ALLOW,
@@ -78,14 +79,14 @@ export class ConsecaSafetyChecker implements InProcessChecker {
const userPrompt = this.extractUserPrompt(input);
let trustedContent = '';
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
if (toolRegistry) {
const tools = toolRegistry.getFunctionDeclarations();
trustedContent = JSON.stringify(tools, null, 2);
}
if (userPrompt) {
await this.getPolicy(userPrompt, trustedContent, this.config);
await this.getPolicy(userPrompt, trustedContent, this.context.config);
} else {
debugLogger.debug(
`[Conseca] Skipping policy generation because userPrompt is null`,
@@ -104,12 +105,12 @@ export class ConsecaSafetyChecker implements InProcessChecker {
result = await enforcePolicy(
this.currentPolicy,
input.toolCall,
this.config,
this.context.config,
);
}
logConsecaVerdict(
this.config,
this.context.config,
new ConsecaVerdictEvent(
userPrompt || '',
JSON.stringify(this.currentPolicy || {}),
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContextBuilder } from './context-builder.js';
import type { Config } from '../config/config.js';
import type { Content, FunctionCall } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
describe('ContextBuilder', () => {
let contextBuilder: ContextBuilder;
@@ -20,15 +21,20 @@ describe('ContextBuilder', () => {
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
mockHistory = [];
const mockGeminiClient = {
getHistory: vi.fn().mockImplementation(() => mockHistory),
};
mockConfig = {
get config() {
return this as unknown as Config;
},
geminiClient: mockGeminiClient as unknown as GeminiClient,
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: vi.fn().mockReturnValue(mockWorkspaces),
}),
getQuestion: vi.fn().mockReturnValue('mock question'),
getGeminiClient: vi.fn().mockReturnValue({
getHistory: vi.fn().mockImplementation(() => mockHistory),
}),
};
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
} as Partial<Config>;
contextBuilder = new ContextBuilder(mockConfig as unknown as Config);
});
+5 -5
View File
@@ -5,21 +5,21 @@
*/
import type { SafetyCheckInput, ConversationTurn } from './protocol.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { Content, FunctionCall } from '@google/genai';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
/**
* Builds context objects for safety checkers, ensuring sensitive data is filtered.
*/
export class ContextBuilder {
constructor(private readonly config: Config) {}
constructor(private readonly context: AgentLoopContext) {}
/**
* Builds the full context object with all available data.
*/
buildFullContext(): SafetyCheckInput['context'] {
const clientHistory = this.config.getGeminiClient()?.getHistory() || [];
const clientHistory = this.context.geminiClient?.getHistory() || [];
const history = this.convertHistoryToTurns(clientHistory);
debugLogger.debug(
@@ -29,7 +29,7 @@ export class ContextBuilder {
// ContextBuilder's responsibility is to provide the *current* context.
// If the conversation hasn't started (history is empty), we check if there's a pending question.
// However, if the history is NOT empty, we trust it reflects the true state.
const currentQuestion = this.config.getQuestion();
const currentQuestion = this.context.config.getQuestion();
if (currentQuestion && history.length === 0) {
history.push({
user: {
@@ -43,7 +43,7 @@ export class ContextBuilder {
environment: {
cwd: process.cwd(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
workspaces: this.config
workspaces: this.context.config
.getWorkspaceContext()
.getDirectories() as string[],
},
+25 -6
View File
@@ -227,6 +227,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
@@ -254,6 +255,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -286,6 +288,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysAndSave,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -324,6 +327,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -362,12 +366,13 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysServer,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'my-server__*',
toolName: 'mcp_my-server_*',
mcpName: 'my-server',
persist: false,
}),
@@ -393,6 +398,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedOnce,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
@@ -418,6 +424,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.Cancel,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
@@ -442,6 +449,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ModifyWithEditor,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
@@ -474,6 +482,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysTool,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -513,6 +522,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlways,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -554,6 +564,7 @@ describe('policy.ts', () => {
ToolConfirmationOutcome.ProceedAlwaysAndSave,
details,
mockConfig,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -585,8 +596,8 @@ describe('policy.ts', () => {
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -615,8 +626,8 @@ describe('policy.ts', () => {
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -653,8 +664,8 @@ describe('policy.ts', () => {
details,
{
config: mockConfig,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
@@ -777,7 +788,11 @@ describe('Plan Mode Denial Consistency', () => {
if (enableEventDrivenScheduler) {
const scheduler = new Scheduler({
context: mockConfig,
context: {
config: mockConfig,
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as AgentLoopContext,
getPreferredEditor: () => undefined,
schedulerId: ROOT_SCHEDULER_ID,
});
@@ -793,7 +808,11 @@ describe('Plan Mode Denial Consistency', () => {
} else {
let capturedCalls: CompletedToolCall[] = [];
const scheduler = new CoreToolScheduler({
config: mockConfig,
context: {
config: mockConfig,
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as AgentLoopContext,
getPreferredEditor: () => undefined,
onAllToolCallsComplete: async (calls) => {
capturedCalls = calls;
+11 -11
View File
@@ -25,7 +25,7 @@ import {
} from '../tools/tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { makeRelative } from '../utils/paths.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { DiscoveredMCPTool, formatMcpToolName } from '../tools/mcp-tool.js';
import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import type { ValidatingToolCall } from './types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
@@ -114,13 +114,12 @@ export async function updatePolicy(
outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined,
context: AgentLoopContext,
messageBus: MessageBus,
toolInvocation?: AnyToolInvocation,
): Promise<void> {
const deps = { ...context, toolInvocation };
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
deps.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
context.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
return;
}
@@ -129,8 +128,9 @@ export async function updatePolicy(
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
// If folder is trusted and workspace policies are enabled, we prefer workspace scope.
if (
deps.config.isTrustedFolder() &&
deps.config.getWorkspacePoliciesDir() !== undefined
context.config &&
context.config.isTrustedFolder() &&
context.config.getWorkspacePoliciesDir() !== undefined
) {
persistScope = 'workspace';
} else {
@@ -144,7 +144,7 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.messageBus,
messageBus,
persistScope,
);
return;
@@ -155,10 +155,10 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.messageBus,
messageBus,
persistScope,
deps.toolInvocation,
deps.config,
toolInvocation,
context.config,
);
}
@@ -247,7 +247,7 @@ async function handleMcpPolicyUpdate(
// If "Always allow all tools from this server", use the wildcard pattern
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
toolName = `${confirmationDetails.serverName}__*`;
toolName = formatMcpToolName(confirmationDetails.serverName, '*');
}
await messageBus.publish({
@@ -845,6 +845,7 @@ describe('Scheduler (Orchestrator)', () => {
resolution.lastDetails,
mockConfig,
expect.anything(),
expect.anything(),
);
expect(mockExecutor.execute).toHaveBeenCalled();
+1
View File
@@ -623,6 +623,7 @@ export class Scheduler {
outcome,
lastDetails,
this.context,
this.messageBus,
toolCall.invocation,
);
}
@@ -172,6 +172,9 @@ describe('ChatCompressionService', () => {
} as unknown as GenerateContentResponse);
mockConfig = {
get config() {
return this;
},
getCompressionThreshold: vi.fn(),
getBaseLlmClient: vi.fn().mockReturnValue({
generateContent: mockGenerateContent,
@@ -43,6 +43,13 @@ describe('ChatRecordingService', () => {
);
mockConfig = {
get config() {
return this;
},
toolRegistry: {
getTool: vi.fn(),
},
promptId: 'test-session-id',
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
storage: {
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config } from '../config/config.js';
import { type Status } from '../core/coreToolScheduler.js';
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
import { getProjectHash } from '../utils/paths.js';
@@ -20,6 +19,7 @@ import type {
} from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js';
import type { ToolResultDisplay } from '../tools/tools.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export const SESSION_FILE_PREFIX = 'session-';
@@ -134,12 +134,12 @@ export class ChatRecordingService {
private kind?: 'main' | 'subagent';
private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = [];
private queuedTokens: TokensSummary | null = null;
private config: Config;
private context: AgentLoopContext;
constructor(config: Config) {
this.config = config;
this.sessionId = config.getSessionId();
this.projectHash = getProjectHash(config.getProjectRoot());
constructor(context: AgentLoopContext) {
this.context = context;
this.sessionId = context.promptId;
this.projectHash = getProjectHash(context.config.getProjectRoot());
}
/**
@@ -171,9 +171,9 @@ export class ChatRecordingService {
this.cachedConversation = null;
} else {
// Create new session
this.sessionId = this.config.getSessionId();
this.sessionId = this.context.promptId;
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
this.context.config.storage.getProjectTempDir(),
'chats',
);
fs.mkdirSync(chatsDir, { recursive: true });
@@ -341,7 +341,7 @@ export class ChatRecordingService {
if (!this.conversationFile) return;
// Enrich tool calls with metadata from the ToolRegistry
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
const enrichedToolCalls = toolCalls.map((toolCall) => {
const toolInstance = toolRegistry.getTool(toolCall.name);
return {
@@ -594,7 +594,7 @@ export class ChatRecordingService {
*/
deleteSession(sessionId: string): void {
try {
const tempDir = this.config.storage.getProjectTempDir();
const tempDir = this.context.config.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const sessionPath = path.join(chatsDir, `${sessionId}.json`);
if (fs.existsSync(sessionPath)) {
@@ -36,6 +36,9 @@ describe('LoopDetectionService', () => {
beforeEach(() => {
mockConfig = {
get config() {
return this;
},
getTelemetryEnabled: () => true,
isInteractive: () => false,
getDisableLoopDetection: () => false,
@@ -806,7 +809,13 @@ describe('LoopDetectionService LLM Checks', () => {
vi.mocked(mockAvailability.snapshot).mockReturnValue({ available: true });
mockConfig = {
get config() {
return this;
},
getGeminiClient: () => mockGeminiClient,
get geminiClient() {
return mockGeminiClient;
},
getBaseLlmClient: () => mockBaseLlmClient,
getDisableLoopDetection: () => false,
getDebugMode: () => false,
@@ -19,12 +19,12 @@ import {
LlmLoopCheckEvent,
LlmRole,
} from '../telemetry/types.js';
import type { Config } from '../config/config.js';
import {
isFunctionCall,
isFunctionResponse,
} from '../utils/messageInspectors.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
const TOOL_CALL_LOOP_THRESHOLD = 5;
const CONTENT_LOOP_THRESHOLD = 10;
@@ -131,7 +131,7 @@ export interface LoopDetectionResult {
* Monitors tool call repetitions and content sentence repetitions.
*/
export class LoopDetectionService {
private readonly config: Config;
private readonly context: AgentLoopContext;
private promptId = '';
private userPrompt = '';
@@ -157,8 +157,8 @@ export class LoopDetectionService {
// Session-level disable flag
private disabledForSession = false;
constructor(config: Config) {
this.config = config;
constructor(context: AgentLoopContext) {
this.context = context;
}
/**
@@ -167,7 +167,7 @@ export class LoopDetectionService {
disableForSession(): void {
this.disabledForSession = true;
logLoopDetectionDisabled(
this.config,
this.context.config,
new LoopDetectionDisabledEvent(this.promptId),
);
}
@@ -184,7 +184,10 @@ export class LoopDetectionService {
* @returns A LoopDetectionResult
*/
addAndCheck(event: ServerGeminiStreamEvent): LoopDetectionResult {
if (this.disabledForSession || this.config.getDisableLoopDetection()) {
if (
this.disabledForSession ||
this.context.config.getDisableLoopDetection()
) {
return { count: 0 };
}
if (this.loopDetected) {
@@ -228,7 +231,7 @@ export class LoopDetectionService {
: LoopType.CONTENT_CHANTING_LOOP;
logLoopDetected(
this.config,
this.context.config,
new LoopDetectedEvent(
this.lastLoopType,
this.promptId,
@@ -256,7 +259,10 @@ export class LoopDetectionService {
* @returns A promise that resolves to a LoopDetectionResult.
*/
async turnStarted(signal: AbortSignal): Promise<LoopDetectionResult> {
if (this.disabledForSession || this.config.getDisableLoopDetection()) {
if (
this.disabledForSession ||
this.context.config.getDisableLoopDetection()
) {
return { count: 0 };
}
if (this.loopDetected) {
@@ -283,7 +289,7 @@ export class LoopDetectionService {
this.lastLoopType = LoopType.LLM_DETECTED_LOOP;
logLoopDetected(
this.config,
this.context.config,
new LoopDetectedEvent(
this.lastLoopType,
this.promptId,
@@ -536,8 +542,7 @@ export class LoopDetectionService {
analysis?: string;
confirmedByModel?: string;
}> {
const recentHistory = this.config
.getGeminiClient()
const recentHistory = this.context.geminiClient
.getHistory()
.slice(-LLM_LOOP_CHECK_HISTORY_COUNT);
@@ -590,13 +595,13 @@ export class LoopDetectionService {
: '';
const doubleCheckModelName =
this.config.modelConfigService.getResolvedConfig({
this.context.config.modelConfigService.getResolvedConfig({
model: DOUBLE_CHECK_MODEL_ALIAS,
}).model;
if (flashConfidence < LLM_CONFIDENCE_THRESHOLD) {
logLlmLoopCheck(
this.config,
this.context.config,
new LlmLoopCheckEvent(
this.promptId,
flashConfidence,
@@ -608,12 +613,13 @@ export class LoopDetectionService {
return { isLoop: false };
}
const availability = this.config.getModelAvailabilityService();
const availability = this.context.config.getModelAvailabilityService();
if (!availability.snapshot(doubleCheckModelName).available) {
const flashModelName = this.config.modelConfigService.getResolvedConfig({
model: 'loop-detection',
}).model;
const flashModelName =
this.context.config.modelConfigService.getResolvedConfig({
model: 'loop-detection',
}).model;
return {
isLoop: true,
analysis: flashAnalysis,
@@ -642,7 +648,7 @@ export class LoopDetectionService {
: undefined;
logLlmLoopCheck(
this.config,
this.context.config,
new LlmLoopCheckEvent(
this.promptId,
flashConfidence,
@@ -672,7 +678,7 @@ export class LoopDetectionService {
signal: AbortSignal,
): Promise<Record<string, unknown> | null> {
try {
const result = await this.config.getBaseLlmClient().generateJson({
const result = await this.context.config.getBaseLlmClient().generateJson({
modelConfigKey: { model },
contents,
schema: LOOP_DETECTION_SCHEMA,
@@ -692,7 +698,7 @@ export class LoopDetectionService {
}
return null;
} catch (error) {
if (this.config.getDebugMode()) {
if (this.context.config.getDebugMode()) {
debugLogger.warn(
`Error querying loop detection model (${model}): ${String(error)}`,
);
@@ -51,11 +51,34 @@ export interface ModelConfigAlias {
modelConfig: ModelConfig;
}
// A model definition is a mapping from a model name to a list of features
// that the model supports. Model names can be either direct model IDs
// (gemini-2.5-pro) or aliases (auto).
export interface ModelDefinition {
displayName?: string;
tier?: string; // 'pro' | 'flash' | 'flash-lite' | 'custom' | 'auto'
family?: string; // The gemini family, e.g. 'gemini-3' | 'gemini-2'
isPreview?: boolean;
// Specifies which view the model should appear in. If unset, the model will
// not appear in the dialog.
dialogLocation?: 'main' | 'manual';
/** A short description of the model for the dialog. */
dialogDescription?: string;
features?: {
// Whether the model supports thinking.
thinking?: boolean;
// Whether the model supports mutlimodal function responses. This is
// supported in Gemini 3.
multimodalToolUse?: boolean;
};
}
export interface ModelConfigServiceConfig {
aliases?: Record<string, ModelConfigAlias>;
customAliases?: Record<string, ModelConfigAlias>;
overrides?: ModelConfigOverride[];
customOverrides?: ModelConfigOverride[];
modelDefinitions?: Record<string, ModelDefinition>;
}
const MAX_ALIAS_CHAIN_DEPTH = 100;
@@ -76,6 +99,28 @@ export class ModelConfigService {
// TODO(12597): Process config to build a typed alias hierarchy.
constructor(private readonly config: ModelConfigServiceConfig) {}
getModelDefinition(modelId: string): ModelDefinition | undefined {
const definition = this.config.modelDefinitions?.[modelId];
if (definition) {
return definition;
}
// For unknown models, return an implicit custom definition to match legacy behavior.
if (!modelId.startsWith('gemini-')) {
return {
tier: 'custom',
family: 'custom',
features: {},
};
}
return undefined;
}
getModelDefinitions(): Record<string, ModelDefinition> {
return this.config.modelDefinitions ?? {};
}
registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void {
this.runtimeAliases[aliasName] = alias;
}
@@ -47,6 +47,9 @@ describe('Tool Confirmation Policy Updates', () => {
} as unknown as MessageBus;
mockConfig = {
get config() {
return this;
},
getTargetDir: () => rootDir,
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
+68
View File
@@ -33,6 +33,14 @@ vi.mock('../utils/editor.js', () => ({
openDiff: mockOpenDiff,
}));
vi.mock('./jit-context.js', () => ({
discoverJitContext: vi.fn().mockResolvedValue(''),
appendJitContext: vi.fn().mockImplementation((content, context) => {
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
}),
}));
import {
describe,
it,
@@ -1231,4 +1239,64 @@ function doIt() {
expect(mockFixLLMEditWithInstruction).toHaveBeenCalled();
});
});
describe('JIT context discovery', () => {
it('should append JIT context to output when enabled and context is found', async () => {
const { discoverJitContext, appendJitContext } = await import(
'./jit-context.js'
);
vi.mocked(discoverJitContext).mockResolvedValue('Use the useAuth hook.');
vi.mocked(appendJitContext).mockImplementation((content, context) => {
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
});
const filePath = path.join(rootDir, 'jit-edit-test.txt');
const initialContent = 'some old text here';
fs.writeFileSync(filePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace old with new',
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(discoverJitContext).toHaveBeenCalled();
expect(result.llmContent).toContain('Newly Discovered Project Context');
expect(result.llmContent).toContain('Use the useAuth hook.');
});
it('should not append JIT context when disabled', async () => {
const { discoverJitContext, appendJitContext } = await import(
'./jit-context.js'
);
vi.mocked(discoverJitContext).mockResolvedValue('');
vi.mocked(appendJitContext).mockImplementation((content, context) => {
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
});
const filePath = path.join(rootDir, 'jit-disabled-edit-test.txt');
const initialContent = 'some old text here';
fs.writeFileSync(filePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace old with new',
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).not.toContain(
'Newly Discovered Project Context',
);
});
});
});
+12 -1
View File
@@ -57,6 +57,7 @@ import levenshtein from 'fast-levenshtein';
import { EDIT_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
const ENABLE_FUZZY_MATCH_RECOVERY = true;
const FUZZY_MATCH_THRESHOLD = 0.1; // Allow up to 10% weighted difference
@@ -937,8 +938,18 @@ ${snippet}`);
);
}
// Discover JIT subdirectory context for the edited file path
const jitContext = await discoverJitContext(
this.config,
this.resolvedPath,
);
let llmContent = llmSuccessMessageParts.join(' ');
if (jitContext) {
llmContent = appendJitContext(llmContent, jitContext);
}
return {
llmContent: llmSuccessMessageParts.join(' '),
llmContent,
returnDisplay: displayResult,
};
} catch (error) {
+131
View File
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { discoverJitContext, appendJitContext } from './jit-context.js';
import type { Config } from '../config/config.js';
import type { ContextManager } from '../services/contextManager.js';
describe('jit-context', () => {
describe('discoverJitContext', () => {
let mockConfig: Config;
let mockContextManager: ContextManager;
beforeEach(() => {
mockContextManager = {
discoverContext: vi.fn().mockResolvedValue(''),
} as unknown as ContextManager;
mockConfig = {
isJitContextEnabled: vi.fn().mockReturnValue(false),
getContextManager: vi.fn().mockReturnValue(mockContextManager),
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: vi.fn().mockReturnValue(['/app']),
}),
} as unknown as Config;
});
it('should return empty string when JIT is disabled', async () => {
vi.mocked(mockConfig.isJitContextEnabled).mockReturnValue(false);
const result = await discoverJitContext(mockConfig, '/app/src/file.ts');
expect(result).toBe('');
expect(mockContextManager.discoverContext).not.toHaveBeenCalled();
});
it('should return empty string when contextManager is undefined', async () => {
vi.mocked(mockConfig.isJitContextEnabled).mockReturnValue(true);
vi.mocked(mockConfig.getContextManager).mockReturnValue(undefined);
const result = await discoverJitContext(mockConfig, '/app/src/file.ts');
expect(result).toBe('');
});
it('should call contextManager.discoverContext with correct args when JIT is enabled', async () => {
vi.mocked(mockConfig.isJitContextEnabled).mockReturnValue(true);
vi.mocked(mockContextManager.discoverContext).mockResolvedValue(
'Subdirectory context content',
);
const result = await discoverJitContext(mockConfig, '/app/src/file.ts');
expect(mockContextManager.discoverContext).toHaveBeenCalledWith(
'/app/src/file.ts',
['/app'],
);
expect(result).toBe('Subdirectory context content');
});
it('should pass all workspace directories as trusted roots', async () => {
vi.mocked(mockConfig.isJitContextEnabled).mockReturnValue(true);
vi.mocked(mockConfig.getWorkspaceContext).mockReturnValue({
getDirectories: vi.fn().mockReturnValue(['/app', '/lib']),
} as unknown as ReturnType<Config['getWorkspaceContext']>);
vi.mocked(mockContextManager.discoverContext).mockResolvedValue('');
await discoverJitContext(mockConfig, '/app/src/file.ts');
expect(mockContextManager.discoverContext).toHaveBeenCalledWith(
'/app/src/file.ts',
['/app', '/lib'],
);
});
it('should return empty string when no new context is found', async () => {
vi.mocked(mockConfig.isJitContextEnabled).mockReturnValue(true);
vi.mocked(mockContextManager.discoverContext).mockResolvedValue('');
const result = await discoverJitContext(mockConfig, '/app/src/file.ts');
expect(result).toBe('');
});
it('should return empty string when discoverContext throws', async () => {
vi.mocked(mockConfig.isJitContextEnabled).mockReturnValue(true);
vi.mocked(mockContextManager.discoverContext).mockRejectedValue(
new Error('Permission denied'),
);
const result = await discoverJitContext(mockConfig, '/app/src/file.ts');
expect(result).toBe('');
});
});
describe('appendJitContext', () => {
it('should return original content when jitContext is empty', () => {
const content = 'file contents here';
const result = appendJitContext(content, '');
expect(result).toBe(content);
});
it('should append delimited context when jitContext is non-empty', () => {
const content = 'file contents here';
const jitContext = 'Use the useAuth hook.';
const result = appendJitContext(content, jitContext);
expect(result).toContain(content);
expect(result).toContain('--- Newly Discovered Project Context ---');
expect(result).toContain(jitContext);
expect(result).toContain('--- End Project Context ---');
});
it('should place context after the original content', () => {
const content = 'original output';
const jitContext = 'context rules';
const result = appendJitContext(content, jitContext);
const contentIndex = result.indexOf(content);
const contextIndex = result.indexOf(jitContext);
expect(contentIndex).toBeLessThan(contextIndex);
});
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
/**
* Discovers and returns JIT (Just-In-Time) subdirectory context for a given
* file or directory path. This is used by "high-intent" tools (read_file,
* list_directory, write_file, replace, read_many_files) to dynamically load
* GEMINI.md context files from subdirectories when the agent accesses them.
*
* @param config - The runtime configuration.
* @param accessedPath - The absolute path being accessed by the tool.
* @returns The discovered context string, or empty string if none found or JIT is disabled.
*/
export async function discoverJitContext(
config: Config,
accessedPath: string,
): Promise<string> {
if (!config.isJitContextEnabled?.()) {
return '';
}
const contextManager = config.getContextManager();
if (!contextManager) {
return '';
}
const trustedRoots = [...config.getWorkspaceContext().getDirectories()];
try {
return await contextManager.discoverContext(accessedPath, trustedRoots);
} catch {
// JIT context is supplementary — never fail the tool's primary operation.
return '';
}
}
/**
* Format string to delimit JIT context in tool output.
*/
export const JIT_CONTEXT_PREFIX =
'\n\n--- Newly Discovered Project Context ---\n';
export const JIT_CONTEXT_SUFFIX = '\n--- End Project Context ---';
/**
* Appends JIT context to tool LLM content if any was discovered.
* Returns the original content unchanged if no context was found.
*
* @param llmContent - The original tool output content.
* @param jitContext - The discovered JIT context string.
* @returns The content with JIT context appended, or unchanged if empty.
*/
export function appendJitContext(
llmContent: string,
jitContext: string,
): string {
if (!jitContext) {
return llmContent;
}
return `${llmContent}${JIT_CONTEXT_PREFIX}${jitContext}${JIT_CONTEXT_SUFFIX}`;
}
+41
View File
@@ -17,6 +17,14 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
vi.mock('./jit-context.js', () => ({
discoverJitContext: vi.fn().mockResolvedValue(''),
appendJitContext: vi.fn().mockImplementation((content, context) => {
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
}),
}));
describe('LSTool', () => {
let lsTool: LSTool;
let tempRootDir: string;
@@ -342,4 +350,37 @@ describe('LSTool', () => {
expect(result.returnDisplay).toBe('Listed 1 item(s).');
});
});
describe('JIT context discovery', () => {
it('should append JIT context to output when enabled and context is found', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('Use the useAuth hook.');
await fs.writeFile(path.join(tempRootDir, 'jit-file.txt'), 'content');
const invocation = lsTool.build({ dir_path: tempRootDir });
const result = await invocation.execute(abortSignal);
expect(discoverJitContext).toHaveBeenCalled();
expect(result.llmContent).toContain('Newly Discovered Project Context');
expect(result.llmContent).toContain('Use the useAuth hook.');
});
it('should not append JIT context when disabled', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('');
await fs.writeFile(
path.join(tempRootDir, 'jit-disabled-file.txt'),
'content',
);
const invocation = lsTool.build({ dir_path: tempRootDir });
const result = await invocation.execute(abortSignal);
expect(result.llmContent).not.toContain(
'Newly Discovered Project Context',
);
});
});
});
+7
View File
@@ -25,6 +25,7 @@ import { buildDirPathArgsPattern } from '../policy/utils.js';
import { debugLogger } from '../utils/debugLogger.js';
import { LS_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
/**
* Parameters for the LS tool
@@ -270,6 +271,12 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
resultMessage += `\n\n(${ignoredCount} ignored)`;
}
// Discover JIT subdirectory context for the listed directory
const jitContext = await discoverJitContext(this.config, resolvedDirPath);
if (jitContext) {
resultMessage = appendJitContext(resultMessage, jitContext);
}
let displayMessage = `Listed ${entries.length} item(s).`;
if (ignoredCount > 0) {
displayMessage += ` (${ignoredCount} ignored)`;
+2 -2
View File
@@ -302,7 +302,7 @@ export class McpClient implements McpProgressReporter {
this.serverConfig,
this.client!,
cliConfig,
this.toolRegistry.getMessageBus(),
this.toolRegistry.messageBus,
{
...(options ?? {
timeout: this.serverConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
@@ -1167,7 +1167,7 @@ export async function connectAndDiscover(
mcpServerConfig,
mcpClient,
cliConfig,
toolRegistry.getMessageBus(),
toolRegistry.messageBus,
{ timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC },
);
+4 -1
View File
@@ -188,7 +188,10 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
override getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
return { mcpName: this.serverName };
return {
mcpName: this.serverName,
toolName: this.serverToolName,
};
}
protected override async getConfirmationDetails(
+42
View File
@@ -24,6 +24,14 @@ vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
vi.mock('./jit-context.js', () => ({
discoverJitContext: vi.fn().mockResolvedValue(''),
appendJitContext: vi.fn().mockImplementation((content, context) => {
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
}),
}));
describe('ReadFileTool', () => {
let tempRootDir: string;
let tool: ReadFileTool;
@@ -596,4 +604,38 @@ describe('ReadFileTool', () => {
expect(schema.description).toContain('surgical reads');
});
});
describe('JIT context discovery', () => {
it('should append JIT context to output when enabled and context is found', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('Use the useAuth hook.');
const filePath = path.join(tempRootDir, 'jit-test.txt');
const fileContent = 'JIT test content.';
await fsp.writeFile(filePath, fileContent, 'utf-8');
const invocation = tool.build({ file_path: filePath });
const result = await invocation.execute(abortSignal);
expect(discoverJitContext).toHaveBeenCalled();
expect(result.llmContent).toContain('Newly Discovered Project Context');
expect(result.llmContent).toContain('Use the useAuth hook.');
});
it('should not append JIT context when disabled', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('');
const filePath = path.join(tempRootDir, 'jit-disabled-test.txt');
const fileContent = 'No JIT content.';
await fsp.writeFile(filePath, fileContent, 'utf-8');
const invocation = tool.build({ file_path: filePath });
const result = await invocation.execute(abortSignal);
expect(result.llmContent).not.toContain(
'Newly Discovered Project Context',
);
});
});
});
+7
View File
@@ -34,6 +34,7 @@ import { READ_FILE_TOOL_NAME, READ_FILE_DISPLAY_NAME } from './tool-names.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { READ_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
/**
* Parameters for the ReadFile tool
@@ -170,6 +171,12 @@ ${result.llmContent}`;
),
);
// Discover JIT subdirectory context for the accessed file path
const jitContext = await discoverJitContext(this.config, this.resolvedPath);
if (jitContext && typeof llmContent === 'string') {
llmContent = appendJitContext(llmContent, jitContext);
}
return {
llmContent,
returnDisplay: result.returnDisplay || '',
@@ -65,6 +65,16 @@ vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
vi.mock('./jit-context.js', () => ({
discoverJitContext: vi.fn().mockResolvedValue(''),
appendJitContext: vi.fn().mockImplementation((content, context) => {
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
}),
JIT_CONTEXT_PREFIX: '\n\n--- Newly Discovered Project Context ---\n',
JIT_CONTEXT_SUFFIX: '\n--- End Project Context ---',
}));
describe('ReadManyFilesTool', () => {
let tool: ReadManyFilesTool;
let tempRootDir: string;
@@ -809,4 +819,46 @@ Content of file[1]
detectFileTypeSpy.mockRestore();
});
});
describe('JIT context discovery', () => {
it('should append JIT context to output when enabled and context is found', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('Use the useAuth hook.');
fs.writeFileSync(
path.join(tempRootDir, 'jit-test.ts'),
'const x = 1;',
'utf8',
);
const invocation = tool.build({ include: ['jit-test.ts'] });
const result = await invocation.execute(new AbortController().signal);
expect(discoverJitContext).toHaveBeenCalled();
const llmContent = Array.isArray(result.llmContent)
? result.llmContent.join('')
: String(result.llmContent);
expect(llmContent).toContain('Newly Discovered Project Context');
expect(llmContent).toContain('Use the useAuth hook.');
});
it('should not append JIT context when disabled', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('');
fs.writeFileSync(
path.join(tempRootDir, 'jit-disabled-test.ts'),
'const y = 2;',
'utf8',
);
const invocation = tool.build({ include: ['jit-disabled-test.ts'] });
const result = await invocation.execute(new AbortController().signal);
const llmContent = Array.isArray(result.llmContent)
? result.llmContent.join('')
: String(result.llmContent);
expect(llmContent).not.toContain('Newly Discovered Project Context');
});
});
});
@@ -41,6 +41,11 @@ import { READ_MANY_FILES_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { REFERENCE_CONTENT_END } from '../utils/constants.js';
import {
discoverJitContext,
JIT_CONTEXT_PREFIX,
JIT_CONTEXT_SUFFIX,
} from './jit-context.js';
/**
* Parameters for the ReadManyFilesTool.
@@ -411,6 +416,20 @@ ${finalExclusionPatternsForDescription
}
}
// Discover JIT subdirectory context for all unique directories of processed files
const uniqueDirs = new Set(
Array.from(filesToConsider).map((f) => path.dirname(f)),
);
const jitResults = await Promise.all(
Array.from(uniqueDirs).map((dir) => discoverJitContext(this.config, dir)),
);
const jitParts = jitResults.filter(Boolean);
if (jitParts.length > 0) {
contentParts.push(
`${JIT_CONTEXT_PREFIX}${jitParts.join('\n')}${JIT_CONTEXT_SUFFIX}`,
);
}
let displayMessage = `### ReadManyFiles Result (Target Dir: \`${this.config.getTargetDir()}\`)\n\n`;
if (processedFilesRelativePaths.length > 0) {
displayMessage += `Successfully read and concatenated content from **${processedFilesRelativePaths.length} file(s)**.\n`;
+8 -1
View File
@@ -94,6 +94,13 @@ describe('ShellTool', () => {
fs.mkdirSync(path.join(tempRootDir, 'subdir'));
mockConfig = {
get config() {
return this;
},
geminiClient: {
stripThoughtsFromHistory: vi.fn(),
},
getAllowedTools: vi.fn().mockReturnValue([]),
getApprovalMode: vi.fn().mockReturnValue('strict'),
getCoreTools: vi.fn().mockReturnValue([]),
@@ -441,7 +448,7 @@ describe('ShellTool', () => {
mockConfig,
{ model: 'summarizer-shell' },
expect.any(String),
mockConfig.getGeminiClient(),
mockConfig.geminiClient,
mockAbortSignal,
);
expect(result.llmContent).toBe('summarized output');
+21 -20
View File
@@ -8,7 +8,6 @@ import fsPromises from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { debugLogger } from '../index.js';
import { ToolErrorType } from './tool-error.js';
import {
@@ -45,6 +44,7 @@ import { SHELL_TOOL_NAME } from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
@@ -63,7 +63,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
ToolResult
> {
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
params: ShellToolParams,
messageBus: MessageBus,
_toolName?: string,
@@ -168,7 +168,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
.toString('hex')}.tmp`;
const tempFilePath = path.join(os.tmpdir(), tempFileName);
const timeoutMs = this.config.getShellToolInactivityTimeout();
const timeoutMs = this.context.config.getShellToolInactivityTimeout();
const timeoutController = new AbortController();
let timeoutTimer: NodeJS.Timeout | undefined;
@@ -189,10 +189,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
})();
const cwd = this.params.dir_path
? path.resolve(this.config.getTargetDir(), this.params.dir_path)
: this.config.getTargetDir();
? path.resolve(this.context.config.getTargetDir(), this.params.dir_path)
: this.context.config.getTargetDir();
const validationError = this.config.validatePathAccess(cwd);
const validationError = this.context.config.validatePathAccess(cwd);
if (validationError) {
return {
llmContent: validationError,
@@ -271,13 +271,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
},
combinedController.signal,
this.config.getEnableInteractiveShell(),
this.context.config.getEnableInteractiveShell(),
{
...shellExecutionConfig,
pager: 'cat',
sanitizationConfig:
shellExecutionConfig?.sanitizationConfig ??
this.config.sanitizationConfig,
this.context.config.sanitizationConfig,
},
);
@@ -382,7 +382,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
let returnDisplayMessage = '';
if (this.config.getDebugMode()) {
if (this.context.config.getDebugMode()) {
returnDisplayMessage = llmContent;
} else {
if (this.params.is_background || result.backgrounded) {
@@ -411,7 +411,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
const summarizeConfig = this.config.getSummarizeToolOutputConfig();
const summarizeConfig =
this.context.config.getSummarizeToolOutputConfig();
const executionError = result.error
? {
error: {
@@ -422,10 +423,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
: {};
if (summarizeConfig && summarizeConfig[SHELL_TOOL_NAME]) {
const summary = await summarizeToolOutput(
this.config,
this.context.config,
{ model: 'summarizer-shell' },
llmContent,
this.config.getGeminiClient(),
this.context.geminiClient,
signal,
);
return {
@@ -461,15 +462,15 @@ export class ShellTool extends BaseDeclarativeTool<
static readonly Name = SHELL_TOOL_NAME;
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
messageBus: MessageBus,
) {
void initializeShellParsers().catch(() => {
// Errors are surfaced when parsing commands.
});
const definition = getShellDefinition(
config.getEnableInteractiveShell(),
config.getEnableShellOutputEfficiency(),
context.config.getEnableInteractiveShell(),
context.config.getEnableShellOutputEfficiency(),
);
super(
ShellTool.Name,
@@ -492,10 +493,10 @@ export class ShellTool extends BaseDeclarativeTool<
if (params.dir_path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
this.context.config.getTargetDir(),
params.dir_path,
);
return this.config.validatePathAccess(resolvedPath);
return this.context.config.validatePathAccess(resolvedPath);
}
return null;
}
@@ -507,7 +508,7 @@ export class ShellTool extends BaseDeclarativeTool<
_toolDisplayName?: string,
): ToolInvocation<ShellToolParams, ToolResult> {
return new ShellToolInvocation(
this.config,
this.context.config,
params,
messageBus,
_toolName,
@@ -517,8 +518,8 @@ export class ShellTool extends BaseDeclarativeTool<
override getSchema(modelId?: string) {
const definition = getShellDefinition(
this.config.getEnableInteractiveShell(),
this.config.getEnableShellOutputEfficiency(),
this.context.config.getEnableInteractiveShell(),
this.context.config.getEnableShellOutputEfficiency(),
);
return resolveToolDeclaration(definition, modelId);
}
+1 -1
View File
@@ -201,7 +201,7 @@ export class ToolRegistry {
// and `isActive` to get only the active tools.
private allKnownTools: Map<string, AnyDeclarativeTool> = new Map();
private config: Config;
private messageBus: MessageBus;
readonly messageBus: MessageBus;
constructor(config: Config, messageBus: MessageBus) {
this.config = config;
+1
View File
@@ -122,6 +122,7 @@ export interface PolicyUpdateOptions {
argsPattern?: string;
commandPrefix?: string | string[];
mcpName?: string;
toolName?: string;
}
/**
@@ -277,6 +277,12 @@ describe('WebFetchTool', () => {
setApprovalMode: vi.fn(),
getProxy: vi.fn(),
getGeminiClient: mockGetGeminiClient,
get config() {
return this;
},
get geminiClient() {
return mockGetGeminiClient();
},
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getMaxAttempts: vi.fn().mockReturnValue(3),
getDirectWebFetch: vi.fn().mockReturnValue(false),
+16 -16
View File
@@ -18,7 +18,6 @@ import { buildParamArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { getResponseText } from '../utils/partUtils.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
@@ -38,6 +37,7 @@ import { retryWithBackoff, getRetryErrorType } from '../utils/retry.js';
import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { LRUCache } from 'mnemonist';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
@@ -213,7 +213,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
ToolResult
> {
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
params: WebFetchToolParams,
messageBus: MessageBus,
_toolName?: string,
@@ -223,7 +223,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
}
private handleRetry(attempt: number, error: unknown, delayMs: number): void {
const maxAttempts = this.config.getMaxAttempts();
const maxAttempts = this.context.config.getMaxAttempts();
const modelName = 'Web Fetch';
const errorType = getRetryErrorType(error);
@@ -236,7 +236,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
});
logNetworkRetryAttempt(
this.config,
this.context.config,
new NetworkRetryAttemptEvent(
attempt,
maxAttempts,
@@ -290,7 +290,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
return res;
},
{
retryFetchErrors: this.config.getRetryFetchErrors(),
retryFetchErrors: this.context.config.getRetryFetchErrors(),
onRetry: (attempt, error, delayMs) =>
this.handleRetry(attempt, error, delayMs),
signal,
@@ -342,7 +342,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
`[WebFetchTool] Skipped private or local host: ${url}`,
);
logWebFetchFallbackAttempt(
this.config,
this.context.config,
new WebFetchFallbackAttemptEvent('private_ip_skipped'),
);
skipped.push(`[Blocked Host] ${url}`);
@@ -379,7 +379,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<
.join('\n\n---\n\n');
try {
const geminiClient = this.config.getGeminiClient();
const geminiClient = this.context.geminiClient;
const fallbackPrompt = `The user requested the following: "${this.params.prompt}".
I was unable to access the URL(s) directly using the primary fetch tool. Instead, I have fetched the raw content of the page(s). Please use the following content to answer the request. Do not attempt to access the URL(s) again.
@@ -458,7 +458,7 @@ ${aggregatedContent}
): Promise<ToolCallConfirmationDetails | false> {
// Check for AUTO_EDIT approval mode. This tool has a specific behavior
// where ProceedAlways switches the entire session to AUTO_EDIT.
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
if (this.context.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
@@ -581,7 +581,7 @@ ${aggregatedContent}
return res;
},
{
retryFetchErrors: this.config.getRetryFetchErrors(),
retryFetchErrors: this.context.config.getRetryFetchErrors(),
onRetry: (attempt, error, delayMs) =>
this.handleRetry(attempt, error, delayMs),
signal,
@@ -692,7 +692,7 @@ Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response trun
}
async execute(signal: AbortSignal): Promise<ToolResult> {
if (this.config.getDirectWebFetch()) {
if (this.context.config.getDirectWebFetch()) {
return this.executeExperimental(signal);
}
const userPrompt = this.params.prompt!;
@@ -715,7 +715,7 @@ Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response trun
}
try {
const geminiClient = this.config.getGeminiClient();
const geminiClient = this.context.geminiClient;
const response = await geminiClient.generateContent(
{ model: 'web-fetch' },
[{ role: 'user', parts: [{ text: userPrompt }] }],
@@ -797,7 +797,7 @@ Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response trun
`[WebFetchTool] Primary fetch failed, falling back: ${getErrorMessage(error)}`,
);
logWebFetchFallbackAttempt(
this.config,
this.context.config,
new WebFetchFallbackAttemptEvent('primary_failed'),
);
// Simple All-or-Nothing Fallback
@@ -816,7 +816,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
static readonly Name = WEB_FETCH_TOOL_NAME;
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
messageBus: MessageBus,
) {
super(
@@ -834,7 +834,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
protected override validateToolParamValues(
params: WebFetchToolParams,
): string | null {
if (this.config.getDirectWebFetch()) {
if (this.context.config.getDirectWebFetch()) {
if (!params.url) {
return "The 'url' parameter is required.";
}
@@ -870,7 +870,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
_toolDisplayName?: string,
): ToolInvocation<WebFetchToolParams, ToolResult> {
return new WebFetchToolInvocation(
this.config,
this.context.config,
params,
messageBus,
_toolName,
@@ -880,7 +880,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
override getSchema(modelId?: string) {
const schema = resolveToolDeclaration(WEB_FETCH_DEFINITION, modelId);
if (this.config.getDirectWebFetch()) {
if (this.context.config.getDirectWebFetch()) {
return {
...schema,
description:
@@ -31,6 +31,9 @@ describe('WebSearchTool', () => {
beforeEach(() => {
const mockConfigInstance = {
getGeminiClient: () => mockGeminiClient,
get geminiClient() {
return mockGeminiClient;
},
getProxy: () => undefined,
generationConfigService: {
getResolvedConfig: vi.fn().mockImplementation(({ model }) => ({
+5 -5
View File
@@ -17,12 +17,12 @@ import {
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage, isAbortError } from '../utils/errors.js';
import { type Config } from '../config/config.js';
import { getResponseText } from '../utils/partUtils.js';
import { debugLogger } from '../utils/debugLogger.js';
import { WEB_SEARCH_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { LlmRole } from '../telemetry/llmRole.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
interface GroundingChunkWeb {
uri?: string;
@@ -71,7 +71,7 @@ class WebSearchToolInvocation extends BaseToolInvocation<
WebSearchToolResult
> {
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
params: WebSearchToolParams,
messageBus: MessageBus,
_toolName?: string,
@@ -85,7 +85,7 @@ class WebSearchToolInvocation extends BaseToolInvocation<
}
async execute(signal: AbortSignal): Promise<WebSearchToolResult> {
const geminiClient = this.config.getGeminiClient();
const geminiClient = this.context.geminiClient;
try {
const response = await geminiClient.generateContent(
@@ -207,7 +207,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
static readonly Name = WEB_SEARCH_TOOL_NAME;
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
messageBus: MessageBus,
) {
super(
@@ -243,7 +243,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
_toolDisplayName?: string,
): ToolInvocation<WebSearchToolParams, WebSearchToolResult> {
return new WebSearchToolInvocation(
this.config,
this.context.config,
params,
messageBus ?? this.messageBus,
_toolName,
@@ -115,6 +115,14 @@ vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
vi.mock('./jit-context.js', () => ({
discoverJitContext: vi.fn().mockResolvedValue(''),
appendJitContext: vi.fn().mockImplementation((content, context) => {
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
}),
}));
// --- END MOCKS ---
describe('WriteFileTool', () => {
@@ -1065,4 +1073,42 @@ describe('WriteFileTool', () => {
expect(result.fileExists).toBe(true);
});
});
describe('JIT context discovery', () => {
const abortSignal = new AbortController().signal;
it('should append JIT context to output when enabled and context is found', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('Use the useAuth hook.');
const filePath = path.join(rootDir, 'jit-write-test.txt');
const content = 'JIT test content.';
mockEnsureCorrectFileContent.mockResolvedValue(content);
const params = { file_path: filePath, content };
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(discoverJitContext).toHaveBeenCalled();
expect(result.llmContent).toContain('Newly Discovered Project Context');
expect(result.llmContent).toContain('Use the useAuth hook.');
});
it('should not append JIT context when disabled', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue('');
const filePath = path.join(rootDir, 'jit-disabled-write-test.txt');
const content = 'No JIT content.';
mockEnsureCorrectFileContent.mockResolvedValue(content);
const params = { file_path: filePath, content };
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).not.toContain(
'Newly Discovered Project Context',
);
});
});
});

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