fix(smart-routing): stop overriding sub-agent models chosen by agentRouting (#2310)

* fix(smart-routing): stop overriding sub-agent models chosen by agentRouting

Smart routing classified the first pass of every query loop, including
the loop each sub-agent runs. A sub-agent's prompt is not a user turn, but
it was classified as one and the pinned model overwrote the model
agentRouting (or the agent definition) had just selected. With both
features on, every routed sub-agent silently ran on the strong model.

decideTurnModel now returns routed:false when the query loop belongs to a
sub-agent, so the parent turn's decision holds for its Agent tool call as
documented, and sub-agent runs no longer count toward the routing tally
or the per-session allowlist disable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(smart-routing): cover sub-agent query model preservation

* fix(smart-routing): preserve inherited fork models

* fix(smart-routing): preserve parent request selection in forks

* fix(smart-routing): preserve selection across context boundaries

* fix(smart-routing): keep inherited runtime and request selections paired

* fix: preserve fork selections across tool and provider changes

* fix: preserve model selections across asynchronous context lifetimes

* fix: preserve child model policy and recovery origin

* fix: validate skill admission before effects and retain recovery ownership

* fix: establish ambient fork models against the current provider

* fix: preserve assigned child models in plan mode

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jatmn <the@jat.mn>
This commit is contained in:
pluginslab
2026-10-26 20:24:01 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 jatmn
parent 4e746935f8
commit ec39d807c0
12 changed files with 1409 additions and 19 deletions
+3
View File
@@ -51,7 +51,10 @@ These set a startup default. An explicit `smartRouting` block in settings always
## Behavior notes
- **One decision per turn.** The model is chosen once when your message arrives and held for the whole turn (including its tool calls), so it does not flap mid-turn.
- **Sub-agents are not routed.** A sub-agent's prompt is not a user turn, so smart routing leaves its model alone: it uses its [`agentRouting`](agent-routing.md) route or agent-definition model, and otherwise inherits the turn's model. Default internal forks (such as `/btw` and SDK side questions) inherit the parent's accepted provider request selection, including explicit reasoning settings, across tool-list refreshes. The implicit Agent-tool fork supplies its own model options and does not inherit this request selection. After an active provider switch, including one during compaction or following automatic recovery, ambient forks use the new provider's configured model; explicit provider overrides remain fixed. Side questions without a saved parent request use the configured model selection; allowed explicit agent, hook, and skill model choices take precedence. These explicit choices still obey `availableModels`; inherited/default choices retain their existing policy. Sub-agent runs are not counted in the `/cost` routing summary.
- **Upgrade note.** Explicit agent-definition, inline-skill, or hook models excluded by `availableModels` are rejected. If a configuration previously relied on such a choice, select an allowed model or have the administrator update the allowlist.
- **Fallback.** If a simple-routed turn's model call errors (transport or server error), it retries once on the strong model. Aborts and auth/permission/bad-request errors are not retried.
- **Model limits still apply.** Inherited request aliases and reasoning settings do not replace the concrete model used for context limits, session context-window overrides, or compaction.
- **Allowlist.** Any model smart routing selects is checked against your org model allowlist (`availableModels`). A disallowed model is coerced to strong; if strong is also disallowed, routing disables itself for the session and the default model is used. Running `/smartroute on` re-enables routing and clears that session disable.
- **Same-provider only.** Roles must be model-only `agentModels` entries (or bare model ids). If a role resolves to a cross-provider entry (one with `base_url`/`api_key`), routing silently disables — cross-provider routing is not supported yet.
- **Auditing.** `/cost` shows a routing summary: how many turns went simple vs strong, how many escalated to strong via fallback, and an estimated savings line when both models appear in the first-party reference pricing table. That estimate is reference pricing only and may not reflect what your provider/gateway/account actually bills.
+19
View File
@@ -201,6 +201,25 @@ export type ToolUseContext = {
/** Per-agent provider override from agentRouting config */
providerOverride?: { model: string; baseURL: string; apiKey: string }
}
/** Shared by copies with the same options; internal options clones retain separate snapshots. */
queryModelSelection?: {
agentId: ToolUseContext['agentId']
/** Inherited selection; accepted requests replace it within this options snapshot. */
current?: {
/** Identity and value invalidate provenance after an explicit model override. */
options: ToolUseContext['options']
model: string
/** Selection before routing/resolution, to detect later configuration changes. */
selectedModel: string
/** Concrete model used for the request, independent of source options. */
runtimeModel: string
requestModel: string
/** Provider category and optional saved profile; fixed overrides are independent. */
providerKey: string
/** Default ambient inheritance may follow a provider switch; explicit model choices may not. */
followsProvider: boolean
}
}
abortController: AbortController
readFileState: FileStateCache
getAppState(): AppState
+21 -2
View File
@@ -18,8 +18,9 @@ import type { Message } from '../../types/message.js';
import { createAbortController } from '../../utils/abortController.js';
import { saveGlobalConfig } from '../../utils/config.js';
import { errorMessage } from '../../utils/errors.js';
import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js';
import { type CacheSafeParams, getLastCacheSafeParams, getQueryModelSelection } from '../../utils/forkedAgent.js';
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
import { getDefaultMainLoopModelSetting, parseUserSpecifiedModel } from '../../utils/model/model.js';
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js';
import { runSideQuestion } from '../../utils/sideQuestion.js';
import { asSystemPrompt } from '../../utils/systemPromptType.js';
@@ -209,11 +210,29 @@ async function buildCacheSafeParams(context: ProcessUserInputContext): Promise<C
const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages));
const saved = getLastCacheSafeParams();
if (saved) {
const parent = saved.toolUseContext;
const selection = getQueryModelSelection(parent);
let toolUseContext = context;
// Commands get fresh options. Carry the accepted request only while both
// its source and the current configured selection are still unchanged.
if (!context.agentId && !context.queryModelSelection && selection) {
const state = context.getAppState();
const selectedModel = state.mainLoopModelForSession ?? state.mainLoopModel ?? getDefaultMainLoopModelSetting();
if (selectedModel === selection.selectedModel && context.options.mainLoopModel === parseUserSpecifiedModel(selectedModel)) {
toolUseContext = {
...context,
queryModelSelection: {
agentId: context.agentId,
current: { ...selection, options: context.options, model: context.options.mainLoopModel }
}
};
}
}
return {
systemPrompt: saved.systemPrompt,
userContext: saved.userContext,
systemContext: saved.systemContext,
toolUseContext: context,
toolUseContext,
forkContextMessages
};
}
+85 -12
View File
@@ -102,6 +102,7 @@ import {
} from './utils/messageQueueManager.js'
import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
import { getAmbientQueryProviderKey, getOwnedQueryModelSelection, getQueryModelSelection, refreshAmbientQueryModelSelection } from './utils/forkedAgent.js'
import {
getDefaultMainLoopModelSetting,
getProviderRequestModel,
@@ -664,6 +665,16 @@ export async function* query(
Terminal
> {
const consumedCommandUuids: string[] = []
// Query clones its context; retained parent references must see the request
// selection too (for post-turn cache-sharing forks).
// Hooks can shallow-copy a context without using createSubagentContext.
// Preserve sharing within this agent, never across an agent boundary.
if (
!params.toolUseContext.queryModelSelection ||
params.toolUseContext.queryModelSelection.agentId !== params.toolUseContext.agentId
) {
params.toolUseContext.queryModelSelection = { agentId: params.toolUseContext.agentId }
}
const terminal = yield* queryLoop(params, consumedCommandUuids)
// Only reached if queryLoop returned normally. Skipped on throw (error
// propagates through yield*) and on .return() (Return completion closes
@@ -842,6 +853,9 @@ async function* queryLoop(
// is reassigned within an iteration (queryTracking, messages updates);
// the rest are read-only between continue sites.
let { toolUseContext } = state
// Existing forks can outlive a provider switch. Refresh before compaction,
// capability checks, or request selection consume their concrete model.
toolUseContext = refreshAmbientQueryModelSelection(toolUseContext)
const {
messages,
autoCompactTracking,
@@ -1233,6 +1247,12 @@ async function* queryLoop(
}
}
// Compaction can await while the active provider changes. Refresh before
// request selection and the tool executor capture this context.
toolUseContext = refreshAmbientQueryModelSelection(toolUseContext)
const followsProvider = !toolUseContext.agentId ||
getQueryModelSelection(toolUseContext)?.followsProvider === true
//TODO: no need to set toolUseContext.messages during set-up since it is updated here
toolUseContext = {
...toolUseContext,
@@ -1265,19 +1285,26 @@ async function* queryLoop(
appState.mainLoopModelForSession ??
appState.mainLoopModel ??
getDefaultMainLoopModelSetting()
let currentModel = getRuntimeMainLoopModel({
permissionMode,
mainLoopModel: parseUserSpecifiedModel(appStateMainLoopModel),
exceeds200kTokens:
permissionMode === 'plan' &&
doesMostRecentAssistantMessageExceed200k(messagesForQuery),
})
const selectedModel = toolUseContext.agentId
? getQueryModelSelection(toolUseContext)?.requestModel ?? toolUseContext.options.mainLoopModel
: appStateMainLoopModel
// Children already have an assigned model. Root plan aliases must not
// promote that assignment again, even when smart routing is disabled.
let currentModel = toolUseContext.agentId
? parseUserSpecifiedModel(toolUseContext.options.mainLoopModel)
: getRuntimeMainLoopModel({
permissionMode,
mainLoopModel: parseUserSpecifiedModel(selectedModel),
exceeds200kTokens:
permissionMode === 'plan' &&
doesMostRecentAssistantMessageExceed200k(messagesForQuery),
})
// Smart routing (opt-in): classify once per user turn (transition===undefined)
// and pin the decision; reuse the pin on every continuation pass. Applied
// BEFORE the blocking-limit math below so the token-budget guard and the
// model call agree on the model. Disabled/misconfigured → pin is `routed:false`
// and currentModel keeps today's resolution (byte-for-byte unchanged).
// and currentModel keeps the root's resolution or child's assigned model.
if (state.transition === undefined) {
pinnedTurnRoute = decideTurnModel({
settings: appState.settings as unknown as Parameters<typeof decideTurnModel>[0]['settings'],
@@ -1289,6 +1316,7 @@ async function* queryLoop(
turnNumber: deriveUserTurnNumber(messagesForQuery),
},
sessionId: getSessionId(),
agentId: toolUseContext.agentId,
})
if (pinnedTurnRoute.routed === false && pinnedTurnRoute.justDisabledForSession) {
yield createSystemMessage(
@@ -1532,6 +1560,23 @@ async function* queryLoop(
// callModel performs async provider preparation.
params.onModelRequestStart?.()
modelRequestLifecycleStarted = true
const requestModel = pinnedTurnRoute?.routed
? currentModel
: getProviderRequestModel(selectedModel, currentModel)
const queryModelSelection = toolUseContext.queryModelSelection ??= {
agentId: toolUseContext.agentId,
}
const requestSelection = {
options: toolUseContext.options,
model: toolUseContext.options.mainLoopModel,
selectedModel,
runtimeModel: currentModel,
requestModel,
providerKey: getAmbientQueryProviderKey(),
// Automatic model retries may mutate options; that is not an
// explicit user choice and must not change the selection origin.
followsProvider,
}
for await (const message of deps.callModel({
messages: prependUserContext(
injectRequestOnlyMessages(
@@ -1550,9 +1595,7 @@ async function* queryLoop(
return appState.toolPermissionContext
},
model: currentModel,
requestModel: pinnedTurnRoute?.routed
? currentModel
: getProviderRequestModel(appStateMainLoopModel, currentModel),
requestModel,
...(config.gates.fastModeEnabled && {
fastMode: appState.fastMode,
}),
@@ -1604,6 +1647,7 @@ async function* queryLoop(
reservedTurnCount = turnCount
}
providerDispatchAccepted = true
queryModelSelection.current = requestSelection
params.onProviderDispatchAccepted?.()
return true
},
@@ -2420,6 +2464,9 @@ async function* queryLoop(
if (isWithheldRateLimit) {
const fallback = resolveNextFallbackProviderFromState()
if (fallback !== null) {
// Recovery needs the origin even if the active provider changed
// while the failed request was in flight; it replaces the pair below.
const selection = getOwnedQueryModelSelection(toolUseContext)
const activated = setActiveProviderProfile(fallback.nextProfileId)
if (activated) {
const fromLabel = fallback.fromProfileId ?? 'previous provider'
@@ -2441,7 +2488,23 @@ async function* queryLoop(
mainLoopModel: activatedModel,
mainLoopModelForSession: null,
}))
toolUseContext.options.mainLoopModel = activatedModel
const runtimeModel = parseUserSpecifiedModel(activatedModel)
const options = { ...toolUseContext.options, mainLoopModel: runtimeModel }
// Recovery replaces the pair, not its origin. Clone so retained
// contexts still own their earlier snapshot, even if setAppState
// is a no-op in this child.
toolUseContext = {
...toolUseContext, options,
queryModelSelection: {
agentId: toolUseContext.agentId,
...(selection && { current: {
...selection, options, model: runtimeModel,
selectedModel: activatedModel, runtimeModel,
requestModel: getProviderRequestModel(activatedModel, runtimeModel),
providerKey: getAmbientQueryProviderKey(),
} }),
},
}
}
// System informational, NOT an assistant API error. The original
// 429 is still withheld upstream, so SDK hosts that terminate on
@@ -3101,6 +3164,10 @@ async function* queryLoop(
if (updatedToolUseContext.options.refreshTools) {
const refreshedTools = updatedToolUseContext.options.refreshTools()
if (refreshedTools !== updatedToolUseContext.options.tools) {
// A provider can also switch during the tool. Preserve that transition
// before cloning options, while the old ownership proof is still valid.
updatedToolUseContext = refreshAmbientQueryModelSelection(updatedToolUseContext)
const selection = getQueryModelSelection(updatedToolUseContext)
updatedToolUseContext = {
...updatedToolUseContext,
options: {
@@ -3108,6 +3175,12 @@ async function* queryLoop(
tools: refreshedTools,
},
}
// Keep earlier sampled/retained contexts valid too. Moving a shared
// holder's options pointer would invalidate their accepted selections.
updatedToolUseContext.queryModelSelection = {
agentId: updatedToolUseContext.agentId,
...(selection && { current: { ...selection, options: updatedToolUseContext.options } }),
}
}
}
File diff suppressed because it is too large Load Diff
@@ -177,6 +177,33 @@ describe('decideTurnModel', () => {
expect(d).toMatchObject({ routed: true, complexity: 'strong', model: 'gpt-5' })
})
test('sub-agent loops are never routed, so an agentRouting model is not overwritten', () => {
mockGlobalAllowlist(undefined)
for (const userText of ['ok thanks', 'refactor the auth module please']) {
const d = decideTurnModel({
settings: enabledSettings(),
parentModel: 'agent-routed-model',
input: { userText, turnNumber: 3 },
sessionId: 'sess-1',
agentId: 'agent-1',
})
expect(d).toEqual({ routed: false })
}
})
test('a sub-agent loop does not disable routing for the session', () => {
const spy = mockGlobalAllowlist(['some-other-model'])
decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'ok thanks', turnNumber: 3 },
sessionId: 'sess-1',
agentId: 'agent-1',
})
expect(isSmartRoutingDisabledForSession('sess-1')).toBe(false)
spy.mockRestore()
})
test('disallowed simple model coerces to strong', () => {
// Distinct, non-prefix-colliding model ids so the allowlist genuinely blocks
// simple while permitting strong.
+9
View File
@@ -91,6 +91,8 @@ export interface DecideTurnModelInput {
permissionMode?: PermissionMode
input: RoutingInput
sessionId?: string
/** Set when the query loop belongs to a sub-agent rather than the main thread. */
agentId?: string
}
/**
@@ -100,6 +102,11 @@ export interface DecideTurnModelInput {
* short-circuits when the routed model equals the session model). A disallowed
* routed model is coerced to strong; if strong is also disallowed, routing is
* disabled for the session and the caller falls back to today's resolution.
*
* Sub-agents are never routed. Their prompt is not a user turn: the parent
* turn's decision already holds for its Agent tool call, and the sub-agent's
* model comes from agentRouting or its definition. Pinning here would
* overwrite that model with the classifier's pick.
*/
export function decideTurnModel({
settings,
@@ -107,7 +114,9 @@ export function decideTurnModel({
permissionMode,
input,
sessionId,
agentId,
}: DecideTurnModelInput): TurnRoutingDecision {
if (agentId) return { routed: false }
if (isSmartRoutingDisabledForSession(sessionId)) return { routed: false }
const config = resolveSmartRoutingConfig({ settings, parentModel, permissionMode })
+1 -1
View File
@@ -806,7 +806,7 @@ for (const phase of ['initial', 'incremental'] as const) {
yield createAssistantMessage({ content: 'fork answer' })
} }))
const controller = new AbortController()
const context = { abortController: controller, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: getDefaultAppState, options: {}, messages: [] } as unknown as ToolUseContext
const context = { abortController: controller, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: getDefaultAppState, options: { mainLoopModel: 'test-model' }, messages: [] } as unknown as ToolUseContext
const clear = spyOn(FileStateCache.prototype, 'clear')
const fork = runForkedAgent({ promptMessages: [createUserMessage({ content: 'fork initial' })], cacheSafeParams: { toolUseContext: context, forkContextMessages: [], systemPrompt: [] as never, userContext: {}, systemContext: {} }, canUseTool: async () => ({ behavior: 'allow' }), querySource: 'compact', forkLabel: 'compact' })
const outcome = fork.then(() => 'finished', error => error)
+15
View File
@@ -417,6 +417,21 @@ export async function* runAgent({
)
}
// Definitions that resolve to the inherited/default model need no new
// selection permission. A route has its own effective-model check above.
if (
effectiveModel === resolvedAgentModel &&
!providerOverride &&
agentDefinition.model &&
resolvedAgentModel !== getAgentModel(undefined, toolUseContext.options.mainLoopModel, model, permissionMode) &&
!isModelAllowed(agentDefinition.model) &&
!isModelAllowed(resolvedAgentModel)
) {
throw new Error(
`Model '${resolvedAgentModel}' is not available. Your organization restricts model selection.`,
)
}
const agentId = override?.agentId ? override.agentId : createAgentId()
// Route this agent's transcript into a grouping subdirectory if requested
+10
View File
@@ -58,6 +58,7 @@ import { lazySchema } from '../../utils/lazySchema.js'
import { createUserMessage, normalizeMessages } from '../../utils/messages.js'
import type { ModelAlias } from '../../utils/model/aliases.js'
import { resolveSkillModelOverride } from '../../utils/model/model.js'
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js'
import { createAgentId } from '../../utils/uuid.js'
import { runAgent } from '../AgentTool/runAgent.js'
@@ -643,6 +644,15 @@ export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
)
}
// Reject before command expansion can register persistent skill hooks.
const requestedModel = command?.type === 'prompt' ? command.model : undefined
if (requestedModel && !isModelAllowed(requestedModel) &&
!isModelAllowed(resolveSkillModelOverride(requestedModel, context.options.mainLoopModel))) {
throw new Error(
`Model '${requestedModel}' is not available. Your organization restricts model selection.`,
)
}
// Process the skill with optional args
const { processPromptSlashCommand } = await import(
'src/utils/processUserInput/processSlashCommand.js'
+97 -2
View File
@@ -35,6 +35,9 @@ import {
extractTextContent,
getLastAssistantMessage,
} from './messages.js'
import { getDefaultMainLoopModelSetting, getProviderRequestModel, parseUserSpecifiedModel } from './model/model.js'
import { getActiveProviderProfile } from './providerProfiles.js'
import { getAPIProvider } from './model/providers.js'
import { createDenialTrackingState } from './permissions/denialTracking.js'
import { parseToolListFromCLI } from './permissions/permissionSetup.js'
import { recordSidechainTranscript } from './sessionStorage.js'
@@ -324,6 +327,55 @@ export type SubagentContextOverrides = {
contentReplacementState?: ContentReplacementState
}
/** Include profileless transport changes, not just named-profile changes. */
export function getAmbientQueryProviderKey(): string {
return `${getAPIProvider()}:${getActiveProviderProfile()?.id ?? ''}`
}
/** Return provenance only while its owner and model options are unchanged. */
export function getOwnedQueryModelSelection(context: ToolUseContext) {
const selection = context.queryModelSelection?.current
return context.queryModelSelection?.agentId === context.agentId &&
selection?.options === context.options &&
selection.model === context.options.mainLoopModel
? selection
: undefined
}
export function getQueryModelSelection(context: ToolUseContext) {
const selection = getOwnedQueryModelSelection(context)
return !selection?.followsProvider || context.options.providerOverride ||
selection?.providerKey === getAmbientQueryProviderKey()
? selection
: undefined
}
/** Re-select an inherited ambient pair before consuming a context under a new provider. */
export function refreshAmbientQueryModelSelection(context: ToolUseContext): ToolUseContext {
const selection = getOwnedQueryModelSelection(context)
const providerKey = getAmbientQueryProviderKey()
if (!selection?.followsProvider || context.options.providerOverride ||
selection.providerKey === providerKey) return context
const state = context.getAppState()
const selectedModel = state.mainLoopModelForSession ?? state.mainLoopModel ?? getDefaultMainLoopModelSetting()
const runtimeModel = parseUserSpecifiedModel(selectedModel)
const options = { ...context.options, mainLoopModel: runtimeModel }
// A retained reader still owns its earlier options/selection snapshot.
return {
...context,
options,
queryModelSelection: {
agentId: context.agentId,
current: {
...selection, options, model: runtimeModel, selectedModel, runtimeModel,
requestModel: getProviderRequestModel(selectedModel, runtimeModel),
providerKey,
},
},
}
}
/**
* Creates an isolated ToolUseContext for subagents.
*
@@ -367,6 +419,31 @@ export function createSubagentContext(
parentContext: ToolUseContext,
overrides?: SubagentContextOverrides,
): ToolUseContext {
if (!overrides?.options) parentContext = refreshAmbientQueryModelSelection(parentContext)
const selection = getQueryModelSelection(parentContext)
const providerKey = getAmbientQueryProviderKey()
let runtimeModel = selection?.runtimeModel ?? parentContext.options.mainLoopModel
let requestModel = selection?.requestModel ?? parentContext.options.mainLoopModel
// Fresh root options can predate async /btw or SDK preparation. Establish
// the concrete/request pair together from the current configured selection.
// Saved selections, explicit children and fixed providers keep their choices.
if (!overrides?.options && !parentContext.options.providerOverride &&
!parentContext.queryModelSelection?.current && !parentContext.agentId) {
const state = parentContext.getAppState()
const selectedModel = state.mainLoopModelForSession ?? state.mainLoopModel ?? getDefaultMainLoopModelSetting()
runtimeModel = parseUserSpecifiedModel(selectedModel)
requestModel = getProviderRequestModel(
selectedModel,
runtimeModel,
)
}
// Explicit options (including same-runtime model overrides) take precedence.
const options = overrides?.options ?? {
...parentContext.options,
mainLoopModel: runtimeModel,
}
const agentId = overrides?.agentId ?? createAgentId()
// Determine abortController: explicit override > share parent's > new child
const abortController =
overrides?.abortController ??
@@ -472,10 +549,28 @@ export function createSubagentContext(
openMessageSelector: undefined,
// Fields that can be overridden or copied from parent
options: overrides?.options ?? parentContext.options,
options,
messages: overrides?.messages ?? parentContext.messages,
// Generate new agentId for subagents (each subagent should have its own ID)
agentId: overrides?.agentId ?? createAgentId(),
agentId,
// Runtime options retain the concrete model for budgets and capabilities.
// Request aliases/reasoning travel separately, owned by this child. Explicit
// options start a new selection even when the concrete model is unchanged.
queryModelSelection: {
agentId,
...(!overrides?.options && {
current: {
options,
model: options.mainLoopModel,
selectedModel: requestModel,
runtimeModel: options.mainLoopModel,
requestModel,
providerKey,
followsProvider: selection?.followsProvider ??
(!parentContext.agentId && !parentContext.queryModelSelection?.current),
},
}),
},
agentType: overrides?.agentType,
...(overrides?.queryLifecycle
? { queryLifecycle: overrides.queryLifecycle }
+6
View File
@@ -17,6 +17,7 @@ import { errorMessage } from '../errors.js'
import type { HookResult } from '../hooks.js'
import { createUserMessage, handleMessageFromStream } from '../messages.js'
import { getSmallFastModel } from '../model/model.js'
import { isModelAllowed } from '../model/modelAllowlist.js'
import { hasPermissionsToUseTool } from '../permissions/permissions.js'
import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js'
import type { AgentHook } from '../settings/types.js'
@@ -116,6 +117,11 @@ When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with:
])
const model = hook.model ?? getSmallFastModel()
if (hook.model && !isModelAllowed(hook.model)) {
throw new Error(
`Model '${hook.model}' is not available. Your organization restricts model selection.`,
)
}
const MAX_AGENT_TURNS = 50
// Create unique agentId for this hook agent