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

@ -237,6 +237,7 @@ export const useGeminiStream = (
null,
);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const modelOverrideRef = useRef<string | undefined>(undefined);
const {
startNewPrompt,
getPromptCount,
@ -1255,6 +1256,11 @@ export const useGeminiStream = (
!allowConcurrentBtwDuringResponse
) {
setModelSwitchedFromQuotaError(false);
// Clear model override for new user turns, but preserve it on retry
// so the same skill-selected model is used again.
if (submitType !== SendMessageType.Retry) {
modelOverrideRef.current = undefined;
}
// Commit any pending retry error to history (without hint) since the
// user is starting a new conversation turn.
// Clear both countdown-based errors AND static errors (those without
@ -1354,7 +1360,7 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
{ type: submitType },
{ type: submitType, modelOverride: modelOverrideRef.current },
);
const processingStatus = await processGeminiStreamEvents(
@ -1620,6 +1626,15 @@ export const useGeminiStream = (
(toolCall) => toolCall.request.prompt_id,
);
// Persist model override from skill tool results (last one wins).
// Uses `in` so that undefined (from inherit/no-model skills) clears a
// prior override, while non-skill tools (field absent) leave it intact.
for (const toolCall of geminiTools) {
if ('modelOverride' in toolCall.response) {
modelOverrideRef.current = toolCall.response.modelOverride;
}
}
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error