feat(subagents): use fastModel for Explore subagent (#4086)

Adds a "fast" keyword to the subagent model selector. When set, the
runtime resolves it via Config.getFastModel() under the parent authType,
falling back to inheriting the parent model when fastModel is unset or
invalid for the current authType. The built-in Explore subagent opts in,
so a configured fastModel automatically powers codebase searches without
affecting other subagents or the main session.
This commit is contained in:
tanzhenxin 2026-05-13 16:27:25 +08:00 committed by GitHub
parent 613fc42c89
commit 5a1c427d6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 130 additions and 3 deletions

View file

@ -54,6 +54,7 @@ Notes:
name: 'Explore',
description:
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.',
model: 'fast',
systemPrompt: `You are a file search specialist agent. You excel at thoroughly navigating and exploring codebases.
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===

View file

@ -49,4 +49,29 @@ describe('parseSubagentModelSelection', () => {
inherits: false,
});
});
it('parses the fast keyword', () => {
expect(parseSubagentModelSelection('fast')).toEqual({
inherits: false,
usesFastModel: true,
});
});
it('parses the fast keyword with surrounding whitespace', () => {
expect(parseSubagentModelSelection(' fast ')).toEqual({
inherits: false,
usesFastModel: true,
});
});
it('treats model IDs that merely contain "fast" as bare IDs, not the keyword', () => {
expect(parseSubagentModelSelection('qwen3-coder-flash')).toEqual({
modelId: 'qwen3-coder-flash',
inherits: false,
});
expect(parseSubagentModelSelection('Fast')).toEqual({
modelId: 'Fast',
inherits: false,
});
});
});

View file

@ -10,6 +10,12 @@ export interface ParsedSubagentModelSelection {
authType?: AuthType;
modelId?: string;
inherits: boolean;
/**
* True when the selector was `fast` the runtime resolves this to
* `Config.getFastModel()` if a valid fast model is configured, and
* falls back to inheriting the parent model otherwise.
*/
usesFastModel?: boolean;
}
const AUTH_TYPES = new Set<AuthType>(Object.values(AuthType));
@ -19,6 +25,7 @@ const AUTH_TYPES = new Set<AuthType>(Object.values(AuthType));
*
* Supported forms:
* - omitted / inherit -> use parent conversation model
* - fast -> use Config.getFastModel() if available, else inherit parent model
* - modelId -> use parent authType with the provided modelId
* - authType:modelId -> use explicit authType and modelId
*/
@ -30,6 +37,10 @@ export function parseSubagentModelSelection(
return { inherits: true };
}
if (trimmed === 'fast') {
return { inherits: false, usesFastModel: true };
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex === -1) {
return { modelId: trimmed, inherits: false };

View file

@ -1397,6 +1397,40 @@ System prompt 3`);
);
expect(runtimeConfig.modelConfig.model).toBe('gpt-4');
});
it('should resolve "fast" to Config.getFastModel() when one is configured', async () => {
const fastConfig: SubagentConfig = { ...validConfig, model: 'fast' };
vi.spyOn(mockConfig, 'getFastModel').mockReturnValue('fast-model-id');
const runtimeConfig = await manager.convertToRuntimeConfig(
fastConfig,
mockConfig,
);
expect(runtimeConfig.modelConfig.model).toBe('fast-model-id');
});
it('should leave modelConfig empty for "fast" when getFastModel returns undefined', async () => {
// Mirrors the unset / invalid-for-authType cases — AgentCore then
// falls back to runtimeContext.getModel() (the parent model).
const fastConfig: SubagentConfig = { ...validConfig, model: 'fast' };
vi.spyOn(mockConfig, 'getFastModel').mockReturnValue(undefined);
const runtimeConfig = await manager.convertToRuntimeConfig(
fastConfig,
mockConfig,
);
expect(runtimeConfig.modelConfig).toEqual({});
});
it('should leave modelConfig empty for "fast" when no runtimeContext is provided', async () => {
const fastConfig: SubagentConfig = { ...validConfig, model: 'fast' };
const runtimeConfig = await manager.convertToRuntimeConfig(fastConfig);
expect(runtimeConfig.modelConfig).toEqual({});
});
});
describe('mergeConfigurations', () => {
@ -1525,6 +1559,35 @@ System prompt 3`);
expect(runtimeView!.contentGenerator).toBe(fakeGenerator);
expect(runtimeView!.contentGeneratorConfig.model).toBe('custom-model');
});
it('should build a ContentGenerator with the resolved fastModel when model is "fast"', async () => {
const config = { ...agentConfig, model: 'fast' };
vi.spyOn(mockConfig, 'getFastModel').mockReturnValue('fast-model-id');
await manager.createAgentHeadless(config, mockConfig);
expect(mockCreateContentGenerator).toHaveBeenCalledWith(
expect.objectContaining({
model: 'fast-model-id',
authType: AuthType.USE_OPENAI,
}),
mockConfig,
);
});
it('should NOT build a new ContentGenerator for "fast" when getFastModel returns undefined', async () => {
const config = { ...agentConfig, model: 'fast' };
vi.spyOn(mockConfig, 'getFastModel').mockReturnValue(undefined);
await manager.createAgentHeadless(config, mockConfig);
// Falls back to inheriting the parent — no override, no runtimeView.
expect(mockCreateContentGenerator).not.toHaveBeenCalled();
const { runtimeView } = destructureAgentHeadlessCall(
mockAgentHeadlessCreate.mock.calls[0],
);
expect(runtimeView).toBeUndefined();
});
});
});
});

View file

@ -638,7 +638,10 @@ export class SubagentManager {
},
): Promise<AgentHeadless> {
try {
const runtimeConfig = await this.convertToRuntimeConfig(config);
const runtimeConfig = await this.convertToRuntimeConfig(
config,
runtimeContext,
);
const promptConfig: PromptConfig = {
...runtimeConfig.promptConfig,
...options?.promptConfigOverrides,
@ -741,6 +744,20 @@ export class SubagentManager {
return undefined;
}
let resolvedModelId = selection.modelId;
if (selection.usesFastModel) {
// getFastModel() returns the fastModel id only when it's valid for
// the current authType; otherwise undefined. Treat undefined as
// inherit so an unset or invalid fastModel silently falls back to
// the parent session model — matching every other getFastModel()
// call site (ForkedAgent, sessionTitle, etc.).
const fast = base.getFastModel();
if (!fast) {
return undefined;
}
resolvedModelId = fast;
}
const authType =
selection.authType ?? base.getContentGeneratorConfig().authType;
const authOverrides = {
@ -750,7 +767,7 @@ export class SubagentManager {
const view = await createRuntimeContentGeneratorView(
base,
base,
selection.modelId,
resolvedModelId,
authOverrides,
);
@ -770,14 +787,22 @@ export class SubagentManager {
*/
async convertToRuntimeConfig(
config: SubagentConfig,
runtimeContext?: Config,
): Promise<SubagentRuntimeConfig> {
const promptConfig: PromptConfig = {
systemPrompt: config.systemPrompt,
};
const selection = parseSubagentModelSelection(config.model);
let resolvedModelId = selection.modelId;
if (selection.usesFastModel && runtimeContext) {
// Resolve `fast` to the configured fastModel. Undefined here means
// either unset or invalid for the current authType — leave modelConfig
// empty so the agent inherits the parent model (same as `inherit`).
resolvedModelId = runtimeContext.getFastModel();
}
const modelConfig: ModelConfig = {
...(selection.modelId ? { model: selection.modelId } : {}),
...(resolvedModelId ? { model: resolvedModelId } : {}),
};
const runConfig: RunConfig = {

View file

@ -83,6 +83,8 @@ export interface SubagentConfig {
/**
* Optional model selector.
* - Omitted or 'inherit': use the main conversation model
* - 'fast': use Config.getFastModel() under the parent authType when
* configured and valid; silently inherit the parent model otherwise
* - 'model-id': use the given model with the main conversation authType
* - 'authType:model-id': use the given authType and model ID
*/