Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/design/subagent-model-grade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Subagent Model Grade Selection

## Goal

Let the model choose a user-defined model grade when spawning a regular
subagent without exposing provider-specific model IDs in the Agent tool
schema.

```json
{
"agents": {
"modelGrades": {
"small": "fast",
"high": "qwen-max"
},
"allowedGrades": ["small", "high"]
}
}
```

The Agent tool advertises `model: "small" | "high"` and resolves the selected
grade immediately after loading the subagent configuration.

## Resolution

The effective model selector uses this priority:

1. A non-built-in agent's explicit, non-`inherit` model
2. An allowed grade mapped by `agents.modelGrades`
3. The built-in Explore model setting
4. The inherited parent model

Unknown or disallowed grades are rejected. Forks reject the parameter because
they must inherit the parent's model and prompt cache. Named team teammates
also reject it because their backend model override accepts concrete model IDs
rather than grade selectors.

Only configured, allowed grade names are included in the dynamic tool schema.
Concrete model selectors remain private to user settings.

## Verification

- Settings schema and CLI-to-core configuration forwarding
- Grade resolution, allowlist filtering, and custom-agent priority
- Dynamic Agent tool schema without concrete model IDs
- Regular foreground and background dispatch using the resolved model
- Fork validation
8 changes: 5 additions & 3 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,11 @@ See [Memory](../features/memory) for details on how auto-memory works and how to

#### agents

| Setting | Type | Description | Default |
| ----------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
| Setting | Type | Description | Default |
| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` |
| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` |

#### permissions

Expand Down
20 changes: 20 additions & 0 deletions docs/users/features/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,26 @@ This setting accepts the same selectors described above. It is applied only
when Qwen Code resolves the built-in Explore definition; a session, project,
user, or extension agent named Explore keeps its own `model` setting.

To let the model select from user-defined grades without exposing concrete
model IDs, configure `agents.modelGrades` and optionally restrict them with
`agents.allowedGrades`:

```json
{
"agents": {
"modelGrades": {
"small": "fast",
"high": "qwen-max"
},
"allowedGrades": ["small", "high"]
}
}
```

The Agent tool then accepts `model: "small"` or `model: "high"` for regular
subagents. Unknown, disallowed, fork, and named-teammate grade selections are
rejected. A custom agent's explicit model still takes precedence over a grade.

#### Permission Mode

Use the optional `approvalMode` frontmatter field to control how a subagent's tool calls are approved. Valid values:
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,26 @@ describe('loadCliConfig', () => {
expect(config.getAgentsSettings().builtin?.exploreModel).toBe('fast');
});

it('passes model grade settings to core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig(
{
agents: {
modelGrades: { small: 'fast', high: 'qwen-max' },
allowedGrades: ['small'],
},
},
argv,
);

expect(config.getAgentsSettings().modelGrades).toEqual({
small: 'fast',
high: 'qwen-max',
});
expect(config.getAgentsSettings().allowedGrades).toEqual(['small']);
});

