diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c54b557052..32ef02904e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -67,6 +67,7 @@ import { LspTool } from '../tools/lsp.js'; import { CronCreateTool } from '../tools/cron-create.js'; import { CronListTool } from '../tools/cron-list.js'; import { CronDeleteTool } from '../tools/cron-delete.js'; +import { ConfigTool } from '../tools/config-tool.js'; import type { LspClient } from '../lsp/types.js'; // Other modules @@ -2232,6 +2233,8 @@ export class Config { await registerCoreTool(CronDeleteTool, this); } + await registerCoreTool(ConfigTool, this); + if (!options?.skipDiscovery) { await registry.discoverAllTools(); } diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 3f38347f90..8768c858e1 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -416,6 +416,7 @@ export class PermissionManager { 'cron_create', 'cron_list', 'cron_delete', + 'config', ]); /** diff --git a/packages/core/src/tools/config-tool.test.ts b/packages/core/src/tools/config-tool.test.ts new file mode 100644 index 0000000000..ae0256e05c --- /dev/null +++ b/packages/core/src/tools/config-tool.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ConfigTool } from './config-tool.js'; +import type { Config } from '../config/config.js'; + +function makeConfig(currentModel = 'qwen-coder-plus') { + let model = currentModel; + return { + getModel: vi.fn(() => model), + setModel: vi.fn(async (newModel: string) => { + model = newModel; + }), + getAvailableModels: vi.fn(() => [ + { id: 'qwen-coder-plus', label: 'Qwen Coder Plus', authType: 'api-key' }, + { id: 'qwen3-coder', label: 'Qwen3 Coder', authType: 'api-key' }, + ]), + } as unknown as Config; +} + +describe('ConfigTool', () => { + let config: ReturnType; + let tool: ConfigTool; + + beforeEach(() => { + config = makeConfig(); + tool = new ConfigTool(config); + }); + + it('has the correct name and display name', () => { + expect(tool.name).toBe('config'); + expect(tool.displayName).toBe('Config'); + }); + + describe('validation', () => { + it('rejects unknown setting', () => { + expect(() => + tool.build({ action: 'get', setting: 'nonexistent' }), + ).toThrow(/Unknown setting.*nonexistent/); + }); + + it('rejects SET without value', () => { + expect(() => tool.build({ action: 'set', setting: 'model' })).toThrow( + /Value is required/, + ); + }); + + it('rejects SET with empty string value', () => { + expect(() => + tool.build({ action: 'set', setting: 'model', value: '' }), + ).toThrow(/Value is required/); + }); + + it('rejects SET with whitespace-only value', () => { + expect(() => + tool.build({ action: 'set', setting: 'model', value: ' ' }), + ).toThrow(/Value is required/); + }); + + it('rejects prototype chain keys like toString', () => { + expect(() => tool.build({ action: 'get', setting: 'toString' })).toThrow( + /Unknown setting.*toString/, + ); + }); + + it('rejects __proto__ as setting name', () => { + expect(() => tool.build({ action: 'get', setting: '__proto__' })).toThrow( + /Unknown setting/, + ); + }); + + it('accepts valid GET params', () => { + expect(() => + tool.build({ action: 'get', setting: 'model' }), + ).not.toThrow(); + }); + + it('accepts valid SET params', () => { + expect(() => + tool.build({ action: 'set', setting: 'model', value: 'qwen3-coder' }), + ).not.toThrow(); + }); + }); + + describe('GET', () => { + it('returns current model value and available models', async () => { + const invocation = tool.build({ action: 'get', setting: 'model' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('model = qwen-coder-plus'); + expect(result.llmContent).toContain('Available models:'); + expect(result.llmContent).toContain('qwen-coder-plus'); + expect(result.llmContent).toContain('qwen3-coder'); + }); + + it('permission is allow for GET', async () => { + const invocation = tool.build({ action: 'get', setting: 'model' }); + const permission = await invocation.getDefaultPermission(); + expect(permission).toBe('allow'); + }); + }); + + describe('SET', () => { + it('permission is ask for SET', async () => { + const invocation = tool.build({ + action: 'set', + setting: 'model', + value: 'qwen3-coder', + }); + const permission = await invocation.getDefaultPermission(); + expect(permission).toBe('ask'); + }); + + it('changes model on success', async () => { + const invocation = tool.build({ + action: 'set', + setting: 'model', + value: 'qwen3-coder', + }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain("changed from 'qwen-coder-plus'"); + expect(result.llmContent).toContain("to 'qwen3-coder'"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).setModel).toHaveBeenCalledWith('qwen3-coder', { + reason: 'agent-config-tool', + context: 'ConfigTool SET', + }); + }); + + it('returns error when setModel throws', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (config as any).setModel = vi.fn(async () => { + throw new Error('Invalid model ID'); + }); + tool = new ConfigTool(config); + + const invocation = tool.build({ + action: 'set', + setting: 'model', + value: 'nonexistent-model', + }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Failed to set model'); + expect(result.llmContent).toContain('Invalid model ID'); + expect(result.error).toBeDefined(); + expect(result.error?.type).toBe('execution_failed'); + }); + }); + + describe('confirmation details', () => { + it('shows from/to for SET', async () => { + const invocation = tool.build({ + action: 'set', + setting: 'model', + value: 'qwen3-coder', + }); + const details = await invocation.getConfirmationDetails( + new AbortController().signal, + ); + + expect(details.type).toBe('info'); + if (details.type === 'info') { + expect(details.prompt).toContain('qwen-coder-plus'); + expect(details.prompt).toContain('qwen3-coder'); + expect(details.hideAlwaysAllow).toBe(true); + } + }); + + it('shows read description for GET', async () => { + const invocation = tool.build({ action: 'get', setting: 'model' }); + const details = await invocation.getConfirmationDetails( + new AbortController().signal, + ); + + expect(details.type).toBe('info'); + if (details.type === 'info') { + expect(details.prompt).toContain('Read model'); + } + }); + }); +}); diff --git a/packages/core/src/tools/config-tool.ts b/packages/core/src/tools/config-tool.ts new file mode 100644 index 0000000000..ccf4ea98ef --- /dev/null +++ b/packages/core/src/tools/config-tool.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ToolInfoConfirmationDetails, + ToolCallConfirmationDetails, + ToolInvocation, + ToolResult, + ToolConfirmationOutcome, +} from './tools.js'; +import type { PermissionDecision } from '../permissions/types.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolErrorType } from './tool-error.js'; +import type { FunctionDeclaration } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { + SUPPORTED_CONFIG_SETTINGS, + getAllKeys, + getDescriptor, + isSupported, +} from './supported-config-settings.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('CONFIG_TOOL'); + +export interface ConfigToolParams { + action: 'get' | 'set'; + setting: string; + value?: string; +} + +const configToolDescription = `Read or write Qwen Code configuration settings. + +## Supported settings +${getAllKeys() + .map((k) => { + const d = SUPPORTED_CONFIG_SETTINGS[k]; + return `- **${k}**: ${d.description} (${d.writable ? 'read/write' : 'read-only'})`; + }) + .join('\n')} + +## Usage +- GET: read a setting's current value. Always allowed without confirmation. +- SET: change a setting's value. Requires user confirmation. +`; + +const configToolSchemaData: FunctionDeclaration = { + name: ToolNames.CONFIG, + description: configToolDescription, + parametersJsonSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + action: { + type: 'string', + enum: ['get', 'set'], + description: "Whether to read ('get') or write ('set') the setting.", + }, + setting: { + type: 'string', + description: `Setting name. Supported: ${getAllKeys().join(', ')}.`, + }, + value: { + type: 'string', + description: + "New value for the setting. Required when action is 'set'.", + }, + }, + required: ['action', 'setting'], + additionalProperties: false, + }, +}; + +class ConfigToolInvocation extends BaseToolInvocation< + ConfigToolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: ConfigToolParams, + ) { + super(params); + } + + getDescription(): string { + if (this.params.action === 'get') { + return `Get config: ${this.params.setting}`; + } + return `Set config: ${this.params.setting} → ${this.params.value}`; + } + + override async getDefaultPermission(): Promise { + return this.params.action === 'get' ? 'allow' : 'ask'; + } + + override async getConfirmationDetails( + _abortSignal: AbortSignal, + ): Promise { + const descriptor = getDescriptor(this.params.setting); + let currentValue = ''; + if (descriptor) { + try { + currentValue = descriptor.read(this.config); + } catch { + currentValue = ''; + } + } + + const details: ToolInfoConfirmationDetails = { + type: 'info', + title: 'Config', + prompt: + this.params.action === 'set' + ? `Change ${this.params.setting} from '${currentValue}' to '${this.params.value}'` + : `Read ${this.params.setting}`, + hideAlwaysAllow: true, + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // No-op: config changes should be confirmed each time in Phase 1. + }, + }; + return details; + } + + async execute(): Promise { + const { action, setting, value } = this.params; + const descriptor = getDescriptor(setting); + + if (!descriptor) { + const available = getAllKeys().join(', '); + const msg = `Unknown setting: "${setting}". Available settings: ${available}`; + debugLogger.debug(msg); + return { + llmContent: msg, + returnDisplay: msg, + error: { message: msg, type: ToolErrorType.EXECUTION_FAILED }, + }; + } + + if (action === 'get') { + const currentValue = descriptor.read(this.config); + let result = `${setting} = ${currentValue}`; + + // For model, also list available options + if (setting === 'model') { + try { + const available = this.config.getAvailableModels(); + if (available.length > 0) { + const modelList = available + .map((m) => ` - ${m.id}${m.label ? ` (${m.label})` : ''}`) + .join('\n'); + result += `\nAvailable models:\n${modelList}`; + } + } catch (err) { + debugLogger.debug('Failed to get available models:', err); + } + } + + debugLogger.debug(`Config GET ${setting} = ${currentValue}`); + return { llmContent: result, returnDisplay: result }; + } + + // SET + if (value == null) { + const msg = `Value is required for SET operation on "${setting}".`; + return { + llmContent: msg, + returnDisplay: msg, + error: { message: msg, type: ToolErrorType.EXECUTION_FAILED }, + }; + } + const previousValue = descriptor.read(this.config); + const error = await descriptor.write(this.config, value); + + if (error) { + const msg = `Failed to set ${setting}: ${error}`; + debugLogger.debug(msg); + return { + llmContent: msg, + returnDisplay: msg, + error: { message: msg, type: ToolErrorType.EXECUTION_FAILED }, + }; + } + + const newValue = descriptor.read(this.config); + const msg = `${setting} changed from '${previousValue}' to '${newValue}'`; + debugLogger.debug(`Config SET ${msg}`); + return { llmContent: msg, returnDisplay: msg }; + } +} + +export class ConfigTool extends BaseDeclarativeTool< + ConfigToolParams, + ToolResult +> { + static readonly Name = ToolNames.CONFIG; + + constructor(private config: Config) { + super( + ToolNames.CONFIG, + ToolDisplayNames.CONFIG, + configToolDescription, + Kind.Other, + configToolSchemaData.parametersJsonSchema!, + ); + } + + protected override validateToolParamValues( + params: ConfigToolParams, + ): string | null { + if (!isSupported(params.setting)) { + return `Unknown setting: "${params.setting}". Available: ${getAllKeys().join(', ')}`; + } + + if (params.action === 'set') { + if (params.value == null || params.value.trim() === '') { + return `Value is required when action is 'set'.`; + } + const descriptor = getDescriptor(params.setting); + if (descriptor && !descriptor.writable) { + return `Setting "${params.setting}" is read-only.`; + } + } + + return null; + } + + protected createInvocation( + params: ConfigToolParams, + ): ToolInvocation { + return new ConfigToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/supported-config-settings.ts b/packages/core/src/tools/supported-config-settings.ts new file mode 100644 index 0000000000..4585ce8dcf --- /dev/null +++ b/packages/core/src/tools/supported-config-settings.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; + +/** + * Descriptor for a setting that the ConfigTool is allowed to read/write. + * Only settings listed here are accessible — this is the security boundary. + */ +export interface ConfigSettingDescriptor { + /** Human-readable description shown to the LLM. */ + description: string; + /** Value type currently supported by the descriptor API. */ + type: 'string'; + /** Whether the Agent may write this setting. */ + writable: boolean; + /** Read the current value from Config. */ + read: (config: Config) => string; + /** Write a new value. Returns null on success, error message on failure. */ + write: (config: Config, value: string) => Promise; +} + +/** + * Curated allowlist of settings the Agent can access via ConfigTool. + * Phase 1: model only. Extend by adding entries here. + */ +export const SUPPORTED_CONFIG_SETTINGS: Record< + string, + ConfigSettingDescriptor +> = { + model: { + description: + 'The active LLM model ID. GET returns the current model and available options. SET switches the model for this session.', + type: 'string', + writable: true, + read: (config) => config.getModel(), + write: async (config, value) => { + try { + await config.setModel(value, { + reason: 'agent-config-tool', + context: 'ConfigTool SET', + }); + return null; + } catch (e) { + return e instanceof Error ? e.message : 'Failed to set model'; + } + }, + }, +}; + +export function isSupported(key: string): boolean { + return Object.hasOwn(SUPPORTED_CONFIG_SETTINGS, key); +} + +export function getDescriptor( + key: string, +): ConfigSettingDescriptor | undefined { + return Object.hasOwn(SUPPORTED_CONFIG_SETTINGS, key) + ? SUPPORTED_CONFIG_SETTINGS[key] + : undefined; +} + +export function getAllKeys(): string[] { + return Object.keys(SUPPORTED_CONFIG_SETTINGS); +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 9edc21508f..f0672c395f 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -29,6 +29,7 @@ export const ToolNames = { CRON_CREATE: 'cron_create', CRON_LIST: 'cron_list', CRON_DELETE: 'cron_delete', + CONFIG: 'config', } as const; /** @@ -56,6 +57,7 @@ export const ToolDisplayNames = { CRON_CREATE: 'CronCreate', CRON_LIST: 'CronList', CRON_DELETE: 'CronDelete', + CONFIG: 'Config', } as const; // Migration from old tool names to new tool names