Skip to content

Commit 9a889dc

Browse files
authored
feat(skills): add model override support via skill frontmatter (#2949)
* feat(skills): add model override support via skill frontmatter Allow skills to specify a `model` field in YAML frontmatter to override which model is used for subsequent turns within the same agentic loop. The override flows through ToolResult → ToolCallResponseInfo → SendMessageOptions and naturally expires when the loop ends. Resolves #2052 * fix(core): only include modelOverride in response when defined Fixes strict equality test failures in nonInteractiveToolExecutor.test.ts where the extra undefined modelOverride field caused object mismatch. * fix(skills): fix model override pipeline issues - Wire up modelOverride in interactive CLI path (useGeminiStream) - Fix inherit/no-model unable to clear a prior override by using 'in' operator instead of truthiness checks in scheduler and CLI - Reject empty/whitespace model strings in parseModelField() - Extract shared parseModelField() to deduplicate skill-load and skill-manager parsing logic - Propagate modelOverride through stop-hook continuation in client * fix(skills): persist model override across turns in interactive and cron paths The interactive path stored the skill model override in a local variable, causing it to be lost when subsequent non-skill tool turns ran. Use a ref to persist the override for the duration of the agentic loop, resetting on new user messages. Also propagate modelOverride in the cron execution loop for consistency with the main non-interactive path. * fix(skills): preserve model override on retry and add unit tests Retry in interactive mode was clearing modelOverrideRef, causing the skill-selected model to silently fall back to session default. Guard the reset so retries preserve the active override. Add unit tests for parseModelField (edge cases, type validation) and modelOverride propagation through the skill tool result path.
1 parent 189df1b commit 9a889dc

File tree

12 files changed

+250
-8
lines changed

12 files changed

+250
-8
lines changed

packages/cli/src/nonInteractiveCli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export async function runNonInteractive(
251251
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
252252

253253
let isFirstTurn = true;
254+
let modelOverride: string | undefined;
254255
while (true) {
255256
turnCount++;
256257
if (
@@ -270,6 +271,7 @@ export async function runNonInteractive(
270271
type: isFirstTurn
271272
? SendMessageType.UserQuery
272273
: SendMessageType.ToolResult,
274+
modelOverride,
273275
},
274276
);
275277
isFirstTurn = false;
@@ -368,6 +370,13 @@ export async function runNonInteractive(
368370
if (toolResponse.responseParts) {
369371
toolResponseParts.push(...toolResponse.responseParts);
370372
}
373+
374+
// Capture model override from skill tool results.
375+
// Use `in` so that undefined (from inherit/no-model skills) clears a prior override,
376+
// while non-skill tools (field absent) leave the current override intact.
377+
if ('modelOverride' in toolResponse) {
378+
modelOverride = toolResponse.modelOverride;
379+
}
371380
}
372381
currentMessages = [{ role: 'user', parts: toolResponseParts }];
373382
} else {
@@ -400,6 +409,7 @@ export async function runNonInteractive(
400409
{ role: 'user', parts: [{ text: cronPrompt }] },
401410
];
402411
let cronIsFirstTurn = true;
412+
let cronModelOverride: string | undefined;
403413

404414
while (true) {
405415
const cronToolCallRequests: ToolCallRequestInfo[] = [];
@@ -412,6 +422,7 @@ export async function runNonInteractive(
412422
type: cronIsFirstTurn
413423
? SendMessageType.Cron
414424
: SendMessageType.ToolResult,
425+
modelOverride: cronModelOverride,
415426
},
416427
);
417428
cronIsFirstTurn = false;
@@ -476,6 +487,10 @@ export async function runNonInteractive(
476487
...toolResponse.responseParts,
477488
);
478489
}
490+
491+
if ('modelOverride' in toolResponse) {
492+
cronModelOverride = toolResponse.modelOverride;
493+
}
479494
}
480495
cronMessages = [
481496
{ role: 'user', parts: cronToolResponseParts },

packages/cli/src/ui/hooks/useGeminiStream.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export const useGeminiStream = (
237237
null,
238238
);
239239
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
240+
const modelOverrideRef = useRef<string | undefined>(undefined);
240241
const {
241242
startNewPrompt,
242243
getPromptCount,
@@ -1255,6 +1256,11 @@ export const useGeminiStream = (
12551256
!allowConcurrentBtwDuringResponse
12561257
) {
12571258
setModelSwitchedFromQuotaError(false);
1259+
// Clear model override for new user turns, but preserve it on retry
1260+
// so the same skill-selected model is used again.
1261+
if (submitType !== SendMessageType.Retry) {
1262+
modelOverrideRef.current = undefined;
1263+
}
12581264
// Commit any pending retry error to history (without hint) since the
12591265
// user is starting a new conversation turn.
12601266
// Clear both countdown-based errors AND static errors (those without
@@ -1354,7 +1360,7 @@ export const useGeminiStream = (
13541360
finalQueryToSend,
13551361
abortSignal,
13561362
prompt_id!,
1357-
{ type: submitType },
1363+
{ type: submitType, modelOverride: modelOverrideRef.current },
13581364
);
13591365

13601366
const processingStatus = await processGeminiStreamEvents(
@@ -1620,6 +1626,15 @@ export const useGeminiStream = (
16201626
(toolCall) => toolCall.request.prompt_id,
16211627
);
16221628

1629+
// Persist model override from skill tool results (last one wins).
1630+
// Uses `in` so that undefined (from inherit/no-model skills) clears a
1631+
// prior override, while non-skill tools (field absent) leave it intact.
1632+
for (const toolCall of geminiTools) {
1633+
if ('modelOverride' in toolCall.response) {
1634+
modelOverrideRef.current = toolCall.response.modelOverride;
1635+
}
1636+
}
1637+
16231638
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
16241639

16251640
// Don't continue if model was switched due to quota error

packages/core/src/core/client.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export interface SendMessageOptions {
109109
iterationCount: number;
110110
reasons: string[];
111111
};
112+
/** Model override from skill execution. When present, overrides the session model for this turn. */
113+
modelOverride?: string;
112114
}
113115

114116
export class GeminiClient {
@@ -667,6 +669,9 @@ export class GeminiClient {
667669

668670
const turn = new Turn(this.getChat(), prompt_id);
669671

672+
// Determine the model to use for this turn
673+
const model = options?.modelOverride ?? this.config.getModel();
674+
670675
// append system reminders to the request
671676
let requestToSent = await flatMapTextParts(request, async (text) => [text]);
672677
if (
@@ -709,11 +714,7 @@ export class GeminiClient {
709714
requestToSent = [...systemReminders, ...requestToSent];
710715
}
711716

712-
const resultStream = turn.run(
713-
this.config.getModel(),
714-
requestToSent,
715-
signal,
716-
);
717+
const resultStream = turn.run(model, requestToSent, signal);
717718
for await (const event of resultStream) {
718719
if (!this.config.getSkipLoopDetection()) {
719720
if (this.loopDetector.addAndCheck(event)) {
@@ -846,6 +847,7 @@ export class GeminiClient {
846847
prompt_id,
847848
{
848849
type: SendMessageType.Hook,
850+
modelOverride: options?.modelOverride,
849851
stopHookState: {
850852
iterationCount: currentIterationCount,
851853
reasons: currentReasons,

packages/core/src/core/coreToolScheduler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,6 +1602,11 @@ export class CoreToolScheduler {
16021602
error: undefined,
16031603
errorType: undefined,
16041604
contentLength,
1605+
// Propagate modelOverride from skill tools. Use `in` to distinguish
1606+
// "skill returned undefined (inherit)" from "non-skill tool (no field)".
1607+
...('modelOverride' in toolResult
1608+
? { modelOverride: toolResult.modelOverride }
1609+
: {}),
16051610
};
16061611
this.setStatusInternal(callId, 'success', successResponse);
16071612
} else {

packages/core/src/core/turn.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export interface ToolCallResponseInfo {
108108
error: Error | undefined;
109109
errorType: ToolErrorType | undefined;
110110
contentLength?: number;
111+
modelOverride?: string;
111112
}
112113

113114
export interface ServerToolCallConfirmationDetails {

packages/core/src/skills/skill-load.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
loadSkillsFromDir,
1111
validateConfig,
1212
} from './skill-load.js';
13+
import { parseModelField } from './types.js';
1314
import * as fs from 'fs/promises';
1415

1516
// Mock file system operations
@@ -300,4 +301,92 @@ Valid skill.
300301
expect(result.warnings).toContain('Skill body is empty');
301302
});
302303
});
304+
305+
describe('parseModelField', () => {
306+
it('should return the model string for a valid model', () => {
307+
expect(parseModelField({ model: 'qwen-max' })).toBe('qwen-max');
308+
});
309+
310+
it('should return undefined when model is omitted', () => {
311+
expect(parseModelField({})).toBeUndefined();
312+
});
313+
314+
it('should return undefined for "inherit"', () => {
315+
expect(parseModelField({ model: 'inherit' })).toBeUndefined();
316+
});
317+
318+
it('should return undefined for empty string', () => {
319+
expect(parseModelField({ model: '' })).toBeUndefined();
320+
});
321+
322+
it('should return undefined for whitespace-only string', () => {
323+
expect(parseModelField({ model: ' ' })).toBeUndefined();
324+
});
325+
326+
it('should trim whitespace from model string', () => {
327+
expect(parseModelField({ model: ' qwen-max ' })).toBe('qwen-max');
328+
});
329+
330+
it('should throw for non-string types', () => {
331+
expect(() => parseModelField({ model: 123 })).toThrow(
332+
'"model" must be a string',
333+
);
334+
expect(() => parseModelField({ model: true })).toThrow(
335+
'"model" must be a string',
336+
);
337+
});
338+
339+
it('should treat "inherit" case-sensitively', () => {
340+
expect(parseModelField({ model: 'Inherit' })).toBe('Inherit');
341+
expect(parseModelField({ model: 'INHERIT' })).toBe('INHERIT');
342+
});
343+
});
344+
345+
describe('parseSkillContent model field', () => {
346+
const testFilePath = '/test/extension/skills/model-test/SKILL.md';
347+
348+
it('should parse model from frontmatter', () => {
349+
mockParseYaml.mockReturnValue({
350+
name: 'model-test',
351+
description: 'Test skill with model',
352+
model: 'qwen-max',
353+
});
354+
355+
const config = parseSkillContent(
356+
`---\nname: model-test\ndescription: Test skill with model\nmodel: qwen-max\n---\n\nBody text.`,
357+
testFilePath,
358+
);
359+
360+
expect(config.model).toBe('qwen-max');
361+
});
362+
363+
it('should set model to undefined when omitted', () => {
364+
mockParseYaml.mockReturnValue({
365+
name: 'model-test',
366+
description: 'Test skill without model',
367+
});
368+
369+
const config = parseSkillContent(
370+
`---\nname: model-test\ndescription: Test skill without model\n---\n\nBody text.`,
371+
testFilePath,
372+
);
373+
374+
expect(config.model).toBeUndefined();
375+
});
376+
377+
it('should set model to undefined for "inherit"', () => {
378+
mockParseYaml.mockReturnValue({
379+
name: 'model-test',
380+
description: 'Test skill with inherit',
381+
model: 'inherit',
382+
});
383+
384+
const config = parseSkillContent(
385+
`---\nname: model-test\ndescription: Test skill with inherit\nmodel: inherit\n---\n\nBody text.`,
386+
testFilePath,
387+
);
388+
389+
expect(config.model).toBeUndefined();
390+
});
391+
});
303392
});

packages/core/src/skills/skill-load.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { SkillConfig, SkillValidationResult } from './types.js';
1+
import {
2+
type SkillConfig,
3+
type SkillValidationResult,
4+
parseModelField,
5+
} from './types.js';
26
import * as fs from 'fs/promises';
37
import * as path from 'path';
48
import { parse as parseYaml } from '../utils/yaml-parser.js';
@@ -108,10 +112,14 @@ export function parseSkillContent(
108112
}
109113
}
110114

115+
// Extract optional model field
116+
const model = parseModelField(frontmatter);
117+
111118
const config: SkillConfig = {
112119
name,
113120
description,
114121
allowedTools,
122+
model,
115123
filePath,
116124
body: body.trim(),
117125
level: 'extension',

packages/core/src/skills/skill-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
ListSkillsOptions,
1818
SkillValidationResult,
1919
} from './types.js';
20-
import { SkillError, SkillErrorCode } from './types.js';
20+
import { SkillError, SkillErrorCode, parseModelField } from './types.js';
2121
import type { Config } from '../config/config.js';
2222
import { validateConfig } from './skill-load.js';
2323
import { createDebugLogger } from '../utils/debugLogger.js';
@@ -396,10 +396,14 @@ export class SkillManager {
396396
}
397397
}
398398

399+
// Extract optional model field
400+
const model = parseModelField(frontmatter);
401+
399402
const config: SkillConfig = {
400403
name,
401404
description,
402405
allowedTools,
406+
model,
403407
level,
404408
filePath,
405409
body: body.trim(),

packages/core/src/skills/types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export interface SkillConfig {
3131
*/
3232
allowedTools?: string[];
3333

34+
/**
35+
* Optional model override for this skill's execution.
36+
* Uses the same selector syntax as subagent model selectors:
37+
* bare model ID (e.g., `qwen-coder-plus`), `authType:modelId`
38+
* for cross-provider, or omitted/`inherit` to use the session model.
39+
*/
40+
model?: string;
41+
3442
/**
3543
* Storage level - determines where the configuration file is stored
3644
*/
@@ -58,6 +66,27 @@ export interface SkillConfig {
5866
*/
5967
export type SkillRuntimeConfig = SkillConfig;
6068

69+
/**
70+
* Parse the `model` field from skill frontmatter.
71+
* Returns `undefined` for omitted, empty, or "inherit" values.
72+
*/
73+
export function parseModelField(
74+
frontmatter: Record<string, unknown>,
75+
): string | undefined {
76+
const raw = frontmatter['model'];
77+
if (raw === undefined) {
78+
return undefined;
79+
}
80+
if (typeof raw !== 'string') {
81+
throw new Error('"model" must be a string');
82+
}
83+
const trimmed = raw.trim();
84+
if (trimmed === '' || trimmed === 'inherit') {
85+
return undefined;
86+
}
87+
return trimmed;
88+
}
89+
6190
/**
6291
* Result of a validation operation on a skill configuration.
6392
*/

0 commit comments

Comments
 (0)