mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 03:00:58 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f32ff7d6 | |||
| 1693afe027 | |||
| d179e3673b | |||
| e4d6a5cc7c | |||
| fc4fdc3cbe | |||
| bdbb996a2e | |||
| 2d05396dd2 | |||
| 7b4a822b0e | |||
| d44615ac2f | |||
| de656f01d7 | |||
| 1d2585dba6 | |||
| 97bc3f28c5 | |||
| 3038fdce2e | |||
| bb060d7a98 | |||
| 9a73aa4072 | |||
| d7d53981f3 | |||
| 19e0b1ff7d |
@@ -391,4 +391,3 @@ instructions.
|
||||
<p align="center">
|
||||
Built with ❤️ by Google and the open source community
|
||||
</p>
|
||||
unrelated change
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +315,7 @@ export interface ConversationOffered {
|
||||
streamingLatency?: StreamingLatency;
|
||||
isAgentic?: boolean;
|
||||
initiationMethod?: InitiationMethod;
|
||||
trajectoryId?: string;
|
||||
}
|
||||
|
||||
export interface StreamingLatency {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}.');
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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[],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)`;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user