Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
52 changes: 47 additions & 5 deletions docs/users/features/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,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
model: inherit # Optional: inherit, fast, modelId, or authType:modelId
approvalMode: auto-edit # Optional: default, plan, auto-edit, yolo
tools: # Optional: allowlist of tools
- tool1
Expand All @@ -151,10 +151,48 @@ Multiple paragraphs are supported.

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
- `openai:gpt-4o`: Use a different provider (resolves credentials from env vars)
- `inherit`: Use the same model as the main conversation.
- Omit the field: Same as `inherit`.
- `fast`: Use the configured `fastModel`. If no valid fast model is configured,
the subagent falls back to `inherit`.
- `glm-5`: Use that model ID. Qwen Code first checks the main conversation's
auth type; if the model is not available there, it can resolve the model from
another configured provider.
- `openai:gpt-4o`: Use an explicit provider and model ID. This is useful when a
subagent should run on a model registered under a different auth type from the
main conversation.

For example:

```
---
name: fast-reviewer
description: Reviews small diffs with the configured fast model
model: fast
tools:
- read_file
- grep_search
---
```

```
---
name: openai-researcher
description: Uses an OpenAI-compatible provider for research tasks
model: openai:gpt-4o
tools:
- read_file
- grep_search
- glob
---
```

The `fast` selector uses the same `fastModel` setting configured in
`settings.json` or with `/model --fast`. That setting may itself refer to a
model under another configured auth type, such as `openai:deepseek-v4-flash`.
When the selector resolves to another auth type, Qwen Code creates a dedicated
runtime provider for that subagent request and sends the provider only the bare
model ID.

#### Permission Mode

Expand Down Expand Up @@ -620,6 +658,10 @@ Always follow these standards:

