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.
This commit is contained in:
tanzhenxin 2026-04-13 17:57:41 +08:00 committed by GitHub
parent 189df1b098
commit 9a889dc614
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 250 additions and 8 deletions

View file

@ -251,6 +251,7 @@ export async function runNonInteractive(
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
let isFirstTurn = true;
let modelOverride: string | undefined;
while (true) {
turnCount++;
if (
@ -270,6 +271,7 @@ export async function runNonInteractive(
type: isFirstTurn
? SendMessageType.UserQuery
: SendMessageType.ToolResult,
modelOverride,
},
);
isFirstTurn = false;
@ -368,6 +370,13 @@ export async function runNonInteractive(
if (toolResponse.responseParts) {
toolResponseParts.push(...toolResponse.responseParts);
}
// Capture model override from skill tool results.
// Use `in` so that undefined (from inherit/no-model skills) clears a prior override,
// while non-skill tools (field absent) leave the current override intact.
if ('modelOverride' in toolResponse) {
modelOverride = toolResponse.modelOverride;
}
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
@ -400,6 +409,7 @@ export async function runNonInteractive(
{ role: 'user', parts: [{ text: cronPrompt }] },
];
let cronIsFirstTurn = true;
let cronModelOverride: string | undefined;
while (true) {
const cronToolCallRequests: ToolCallRequestInfo[] = [];
@ -412,6 +422,7 @@ export async function runNonInteractive(
type: cronIsFirstTurn
? SendMessageType.Cron
: SendMessageType.ToolResult,
modelOverride: cronModelOverride,
},
);
cronIsFirstTurn = false;
@ -476,6 +487,10 @@ export async function runNonInteractive(
...toolResponse.responseParts,
);
}
if ('modelOverride' in toolResponse) {
cronModelOverride = toolResponse.modelOverride;
}
}
cronMessages = [
{ role: 'user', parts: cronToolResponseParts },