it('should ignore blank settings fallback models', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2303,6 +2303,8 @@ export async function loadCliConfig(
exploreModel: settings.agents.builtin.exploreModel,
}
: undefined,
modelGrades: settings.agents.modelGrades,
allowedGrades: settings.agents.allowedGrades,
maxParallelAgents: settings.agents.maxParallelAgents,
maxParallelAgentsByModel: settings.agents.maxParallelAgentsByModel,
displayMode: settings.agents.displayMode,
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ describe('SettingsSchema', () => {
expect(exploreModel.showInDialog).toBe(false);
});

it('should define model grade settings', () => {
const agents = getSettingsSchema().agents.properties;

expect(agents.modelGrades.jsonSchemaOverride).toEqual({
type: 'object',
additionalProperties: { type: 'string' },
});
expect(agents.modelGrades.requiresRestart).toBe(true);
expect(agents.allowedGrades.type).toBe('array');
expect(agents.allowedGrades.items).toEqual({ type: 'string' });
expect(agents.allowedGrades.requiresRestart).toBe(true);
});

it('should define visionBridgeTimeoutMs as a restart-required bounded integer', () => {
const timeout = getSettingsSchema().visionBridgeTimeoutMs;

Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2947,6 +2947,32 @@ const SETTINGS_SCHEMA = {
},
},
},
modelGrades: {
type: 'object',
label: 'Model Grades',
category: 'Model',
requiresRestart: true,
default: undefined as Record<string, string> | undefined,
description:
'Maps semantic model grade names exposed to the Agent tool to concrete model selectors.',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
jsonSchemaOverride: {
type: 'object',
additionalProperties: { type: 'string' },
},
},
allowedGrades: {
type: 'array',
label: 'Allowed Model Grades',
category: 'Model',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Optional whitelist of model grade names the Agent tool may use.',
showInDialog: false,
items: { type: 'string' },
},
maxParallelAgents: {
type: 'number',
label: 'Max Parallel Agents',
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/agents/background-agent-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe('BackgroundAgentResumeService', () => {
? {
name: 'researcher',
color: 'cyan',
model: undefined as string | undefined,
}
: null,
),
Expand Down Expand Up @@ -1439,14 +1440,20 @@ describe('BackgroundAgentResumeService', () => {
});

const { service, subagentManager } = createService();
subagentManager.loadSubagent.mockResolvedValue({
name: 'researcher',
color: 'cyan',
model: 'configured-model',
});
subagentManager.createAgentHeadless = createAgentHeadless;

const resumed = await service.resumeBackgroundAgent(agentId, 'continue');

expect(resumed).toBeDefined();
expect(createAgentHeadless).toHaveBeenCalledTimes(1);
const [, overriddenConfig, createOptions] =
const [resumeConfig, overriddenConfig, createOptions] =
createAgentHeadless.mock.calls[0]!;
expect(resumeConfig.model).toBe('inherit');
expect(overriddenConfig.getApprovalMode()).toBe('auto-edit');
expect(overriddenConfig.getBareMode()).toBe(true);
expect(overriddenConfig.getSandbox()).toEqual({
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,9 +974,13 @@ export class BackgroundAgentResumeService {
recovery.forkBootstrap!,
);
} else {
const resumeSubagentConfig =
launchModel && meta.persistedCliFlags?.authType
? { ...target.subagentConfig!, model: 'inherit' }
: target.subagentConfig!;
const result = await this.config
.getSubagentManager()
.createAgentHeadless(target.subagentConfig!, bgConfig as Config, {
.createAgentHeadless(resumeSubagentConfig, bgConfig as Config, {
eventEmitter: bgEventEmitter,
promptConfigOverrides: {
initialMessages: resumeHistory,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/agents/runtime/agent-headless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ vi.mock('../../subagents/subagent-manager.js', () => {
.fn()
.mockReturnValue(() => {});
SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]);
SubagentManagerMock.prototype.getAvailableModelGrades = () => new Map();
return { SubagentManager: SubagentManagerMock };
});

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,10 @@ export interface AgentsCollabSettings {
/** Model selector for the built-in Explore subagent (default: inherit). */
exploreModel?: string;
};
/** Maps model grade names exposed to the Agent tool to model selectors. */
modelGrades?: Record<string, string>;
/** Optional whitelist of model grades exposed to the Agent tool. */
allowedGrades?: string[];
/**
* Global maximum number of background sub-agents running concurrently.
* When the cap is reached, additional launches wait for a slot.
Expand Down
105 changes: 105 additions & 0 deletions packages/core/src/subagents/subagent-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,111 @@ describe('SubagentManager', () => {
manager = new SubagentManager(mockConfig);
});

describe('resolveModelGrade', () => {
const builtinConfig: SubagentConfig = {
name: 'Explore',
description: 'Explore files',
systemPrompt: 'Explore.',
level: 'builtin',
isBuiltin: true,
model: 'fast',
};

it('resolves only available grades and preserves custom defaults', () => {
const configuredManager = new SubagentManager(
makeFakeConfig({
agents: {
modelGrades: { small: 'fast', high: 'qwen-max' },
allowedGrades: ['high'],
},
}),
);

expect(configuredManager.getAvailableModelGrades()).toEqual(
new Map([['high', 'qwen-max']]),
);
expect(configuredManager.resolveModelGrade('high', builtinConfig)).toBe(
'qwen-max',
);
expect(
configuredManager.resolveModelGrade('small', builtinConfig),
).toBeUndefined();
expect(
configuredManager.resolveModelGrade('missing', builtinConfig),
).toBeUndefined();

expect(
configuredManager.resolveModelGrade('high', {
...builtinConfig,
level: 'project',
isBuiltin: false,
model: 'custom-model',
}),
).toBeUndefined();
expect(
configuredManager.resolveModelGrade('high', {
...builtinConfig,
level: 'project',
isBuiltin: false,
model: 'inherit',
}),
).toBe('qwen-max');
});

it('exposes every valid grade without an allowlist', () => {
const configuredManager = new SubagentManager(
makeFakeConfig({
agents: { modelGrades: { small: 'fast', high: 'qwen-max' } },
}),
);

expect(configuredManager.getAvailableModelGrades()).toEqual(
new Map([
['small', 'fast'],
['high', 'qwen-max'],
]),
);
});

it('trims grade keys before publishing and resolving', () => {
const configuredManager = new SubagentManager(
makeFakeConfig({
agents: {
modelGrades: { ' small ': 'fast', high: 'qwen-max' },
allowedGrades: ['small', 'high'],
},
}),
);

const grades = configuredManager.getAvailableModelGrades();
expect([...grades.keys()]).toEqual(['small', 'high']);
expect(grades.get('small')).toBe('fast');
expect(configuredManager.resolveModelGrade('small', builtinConfig)).toBe(
'fast',
);
});

it('ignores malformed grade settings', () => {
const malformedGrades = new SubagentManager(
makeFakeConfig({
agents: {
modelGrades: {
valid: 'qwen-max',
invalid: 42 as unknown as string,
blank: ' ',
' ': 'qwen-max',
},
allowedGrades: ['valid', 'invalid', 'blank', ' '],
},
}),
);

expect(malformedGrades.getAvailableModelGrades()).toEqual(
new Map([['valid', 'qwen-max']]),
);
});
});

afterEach(() => {
vi.restoreAllMocks();
});
Expand Down
Loading
Loading