- **Tool Restrictions**: Use `tools` to limit which tools a subagent can access, or `disallowedTools` to block specific tools while inheriting everything else
- **Permission Mode**: Subagents inherit their parent's permission mode by default. Plan-mode sessions cannot escalate to auto-edit through delegated agents. Privileged modes (auto-edit, yolo) are blocked in untrusted folders.
- **Provider Selection**: A subagent with `model: authType:modelId`, or
`model: fast` where `fastModel` resolves to another auth type, sends that
subagent's model requests to the selected provider. Make sure that provider is
appropriate for the subagent's task and data.
- **Sandboxing**: All tool execution follows the same security model as direct tool use
- **Audit Trail**: All Subagents actions are logged and visible in real-time
- **Access Control**: Project and user-level separation provides appropriate boundaries
Expand Down
6 changes: 1 addition & 5 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1899,20 +1899,16 @@ export const AppContainer = (props: AppContainerProps) => {
const fullHistory = geminiClient.getChat().getHistory(true);
const conversationHistory =
fullHistory.length > 40 ? fullHistory.slice(-40) : fullHistory;
const fastModel = config.getFastModel();
generatePromptSuggestion(config, conversationHistory, ac.signal, {
enableCacheSharing: settings.merged.ui?.enableCacheSharing === true,
model: fastModel,
})
.then((result) => {
if (ac.signal.aborted) return;
if (result.suggestion) {
setPromptSuggestion(result.suggestion);
// Start speculation if enabled (runs in background)
if (settings.merged.ui?.enableSpeculation) {
startSpeculation(config, result.suggestion, ac.signal, {
model: fastModel,
})
startSpeculation(config, result.suggestion, ac.signal)
.then((state) => {
speculationRef.current = state;
})
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/commands/recapCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ export const recapCommand: SlashCommand = {
if (context.executionMode === 'interactive') {
const item: HistoryItemAwayRecap = {
type: 'away_recap',
text: recap.text,
text: recap,
};
context.ui.addItem(item, Date.now());
return;
}

return { type: 'message', messageType: 'info', content: recap.text };
return { type: 'message', messageType: 'info', content: recap };
},
};
10 changes: 2 additions & 8 deletions packages/cli/src/ui/hooks/useAwaySummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,7 @@ describe('useAwaySummary', () => {
const recordSlashCommand = vi.fn();
const config = makeConfig(recordSlashCommand);
const addItem = vi.fn();
generateSessionRecapMock.mockResolvedValue({
text: 'recap text',
modelUsed: 'fast',
});
generateSessionRecapMock.mockResolvedValue('recap text');

// Mount blurred to set the away-start timestamp.
const { rerender } = renderHook(
Expand Down Expand Up @@ -104,10 +101,7 @@ describe('useAwaySummary', () => {
const recordSlashCommand = vi.fn();
const config = makeConfig(recordSlashCommand);
const addItem = vi.fn();
generateSessionRecapMock.mockResolvedValue({
text: 'should not appear',
modelUsed: 'fast',
});
generateSessionRecapMock.mockResolvedValue('should not appear');

const historyWithRecentRecap: HistoryItem[] = [
...THREE_USER_HISTORY,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/hooks/useAwaySummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void {
if (!isIdleRef.current) return;
const item: HistoryItemAwayRecap = {
type: 'away_recap',
text: recap.text,
text: recap,
};
addItem(item, Date.now());

Expand Down
25 changes: 7 additions & 18 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ describe('Server Config (config.ts)', () => {
});

describe('model switching with different credentials (OpenAI)', () => {
it('keeps getFastModel current-auth-only for direct runtime callers', () => {
it('returns a bare fast model selector when the model is configured under another auth type', () => {
const config = new Config({
...baseParams,
authType: AuthType.USE_ANTHROPIC,
Expand All @@ -893,11 +893,10 @@ describe('Server Config (config.ts)', () => {
},
});

expect(config.getFastModel()).toBeUndefined();
expect(config.getFastModelForSideQuery()).toBe('deepseek-v4-flash');
expect(config.getFastModel()).toBe('deepseek-v4-flash');
});

it('returns an authType-qualified fast model selector for side queries', () => {
it('returns an authType-qualified fast model selector', () => {
const config = new Config({
...baseParams,
authType: AuthType.USE_ANTHROPIC,
Expand All @@ -923,11 +922,10 @@ describe('Server Config (config.ts)', () => {
},
});

expect(config.getFastModel()).toBeUndefined();
expect(config.getFastModelForSideQuery()).toBe('openai:shared-model');
expect(config.getFastModel()).toBe('openai:shared-model');
});

it('returns a bare fast model for getFastModel when authType-qualified selector matches the current auth type', () => {
it('keeps authType-qualified selectors when the auth type matches the current auth type', () => {
const config = new Config({
...baseParams,
authType: AuthType.USE_OPENAI,
Expand All @@ -945,10 +943,7 @@ describe('Server Config (config.ts)', () => {
},
});

expect(config.getFastModel()).toBe('deepseek-v4-flash');
expect(config.getFastModelForSideQuery()).toBe(
'openai:deepseek-v4-flash',
);
expect(config.getFastModel()).toBe('openai:deepseek-v4-flash');
});

it('accepts runtime fast models for authType-qualified selectors', () => {
Expand Down Expand Up @@ -979,10 +974,7 @@ describe('Server Config (config.ts)', () => {
});
config.getModelsConfig().detectAndCaptureRuntimeModel();

expect(config.getFastModel()).toBe('runtime-fast-model');
expect(config.getFastModelForSideQuery()).toBe(
'openai:runtime-fast-model',
);
expect(config.getFastModel()).toBe('openai:runtime-fast-model');
});

it('returns undefined when the fast model is not configured for any auth type', () => {
Expand All @@ -1004,7 +996,6 @@ describe('Server Config (config.ts)', () => {
});

expect(config.getFastModel()).toBeUndefined();
expect(config.getFastModelForSideQuery()).toBeUndefined();
});

it('returns undefined when the fast model selector is malformed', () => {
Expand All @@ -1026,7 +1017,6 @@ describe('Server Config (config.ts)', () => {
});

expect(config.getFastModel()).toBeUndefined();
expect(config.getFastModelForSideQuery()).toBeUndefined();
});

it('returns undefined when fastModel points back to the fast selector', () => {
Expand All @@ -1048,7 +1038,6 @@ describe('Server Config (config.ts)', () => {
});

expect(config.getFastModel()).toBeUndefined();
expect(config.getFastModelForSideQuery()).toBeUndefined();
});

it('should refresh auth when switching to model with different envKey', async () => {
Expand Down
51 changes: 17 additions & 34 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1861,52 +1861,35 @@ export class Config {
}

/**
* Returns the fast model if one is configured and valid for the current auth
* type, otherwise returns undefined. Direct runtime paths use this as a
* cheaper alternative to the main session model, so it intentionally stays
* current-auth-only.
* Returns the configured fast model selector when it resolves to an available
* model. Bare selectors stay bare and authType-qualified selectors keep their
* authType prefix so selector-aware runtime paths can route cross-auth calls.
*/
getFastModel(): string | undefined {
const authType =
this.contentGeneratorConfig?.authType ??
this.modelsConfig.getCurrentAuthType();
if (!authType) return undefined;
const selector = this.resolveFastModelSelector();
if (!selector) return undefined;
Comment thread
tanzhenxin marked this conversation as resolved.
if (selector.authType && selector.authType !== authType) return undefined;

const available = this.getAllConfiguredModels([authType]);
return available.some((m) => m.id === selector.modelId)
? selector.modelId
: undefined;
}

/**
* Returns the fast model for side-query paths. Unlike {@link getFastModel},
* this can return an authType-qualified selector because BaseLlmClient can
* route a single request through a provider different from the main session.
*/
getFastModelForSideQuery(): string | undefined {
const selector = this.resolveFastModelSelector();
if (!selector) return undefined;

if (selector.authType) {
const available = this.getAllConfiguredModels([selector.authType]);
return available.some((m) => m.id === selector.modelId)
? `${selector.authType}:${selector.modelId}`
: undefined;
const available = selector.authType
? this.getAllConfiguredModels([selector.authType])
: this.getAllConfiguredModels();
if (!available.some((m) => m.id === selector.modelId)) {
return undefined;
Comment thread
tanzhenxin marked this conversation as resolved.
}

const available = this.getAllConfiguredModels();
return available.some((m) => m.id === selector.modelId)
? selector.modelId
: undefined;
const rawSelector = resolveModelId(this.fastModel);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness bug: double-parsing this.fastModel with mismatched context

resolveFastModelSelector() (above) calls resolveModelId(this.fastModel, { currentAuthType, getAvailableModels }) — with full context. For a bare model ID, resolveAuthTypeForBareModel may populate selector.authType (e.g., gemini) when the model is found under the current auth type.

This line re-parses this.fastModel without context. For a bare model ID, resolveAuthTypeForBareModel now sees no currentAuthType and falls through to getAvailableModels(), which returns all models with qwen-oauth ordered first (see modelsConfig.getAllConfiguredModels).

Concrete failure scenario:

  • fastModel = "qwen-fast" (bare), current auth = gemini
  • qwen-fast registered under both qwen-oauth and gemini
  • Validation: resolveFastModelSelector → checks getAllConfiguredModels([gemini]) → found ✓; selector.authType is undefined (bare input)
  • Availability: getAllConfiguredModels() (no filter) → found ✓
  • Formatting: resolveModelId("qwen-fast") (no context) → find() returns qwen-oauth entry (ordered first) → returns "qwen-oauth:qwen-fast"

The returned string routes to qwen-oauth even though validation was against gemini. This contradicts the docstring ("Bare selectors stay bare") and may send the request to the wrong provider.

Suggested fix: Use selector.authType (from the contextualized resolution) for the prefix decision, or re-parse consistently:

return selector.authType
  ? `${selector.authType}:${selector.modelId}`
  : selector.modelId;

This keeps the formatting aligned with the validation that already happened above.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] resolveModelId(this.fastModel) here is called WITHOUT any context — no currentAuthType, no getAvailableModels. In resolveAuthTypeForBareModel (modelId.ts:112-126), without context, both currentAuthType and getAvailableModels are undefined, so the auth type lookup always fails for bare models, returning undefined.

Meanwhile, resolveFastModelSelector() at line 1888 already correctly resolved the same this.fastModel WITH full context (currentAuthType + getAvailableModels). If a bare fast model is registered under e.g. openai but the session uses gemini, the first parse returns { authType: 'openai', modelId: 'my-model' }, but the second contextless parse returns { modelId: 'my-model' } with no authType.

Result: getFastModel() returns bare "my-model" instead of "openai:my-model", losing the cross-auth info that was already correctly computed. Every downstream caller must independently re-resolve via resolveModelId with context. If any caller forgets to wire getAvailableModels, cross-auth routing silently fails.

Suggested change
const rawSelector = resolveModelId(this.fastModel);
return selector.authType
? `${selector.authType}:${selector.modelId}`
: selector.modelId;

This uses the already-resolved selector directly, avoiding the redundant contextless parse and correctly propagating the auth type.

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

return rawSelector?.authType
? `${rawSelector.authType}:${selector.modelId}`
Comment thread
tanzhenxin marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this.fastModel gets parsed twice on every call.

resolveFastModelSelector() already parsed this.fastModel with full context (returning { authType, modelId }). Then immediately resolveModelId(this.fastModel) re-parses it bare — solely to recover whether the user originally wrote an authType: prefix, so the return value can preserve their preference.

A cleaner shape: have the parser hand back a userQualified: boolean flag (or have resolveFastModelSelector return it alongside the resolved selector), and branch off that. Today this works correctly but reads like a workaround, and it makes a hot path (every side query → getFastModel()) do redundant parsing.

: selector.modelId;
}

private resolveFastModelSelector() {
if (!this.fastModel) return undefined;
try {
return resolveModelId(this.fastModel);
return resolveModelId(this.fastModel, {
currentAuthType: this.getContentGeneratorConfig()?.authType,
getAvailableModels: (authTypes) =>
this.getAllConfiguredModels(authTypes),
});
} catch {
return undefined;
}
Expand Down
70 changes: 66 additions & 4 deletions packages/core/src/core/baseLlmClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,16 @@ describe('BaseLlmClient', () => {
getEmbeddingModel: vi.fn().mockReturnValue('test-embedding-model'),
getModel: vi.fn().mockReturnValue('main-model'),
getFastModel: vi.fn().mockReturnValue(undefined),
getFastModelForSideQuery: vi.fn().mockReturnValue(undefined),
getAllConfiguredModels: vi.fn((authTypes?: AuthType[]) =>
authTypes?.includes(AuthType.QWEN_OAUTH)
? []
: [
{
id: fastModel,
authType: AuthType.USE_ANTHROPIC,
},
],
),
getModelsConfig: vi.fn().mockReturnValue({ getResolvedModel }),
} as unknown as Mocked<Config>;
});
Expand All @@ -575,6 +584,29 @@ describe('BaseLlmClient', () => {
expect(mockCreateContentGenerator).not.toHaveBeenCalled();
});

it('returns the active runtime generator when model matches the runtime view', async () => {
const runtimeContentGenerator = {
generateContent: vi.fn(),
embedContent: vi.fn(),
} as unknown as Mocked<ContentGenerator>;
crossProviderConfig.getContentGenerator = vi
.fn()
.mockReturnValue(runtimeContentGenerator);
vi.mocked(crossProviderConfig.getContentGeneratorConfig).mockReturnValue({
authType: AuthType.USE_OPENAI,
model: 'runtime-model',
});
vi.mocked(crossProviderConfig.getModel).mockReturnValue('runtime-model');
const c = new BaseLlmClient(mockContentGenerator, crossProviderConfig);

const resolved = await c.resolveForModel('runtime-model');

expect(resolved.contentGenerator).toBe(runtimeContentGenerator);
expect(resolved.retryAuthType).toBe(AuthType.USE_OPENAI);
expect(getResolvedModel).not.toHaveBeenCalled();
expect(mockCreateContentGenerator).not.toHaveBeenCalled();
});

it('builds a per-model generator when model differs and is registered under another authType', async () => {
// Main authType is QWEN_OAUTH; fast model only resolves under USE_ANTHROPIC.
getResolvedModel.mockImplementation((authType: string, model: string) => {
Expand Down Expand Up @@ -646,6 +678,38 @@ describe('BaseLlmClient', () => {
expect(mockCreateContentGenerator).not.toHaveBeenCalled();
});

it('does not cache the unregistered-model fallback across runtime-view changes', async () => {
// Unregistered selector: createContentGeneratorForModel falls back to
// getCurrentContentGenerator(). The runtime view changes between calls
// — caching would pin the first call's generator under the selector
// key and return it on the second call after the view has unwound.
getResolvedModel.mockReturnValue(undefined);

const firstRuntimeGenerator = {
generateContent: vi.fn(),
embedContent: vi.fn(),
} as unknown as Mocked<ContentGenerator>;
const secondRuntimeGenerator = {
generateContent: vi.fn(),
embedContent: vi.fn(),
} as unknown as Mocked<ContentGenerator>;
const getContentGenerator = vi
.fn()
.mockReturnValueOnce(firstRuntimeGenerator)
.mockReturnValueOnce(secondRuntimeGenerator);
crossProviderConfig.getContentGenerator = getContentGenerator;

const c = new BaseLlmClient(mockContentGenerator, crossProviderConfig);

const first = await c.resolveForModel('unknown-model');
const second = await c.resolveForModel('unknown-model');

expect(first.contentGenerator).toBe(firstRuntimeGenerator);
expect(second.contentGenerator).toBe(secondRuntimeGenerator);
expect(getContentGenerator).toHaveBeenCalledTimes(2);
expect(mockCreateContentGenerator).not.toHaveBeenCalled();
});

it('falls back to the main generator when createContentGenerator throws', async () => {
getResolvedModel.mockReturnValue({
authType: AuthType.USE_ANTHROPIC,
Expand Down Expand Up @@ -738,9 +802,7 @@ describe('BaseLlmClient', () => {
});

it('generateJson resolves fast selectors through the configured fast model', async () => {
crossProviderConfig.getFastModelForSideQuery.mockReturnValue(
'openai:shared-model',
);
crossProviderConfig.getFastModel.mockReturnValue('openai:shared-model');
getResolvedModel.mockImplementation((authType: string, model: string) => {
if (authType === AuthType.USE_OPENAI && model === 'shared-model') {
return {
Expand Down
Loading
Loading