diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index 256034e3c..39c105383 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -98,6 +98,7 @@ Subagents are configured using Markdown files with YAML frontmatter. This format --- name: agent-name description: Brief description of when and how to use this agent +model: inherit # Optional: inherit or model-id tools: - tool1 - tool2 @@ -106,9 +107,17 @@ tools: System prompt content goes here. Multiple paragraphs are supported. -You can use ${variable} templating for dynamic content. ``` +#### Model Selection + +Use the optional `model` frontmatter field to control which model a subagent uses: + +- `inherit`: Use the same model as the main conversation +- Omit the field: Same as `inherit` +- `glm-5`: Use that model ID with the main conversation's auth type +- `sonnet`, `opus`, `haiku`: Alias-style values are also accepted as model strings + #### Example Usage ``` @@ -117,12 +126,7 @@ name: project-documenter description: Creates project documentation and README files --- -You are a documentation specialist for the ${project_name} project. - -Your task: ${task_description} - -Working directory: ${current_directory} -Generated on: ${timestamp} +You are a documentation specialist. Focus on creating clear, comprehensive documentation that helps both new contributors and end users understand the project. @@ -213,7 +217,7 @@ tools: - web_search --- -You are a technical documentation specialist for ${project_name}. +You are a technical documentation specialist. Your role is to create clear, comprehensive documentation that serves both developers and end users. Focus on: diff --git a/integration-tests/sdk-typescript/subagents.test.ts b/integration-tests/sdk-typescript/subagents.test.ts index 487e0e7e7..d9fa037f7 100644 --- a/integration-tests/sdk-typescript/subagents.test.ts +++ b/integration-tests/sdk-typescript/subagents.test.ts @@ -131,16 +131,13 @@ describe('Subagents (E2E)', () => { } }); - it('should handle subagent with custom model config', async () => { + it('should handle subagent with custom model selector', async () => { const customModelAgent: SubagentConfig = { name: 'custom-model-agent', description: 'Agent with custom model configuration', systemPrompt: 'You are a helpful assistant.', level: 'session', - modelConfig: { - temp: 0.7, - top_p: 0.9, - }, + model: 'inherit', }; const q = query({ diff --git a/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx index ee2fd3664..a74ddc96b 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx @@ -40,6 +40,13 @@ export const AgentViewerStep = ({ selectedAgent }: AgentViewerStepProps) => { {toolsDisplay} + {agent.model && ( + + {t('Model: ')} + {agent.model} + + )} + {shouldShowColor(agent.color) && ( {t('Color: ')} diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index ba0fbf15d..6c23f8adf 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -19,7 +19,14 @@ import { type ContentGeneratorConfig, createContentGenerator, } from '../../core/contentGenerator.js'; -import { AUTH_ENV_MAPPINGS } from '../../models/constants.js'; +import type { ToolRegistry } from '../../tools/tool-registry.js'; +import { WorkspaceContext } from '../../utils/workspaceContext.js'; +import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; +import { + AUTH_ENV_MAPPINGS, + MODEL_GENERATION_CONFIG_FIELDS, +} from '../../models/constants.js'; +import type { ResolvedModelConfig } from '../../models/types.js'; import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; import { AgentCore } from '../runtime/agent-core.js'; import { AgentEventEmitter } from '../runtime/agent-events.js'; @@ -33,9 +40,6 @@ import type { } from './types.js'; import { DISPLAY_MODE } from './types.js'; import type { AnsiOutput } from '../../utils/terminalSerializer.js'; -import { WorkspaceContext } from '../../utils/workspaceContext.js'; -import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; -import type { ToolRegistry } from '../../tools/tool-registry.js'; const debugLogger = createDebugLogger('IN_PROCESS_BACKEND'); @@ -332,15 +336,10 @@ export class InProcessBackend implements Backend { * - `getWorkingDir()` / `getTargetDir()` → agent's worktree cwd * - `getWorkspaceContext()` → WorkspaceContext rooted at agent's cwd * - `getFileService()` → FileDiscoveryService rooted at agent's cwd - * (so .qwenignore checks resolve against the agent's worktree) * - `getToolRegistry()` → per-agent tool registry with core tools bound to - * the agent Config (so tools resolve paths against the agent's worktree) + * the agent Config * - `getContentGenerator()` / `getContentGeneratorConfig()` / `getAuthType()` - * → per-agent ContentGenerator when `authOverrides` is provided, enabling - * agents to target different model providers in the same Arena session - * - * Uses prototypal delegation so all other Config methods/properties resolve - * against the original instance transparently. + * → per-agent ContentGenerator when `authOverrides` is provided */ async function createPerAgentConfig( base: Config, @@ -361,9 +360,6 @@ async function createPerAgentConfig( const agentFileService = new FileDiscoveryService(cwd); override.getFileService = () => agentFileService; - // Build a per-agent tool registry: core tools are constructed with - // the per-agent Config so they resolve paths against cwd. Discovered - // (MCP/command) tools are copied from the parent registry as-is. const agentRegistry: ToolRegistry = await override.createToolRegistry( undefined, { skipDiscovery: true }, @@ -371,9 +367,6 @@ async function createPerAgentConfig( agentRegistry.copyDiscoveredToolsFrom(base.getToolRegistry()); override.getToolRegistry = () => agentRegistry; - // Build a per-agent ContentGenerator when auth overrides are provided. - // This enables Arena agents to use different providers (OpenAI, Anthropic, - // Gemini, etc.) than the parent process. if (authOverrides?.authType) { try { const agentGeneratorConfig = buildAgentContentGeneratorConfig( @@ -406,16 +399,6 @@ async function createPerAgentConfig( return override as Config; } -/** - * Build a ContentGeneratorConfig for a per-agent ContentGenerator. - * Inherits operational settings (timeout, retries, proxy, sampling, etc.) - * from the parent's config and overlays the agent-specific auth fields. - * - * For cross-provider agents the parent's API key / base URL are invalid, - * so we resolve credentials from the provider-specific environment - * variables (e.g. ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL). This mirrors - * what a PTY subprocess does during its own initialization. - */ function buildAgentContentGeneratorConfig( base: Config, modelId: string | undefined, @@ -423,34 +406,101 @@ function buildAgentContentGeneratorConfig( ): ContentGeneratorConfig { const parentConfig = base.getContentGeneratorConfig(); const sameProvider = authOverrides.authType === parentConfig.authType; + const modelsConfig = base.getModelsConfig(); + const resolvedModel = modelId + ? modelsConfig.getResolvedModel(authOverrides.authType as AuthType, modelId) + : undefined; - const resolvedApiKey = resolveCredentialField( + const nextConfig: ContentGeneratorConfig = { + ...parentConfig, + model: modelId ?? parentConfig.model, + authType: authOverrides.authType as AuthType, + }; + + // When switching providers, clear generation config fields so parent + // settings (samplingParams, reasoning, extra_body, etc.) don't leak. + if (!sameProvider) { + for (const field of MODEL_GENERATION_CONFIG_FIELDS) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (nextConfig as any)[field] = undefined; + } + } + + if (resolvedModel) { + applyResolvedModelConfig( + nextConfig, + resolvedModel, + parentConfig, + authOverrides, + ); + return nextConfig; + } + + nextConfig.apiKey = resolveCredentialField( authOverrides.apiKey, sameProvider ? parentConfig.apiKey : undefined, authOverrides.authType, 'apiKey', ); + nextConfig.baseUrl = + authOverrides.baseUrl ?? + resolveCredentialField( + undefined, + sameProvider ? parentConfig.baseUrl : undefined, + authOverrides.authType, + 'baseUrl', + ); + nextConfig.apiKeyEnvKey = sameProvider + ? parentConfig.apiKeyEnvKey + : undefined; - const resolvedBaseUrl = resolveCredentialField( - authOverrides.baseUrl, - sameProvider ? parentConfig.baseUrl : undefined, - authOverrides.authType, - 'baseUrl', - ); - - return { - ...parentConfig, - model: modelId ?? parentConfig.model, - authType: authOverrides.authType as AuthType, - apiKey: resolvedApiKey, - baseUrl: resolvedBaseUrl, - }; + return nextConfig; +} + +function applyResolvedModelConfig( + targetConfig: ContentGeneratorConfig, + resolvedModel: ResolvedModelConfig, + parentConfig: ContentGeneratorConfig, + authOverrides: NonNullable, +): void { + const sameProvider = authOverrides.authType === parentConfig.authType; + targetConfig.model = resolvedModel.id; + targetConfig.authType = resolvedModel.authType; + targetConfig.baseUrl = + authOverrides.baseUrl ?? + resolvedModel.baseUrl ?? + (sameProvider ? parentConfig.baseUrl : undefined); + + if (resolvedModel.envKey) { + targetConfig.apiKey = + authOverrides.apiKey ?? + process.env[resolvedModel.envKey] ?? + (sameProvider ? parentConfig.apiKey : undefined); + targetConfig.apiKeyEnvKey = resolvedModel.envKey; + } else { + targetConfig.apiKey = resolveCredentialField( + authOverrides.apiKey, + sameProvider ? parentConfig.apiKey : undefined, + authOverrides.authType, + 'apiKey', + ); + targetConfig.apiKeyEnvKey = sameProvider + ? parentConfig.apiKeyEnvKey + : undefined; + } + + // Apply registry-defined generation config fields. Cross-provider + // clearing is already handled by buildAgentContentGeneratorConfig, + // so here we only overwrite when the registry provides a value. + for (const field of MODEL_GENERATION_CONFIG_FIELDS) { + const registryValue = resolvedModel.generationConfig[field]; + if (registryValue !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (targetConfig as any)[field] = registryValue; + } + } } -/** - * Resolve a credential field (apiKey or baseUrl) with the following - * priority: explicit override → same-provider parent value → env var. - */ function resolveCredentialField( explicitValue: string | undefined, inheritedValue: string | undefined, diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 9ef287827..b8da31672 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -182,14 +182,9 @@ export function convertClaudeAgentConfig( qwenAgent['tools'] = claudeBuildInToolsTransform(claudeAgent.tools); } - // Convert model to modelConfig + // Preserve Claude's top-level model selector. if (claudeAgent.model) { - // Map Claude model names to Qwen model config - // Claude uses: sonnet, opus, haiku, inherit - // We preserve the model name for now, the actual mapping will be handled at runtime - qwenAgent['modelConfig'] = { - model: claudeAgent.model === 'inherit' ? undefined : claudeAgent.model, - }; + qwenAgent['model'] = claudeAgent.model; } // Preserve unsupported fields as-is for potential future compatibility diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index d22cc790c..f7086bbca 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -301,6 +301,17 @@ export class ModelsConfig { return this.modelRegistry.hasModel(authType, modelId); } + /** + * Get a fully resolved provider model config for the given authType/modelId. + * Returns undefined for raw runtime models that are not present in the registry. + */ + getResolvedModel( + authType: AuthType, + modelId: string, + ): ResolvedModelConfig | undefined { + return this.modelRegistry.getModel(authType, modelId); + } + /** * Set model programmatically (e.g., VLM auto-switch, fallback). * Supports both registry models and raw model IDs. diff --git a/packages/core/src/subagents/model-selection.test.ts b/packages/core/src/subagents/model-selection.test.ts new file mode 100644 index 000000000..aaa2624f8 --- /dev/null +++ b/packages/core/src/subagents/model-selection.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '../core/contentGenerator.js'; +import { parseSubagentModelSelection } from './model-selection.js'; + +describe('parseSubagentModelSelection', () => { + it('treats omitted models as inherit', () => { + expect(parseSubagentModelSelection(undefined)).toEqual({ + inherits: true, + }); + }); + + it('treats explicit inherit as inherit', () => { + expect(parseSubagentModelSelection('inherit')).toEqual({ + inherits: true, + }); + }); + + it('parses bare model IDs', () => { + expect(parseSubagentModelSelection('glm-5')).toEqual({ + modelId: 'glm-5', + inherits: false, + }); + }); + + it('parses authType-prefixed model IDs', () => { + expect(parseSubagentModelSelection('openai:glm-5')).toEqual({ + authType: AuthType.USE_OPENAI, + modelId: 'glm-5', + inherits: false, + }); + }); + + it('rejects invalid authType prefixes', () => { + expect(() => parseSubagentModelSelection('invalid:glm-5')).toThrow( + /Invalid authType prefix/, + ); + }); +}); diff --git a/packages/core/src/subagents/model-selection.ts b/packages/core/src/subagents/model-selection.ts new file mode 100644 index 000000000..cea1ef97d --- /dev/null +++ b/packages/core/src/subagents/model-selection.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '../core/contentGenerator.js'; + +export interface ParsedSubagentModelSelection { + authType?: AuthType; + modelId?: string; + inherits: boolean; +} + +const AUTH_TYPES = new Set(Object.values(AuthType)); + +/** + * Parse a subagent model selector. + * + * Supported forms: + * - omitted / inherit -> use parent conversation model + * - modelId -> use parent authType with the provided modelId + * - authType:modelId -> use explicit authType and modelId + */ +export function parseSubagentModelSelection( + model: string | undefined, +): ParsedSubagentModelSelection { + const trimmed = model?.trim(); + if (!trimmed || trimmed === 'inherit') { + return { inherits: true }; + } + + const colonIndex = trimmed.indexOf(':'); + if (colonIndex === -1) { + return { modelId: trimmed, inherits: false }; + } + + const maybeAuthType = trimmed.slice(0, colonIndex).trim(); + const modelId = trimmed.slice(colonIndex + 1).trim(); + + if (!AUTH_TYPES.has(maybeAuthType as AuthType)) { + throw new Error( + `Invalid authType prefix "${maybeAuthType}". Expected one of: ${Object.values(AuthType).join(', ')}`, + ); + } + + if (!modelId) { + throw new Error( + 'Model selector must include a model ID after the authType', + ); + } + + return { + authType: maybeAuthType as AuthType, + modelId, + inherits: false, + }; +} diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 5fb13e6e2..13b1c077b 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -83,11 +83,11 @@ describe('SubagentManager', () => { tools: ['read_file', 'write_file'], }; } - if (yamlString.includes('modelConfig:')) { + if (yamlString.includes('model:')) { return { name: 'test-agent', description: 'A test subagent', - modelConfig: { model: 'custom-model', temp: 0.5 }, + model: 'custom-model', }; } if (yamlString.includes('runConfig:')) { @@ -130,17 +130,8 @@ describe('SubagentManager', () => { for (const [key, value] of Object.entries(obj)) { if (key === 'tools' && Array.isArray(value)) { yaml += `tools:\n${value.map((tool) => ` - ${tool}`).join('\n')}\n`; - } else if ( - key === 'modelConfig' && - typeof value === 'object' && - value - ) { - yaml += `modelConfig:\n`; - for (const [k, v] of Object.entries( - value as Record, - )) { - yaml += ` ${k}: ${v}\n`; - } + } else if (key === 'model') { + yaml += `model: ${value}\n`; } else if (key === 'runConfig' && typeof value === 'object' && value) { yaml += `runConfig:\n`; for (const [k, v] of Object.entries( @@ -229,13 +220,11 @@ You are a helpful assistant. expect(config.tools).toEqual(['read_file', 'write_file']); }); - it('should parse content with model config', () => { + it('should parse content with model selector', () => { const markdownWithModel = `--- name: test-agent description: A test subagent -modelConfig: - model: custom-model - temp: 0.5 +model: custom-model --- You are a helpful assistant. @@ -247,7 +236,33 @@ You are a helpful assistant. 'project', ); - expect(config.modelConfig).toEqual({ model: 'custom-model', temp: 0.5 }); + expect(config.model).toBe('custom-model'); + }); + + it('should parse legacy modelConfig frontmatter for compatibility', () => { + const markdownWithLegacyModel = `--- +name: test-agent +description: A test subagent +modelConfig: + model: legacy-model +--- + +You are a helpful assistant. +`; + + mockParseYaml.mockReturnValueOnce({ + name: 'test-agent', + description: 'A test subagent', + modelConfig: { model: 'legacy-model' }, + }); + + const config = manager.parseSubagentContent( + markdownWithLegacyModel, + validConfig.filePath!, + 'project', + ); + + expect(config.model).toBe('legacy-model'); }); it('should parse content with run config', () => { @@ -419,24 +434,22 @@ You are a helpful assistant. expect(serialized).toContain('- write_file'); }); - it('should serialize configuration with model config', () => { + it('should serialize configuration with model selector', () => { const configWithModel: SubagentConfig = { ...validConfig, - modelConfig: { model: 'custom-model', temp: 0.5 }, + model: 'custom-model', }; const serialized = manager.serializeSubagent(configWithModel); - expect(serialized).toContain('modelConfig:'); expect(serialized).toContain('model: custom-model'); - expect(serialized).toContain('temp: 0.5'); }); it('should not include empty optional fields', () => { const serialized = manager.serializeSubagent(validConfig); expect(serialized).not.toContain('tools:'); - expect(serialized).not.toContain('modelConfig:'); + expect(serialized).not.toContain('model:'); expect(serialized).not.toContain('runConfig:'); }); }); @@ -1104,26 +1117,28 @@ System prompt 3`); ]); }); - it('should merge custom model and run configurations', () => { + it('should set modelConfig.model from model selector and merge run configurations', () => { const configWithCustom: SubagentConfig = { ...validConfig, - modelConfig: { model: 'custom-model', temp: 0.5 }, + model: 'custom-model', runConfig: { max_time_minutes: 5 }, }; const runtimeConfig = manager.convertToRuntimeConfig(configWithCustom); expect(runtimeConfig.modelConfig.model).toBe('custom-model'); - expect(runtimeConfig.modelConfig.temp).toBe(0.5); expect(runtimeConfig.runConfig.max_time_minutes).toBe(5); - // No default values are provided anymore - expect(Object.keys(runtimeConfig.modelConfig)).toEqual([ - 'model', - 'temp', - ]); - expect(Object.keys(runtimeConfig.runConfig)).toEqual([ - 'max_time_minutes', - ]); + }); + + it('should reject cross-provider model selectors', () => { + const configWithCrossProvider: SubagentConfig = { + ...validConfig, + model: 'openai:gpt-4', + }; + + expect(() => + manager.convertToRuntimeConfig(configWithCrossProvider), + ).toThrow(/Cross-provider model selectors/); }); }); @@ -1144,19 +1159,18 @@ System prompt 3`); it('should merge nested configurations', () => { const configWithNested: SubagentConfig = { ...validConfig, - modelConfig: { model: 'original-model', temp: 0.7 }, + model: 'original-model', runConfig: { max_time_minutes: 10, max_turns: 20 }, }; const updates = { - modelConfig: { temp: 0.5 }, + model: 'updated-model', runConfig: { max_time_minutes: 5 }, }; const merged = manager.mergeConfigurations(configWithNested, updates); - expect(merged.modelConfig!.model).toBe('original-model'); // Should keep original - expect(merged.modelConfig!.temp).toBe(0.5); // Should update + expect(merged.model).toBe('updated-model'); expect(merged.runConfig!.max_time_minutes).toBe(5); // Should update expect(merged.runConfig!.max_turns).toBe(20); // Should keep original }); diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index a3928041d..5ec87de9c 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -36,6 +36,7 @@ import type { import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { normalizeContent } from '../utils/textUtils.js'; +import { parseSubagentModelSelection } from './model-selection.js'; const debugLogger = createDebugLogger('SUBAGENT_MANAGER'); import { BuiltinAgentRegistry } from './builtin-agents.js'; @@ -568,10 +569,8 @@ export class SubagentManager { frontmatter['tools'] = config.tools; } - // No outputs section - - if (config.modelConfig) { - frontmatter['modelConfig'] = config.modelConfig; + if (config.model && config.model !== 'inherit') { + frontmatter['model'] = config.model; } if (config.runConfig) { @@ -640,25 +639,28 @@ export class SubagentManager { * @returns Runtime configuration for AgentHeadless */ convertToRuntimeConfig(config: SubagentConfig): SubagentRuntimeConfig { - // Build prompt configuration const promptConfig: PromptConfig = { systemPrompt: config.systemPrompt, }; - // Build model configuration + const selection = parseSubagentModelSelection(config.model); + if (selection.authType) { + throw new SubagentError( + `Cross-provider model selectors (e.g. "${config.model}") are not supported for subagents. Use a bare model ID instead, or use Arena for cross-provider agents.`, + SubagentErrorCode.INVALID_CONFIG, + config.name, + ); + } const modelConfig: ModelConfig = { - ...config.modelConfig, + ...(selection.modelId ? { model: selection.modelId } : {}), }; - // Build run configuration const runConfig: RunConfig = { ...config.runConfig, }; - // Build tool configuration if tools are specified let toolConfig: ToolConfig | undefined; if (config.tools && config.tools.length > 0) { - // Transform tools array to ensure all entries are tool names (not display names) const toolNames = this.transformToToolNames(config.tools); toolConfig = { tools: toolNames, @@ -740,10 +742,6 @@ export class SubagentManager { return { ...base, ...updates, - // Handle nested objects specially - modelConfig: updates.modelConfig - ? { ...base.modelConfig, ...updates.modelConfig } - : base.modelConfig, runConfig: updates.runConfig ? { ...base.runConfig, ...updates.runConfig } : base.runConfig, @@ -956,13 +954,20 @@ function parseSubagentContent( // Extract optional fields const tools = frontmatter['tools'] as string[] | undefined; - const modelConfig = frontmatter['modelConfig'] as + const modelRaw = frontmatter['model']; + const legacyModelConfig = frontmatter['modelConfig'] as | Record | undefined; const runConfig = frontmatter['runConfig'] as | Record | undefined; const color = frontmatter['color'] as string | undefined; + const model = + modelRaw != null && modelRaw !== '' + ? String(modelRaw) + : typeof legacyModelConfig?.['model'] === 'string' + ? legacyModelConfig['model'] + : undefined; const config: SubagentConfig = { name, @@ -970,7 +975,7 @@ function parseSubagentContent( tools, systemPrompt: systemPrompt.trim(), filePath, - modelConfig: modelConfig as Partial, + model, runConfig: runConfig as Partial, color, level, diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 55e57f61e..3e67869a5 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -64,10 +64,12 @@ export interface SubagentConfig { filePath?: string; /** - * Optional model configuration. If not provided, uses defaults. - * Can specify model name, temperature, and top_p values. + * Optional model selector. + * - Omitted or 'inherit': use the main conversation model + * - 'model-id': use the given model with the main conversation authType + * - 'authType:model-id': use the given authType and model ID */ - modelConfig?: Partial; + model?: string; /** * Optional runtime configuration. If not provided, uses defaults. diff --git a/packages/core/src/subagents/validation.test.ts b/packages/core/src/subagents/validation.test.ts index 1d705cc0d..0818ef4cd 100644 --- a/packages/core/src/subagents/validation.test.ts +++ b/packages/core/src/subagents/validation.test.ts @@ -224,54 +224,53 @@ describe('SubagentValidator', () => { }); }); - describe('validateModelConfig', () => { - it('should accept valid model configurations', () => { - const validConfigs = [ - { model: 'gemini-1.5-pro', temp: 0.7, top_p: 0.9 }, - { temp: 0.5 }, - { top_p: 1.0 }, - {}, - ]; + describe('validateModel', () => { + it('should accept valid model selectors', () => { + const validModels = ['inherit', 'glm-5', 'claude-sonnet-4-6']; - for (const config of validConfigs) { - const result = validator.validateModelConfig(config); + for (const model of validModels) { + const result = validator.validateModel(model); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); } }); - it('should reject invalid model names', () => { - const result = validator.validateModelConfig({ model: '' }); - expect(result.isValid).toBe(false); - expect(result.errors).toContain('Model name must be a non-empty string'); - }); + it('should reject cross-provider authType-prefixed selectors', () => { + const crossProviderModels = ['openai:glm-5', 'anthropic:sonnet']; - it('should reject invalid temperature values', () => { - const invalidTemps = [-0.1, 2.1, 'not-a-number']; - - for (const temp of invalidTemps) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = validator.validateModelConfig({ temp: temp as any }); + for (const model of crossProviderModels) { + const result = validator.validateModel(model); expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('Cross-provider model selectors'); } }); - it('should warn about high temperature', () => { - const result = validator.validateModelConfig({ temp: 1.5 }); - expect(result.isValid).toBe(true); - expect(result.warnings).toContain( - 'High temperature (>1) may produce very creative but unpredictable results', + it('should reject empty model selectors', () => { + const result = validator.validateModel(''); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Model must be a non-empty string'); + }); + + it('should reject invalid authType prefixes', () => { + const result = validator.validateModel('invalid:glm-5'); + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('Invalid authType prefix'); + }); + + it('should reject missing model IDs after authType prefixes', () => { + const result = validator.validateModel('openai:'); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + 'Model selector must include a model ID after the authType', ); }); - it('should reject invalid top_p values', () => { - const invalidTopP = [-0.1, 1.1, 'not-a-number']; - - for (const top_p of invalidTopP) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = validator.validateModelConfig({ top_p: top_p as any }); - expect(result.isValid).toBe(false); - } + it('should warn when inherit is explicit', () => { + const result = validator.validateModel('inherit'); + expect(result.isValid).toBe(true); + expect(result.warnings).toContain( + 'Explicit "inherit" is optional because omitting the model uses the main conversation model', + ); }); }); diff --git a/packages/core/src/subagents/validation.ts b/packages/core/src/subagents/validation.ts index 15fb31269..e2b4070ad 100644 --- a/packages/core/src/subagents/validation.ts +++ b/packages/core/src/subagents/validation.ts @@ -6,7 +6,8 @@ import { SubagentError, SubagentErrorCode } from './types.js'; import type { SubagentConfig, ValidationResult } from './types.js'; -import type { ModelConfig, RunConfig } from '../agents/runtime/agent-types.js'; +import type { RunConfig } from '../agents/runtime/agent-types.js'; +import { parseSubagentModelSelection } from './model-selection.js'; /** * Validates subagent configurations to ensure they are well-formed @@ -54,9 +55,9 @@ export class SubagentValidator { warnings.push(...toolsValidation.warnings); } - // Validate model config if specified - if (config.modelConfig) { - const modelValidation = this.validateModelConfig(config.modelConfig); + // Validate model selector if specified + if (config.model) { + const modelValidation = this.validateModel(config.model); if (!modelValidation.isValid) { errors.push(...modelValidation.errors); } @@ -240,42 +241,39 @@ export class SubagentValidator { } /** - * Validates model configuration. + * Validates a subagent model selector. * - * @param modelConfig - Partial model configuration to validate + * @param model - Model selector to validate * @returns ValidationResult */ - validateModelConfig(modelConfig: ModelConfig): ValidationResult { + validateModel(model: string): ValidationResult { const errors: string[] = []; const warnings: string[] = []; - if (modelConfig.model !== undefined) { - if ( - typeof modelConfig.model !== 'string' || - modelConfig.model.trim().length === 0 - ) { - errors.push('Model name must be a non-empty string'); - } + if (typeof model !== 'string' || model.trim().length === 0) { + errors.push('Model must be a non-empty string'); + return { + isValid: false, + errors, + warnings, + }; } - if (modelConfig.temp !== undefined) { - if (typeof modelConfig.temp !== 'number') { - errors.push('Temperature must be a number'); - } else if (modelConfig.temp < 0 || modelConfig.temp > 2) { - errors.push('Temperature must be between 0 and 2'); - } else if (modelConfig.temp > 1) { - warnings.push( - 'High temperature (>1) may produce very creative but unpredictable results', + try { + const selection = parseSubagentModelSelection(model); + if (selection.authType) { + errors.push( + `Cross-provider model selectors (e.g. "${model}") are not yet supported for subagents. Use a bare model ID instead.`, ); } + } catch (error) { + errors.push(error instanceof Error ? error.message : 'Invalid model'); } - if (modelConfig.top_p !== undefined) { - if (typeof modelConfig.top_p !== 'number') { - errors.push('top_p must be a number'); - } else if (modelConfig.top_p < 0 || modelConfig.top_p > 1) { - errors.push('top_p must be between 0 and 1'); - } + if (model.trim() === 'inherit') { + warnings.push( + 'Explicit "inherit" is optional because omitting the model uses the main conversation model', + ); } return { diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts index e5eeb1212..2a7e4fcbf 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts @@ -534,12 +534,6 @@ export function isToolResultBlock(block: any): block is ToolResultBlock { export type SubagentLevel = 'session'; -export interface ModelConfig { - model?: string; - temp?: number; - top_p?: number; -} - export interface RunConfig { max_time_minutes?: number; max_turns?: number; @@ -552,7 +546,7 @@ export interface SubagentConfig { systemPrompt: string; level: SubagentLevel; filePath?: string; - modelConfig?: Partial; + model?: string; runConfig?: Partial; color?: string; readonly isBuiltin?: boolean; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 805d03cfb..7d841fe5a 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -36,7 +36,6 @@ export type { ControlCancelRequest, SubagentConfig, SubagentLevel, - ModelConfig, RunConfig, } from './types/protocol.js'; diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 3b242d08f..33b0f53d4 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -545,12 +545,6 @@ export function isToolResultBlock(block: any): block is ToolResultBlock { export type SubagentLevel = 'session'; -export interface ModelConfig { - model?: string; - temp?: number; - top_p?: number; -} - export interface RunConfig { max_time_minutes?: number; max_turns?: number; @@ -563,7 +557,7 @@ export interface SubagentConfig { systemPrompt: string; level: SubagentLevel; filePath?: string; - modelConfig?: Partial; + model?: string; runConfig?: Partial; color?: string; readonly isBuiltin?: boolean; diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 823bc7085..5d1d07004 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -94,12 +94,6 @@ export const McpServerConfigSchema = z.union([ SdkMcpServerConfigSchema, ]); -export const ModelConfigSchema = z.object({ - model: z.string().optional(), - temp: z.number().optional(), - top_p: z.number().optional(), -}); - export const RunConfigSchema = z.object({ max_time_minutes: z.number().optional(), max_turns: z.number().optional(), @@ -110,7 +104,7 @@ export const SubagentConfigSchema = z.object({ description: z.string().min(1, 'Description must be a non-empty string'), tools: z.array(z.string()).optional(), systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), - modelConfig: ModelConfigSchema.partial().optional(), + model: z.string().optional(), runConfig: RunConfigSchema.partial().optional(), color: z.string().optional(), isBuiltin: z.boolean().optional(),