diff --git a/.changeset/clean-subagents-think.md b/.changeset/clean-subagents-think.md new file mode 100644 index 00000000000..95a7e8f5fd9 --- /dev/null +++ b/.changeset/clean-subagents-think.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Support configuring the default task subagent model and reasoning effort while safely inheriting the calling agent model when the override is unavailable. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png index e395b8b062d..06a45f9ca3c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdcac95e7969d021ebce19001f37f86b8ab670bd79e39086385c9419617294df -size 27177 +oid sha256:e1a5dfe9704a74cbfa2da2a179ed995bcc31def156e798277dcfb568b25db58d +size 35913 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png index 77079da8613..542e06b36ac 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73a34a22b24e42731510f9ecd3ea8827a0e39fa9b335294c45c4e8780df9a1f4 -size 35853 +oid sha256:6bcfc6c12f69c5131dcc8f294f480e26ff53f87e0c5943bf525e69c4c894ad21 +size 43744 diff --git a/packages/kilo-vscode/tests/unit/settings-io.test.ts b/packages/kilo-vscode/tests/unit/settings-io.test.ts index 81e68cc7402..4ed6aaa3645 100644 --- a/packages/kilo-vscode/tests/unit/settings-io.test.ts +++ b/packages/kilo-vscode/tests/unit/settings-io.test.ts @@ -167,6 +167,16 @@ describe("parseImport", () => { if (result.ok) expect(result.config.model).toBe("test-model") }) + it("preserves task subagent model and variant settings", () => { + const json = JSON.stringify({ subagent_model: "anthropic/claude-sonnet-4", subagent_variant: "high" }) + const result = parseImport(json) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.config.subagent_model).toBe("anthropic/claude-sonnet-4") + expect(result.config.subagent_variant).toBe("high") + } + }) + it("strips _meta before returning config", () => { const json = JSON.stringify({ _meta: { version: 1, exportedAt: "2026-01-01", secretsStripped: true }, diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx index c961e2d943b..9a72e0883e9 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx @@ -2,16 +2,19 @@ import { Component, For, createMemo } from "solid-js" import { Card } from "@kilocode/kilo-ui/card" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" +import { useProvider } from "../../context/provider" import { useSession } from "../../context/session" import { parseModelString } from "../../../../src/shared/provider-model" import { DEFAULT_AUTOCOMPLETE_MODEL } from "../../../../src/shared/autocomplete-models" import { ModelSelectorBase } from "../shared/ModelSelector" +import { ThinkingSelectorBase } from "../shared/ThinkingSelector" import SettingsRow from "./SettingsRow" import { AUTOCOMPLETE_PROVIDER_ID, AUTOCOMPLETE_SELECTOR_MODELS } from "./autocomplete-model-selector" const ModelsTab: Component = () => { const { config, settings, updateConfig, updateSetting } = useConfig() const language = useLanguage() + const provider = useProvider() const session = useSession() const autocompleteModel = () => String(settings()["autocomplete.model"] ?? DEFAULT_AUTOCOMPLETE_MODEL.id) @@ -26,6 +29,35 @@ const ModelsTab: Component = () => { } } + const subagentModel = createMemo(() => parseModelString(config().subagent_model ?? undefined)) + const subagentVariants = createMemo(() => { + const model = provider.findModel(subagentModel()) + return model?.variants ? Object.keys(model.variants) : [] + }) + const subagentVariant = createMemo(() => { + const list = subagentVariants() + if (list.length === 0) return undefined + const value = config().subagent_variant ?? undefined + return value && list.includes(value) ? value : undefined + }) + + function handleSubagentModelSelect(providerID: string, modelID: string) { + if (!providerID || !modelID) { + updateConfig({ subagent_model: null, subagent_variant: null }) + return + } + const model = { providerID, modelID } + const variants = provider.findModel(model)?.variants + const list = variants ? Object.keys(variants) : [] + const value = config().subagent_model === `${providerID}/${modelID}` ? config().subagent_variant : undefined + const variant = value && list.includes(value) ? value : list[0] + updateConfig({ subagent_model: `${providerID}/${modelID}`, subagent_variant: variant ?? null }) + } + + function handleSubagentVariantSelect(value: string) { + updateConfig({ subagent_variant: value }) + } + const allAgents = createMemo(() => session.agents()) function handleModeModelSelect(agentName: string) { @@ -71,6 +103,26 @@ const ModelsTab: Component = () => { includeAutoSmall /> + +
+ + +
+
= [ "permission", "model", "small_model", + "subagent_model", + "subagent_variant", "default_agent", "agent", "provider", diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index 7af984236cc..fbea4eeea18 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -25,6 +25,8 @@ export interface ThinkingSelectorBaseProps { onSelect: (value: string) => void /** Delay outside dismissal while the popover opens inside a dialog. */ deferDismiss?: boolean + /** Popover placement — defaults to "top-start". */ + placement?: "top-start" | "bottom-start" | "bottom-end" | "top-end" } export const ThinkingSelectorBase: Component = (props) => { @@ -110,7 +112,7 @@ export const ThinkingSelectorBase: Component = (props 0}> provider?: Record diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e2cdab1f400..300688eba6c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -226,6 +226,13 @@ export const Info = Schema.Struct({ small_model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({ description: "Small model to use for tasks like title generation in the format of provider/model", }), + subagent_model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({ + description: + "Default model for task-tool subagents in the format of provider/model. If unset or unavailable, subagents inherit the calling agent model.", + }), + subagent_variant: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: "Default model variant for task-tool subagents when subagent_model is configured.", + }), // kilocode_change end // kilocode_change start - renamed from "build" to "code" + nullable for delete sentinel default_agent: Schema.optional(Schema.NullOr(Schema.String)).annotate({ diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index b349ab86b30..8397735c620 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -4,12 +4,16 @@ import path from "path" import { Permission } from "@/permission" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" +import * as Log from "@opencode-ai/core/util/log" import { ModelID, ProviderID } from "@/provider/schema" import type { Session } from "../../session/session" import type { Agent } from "../../agent/agent" import type { Config } from "../../config/config" +import { Provider } from "../../provider/provider" import z from "zod" +const log = Log.create({ service: "kilocode-task-model" }) + // RATIONALE: Mirror narrow state slice Task tool consumes and ignore unrelated TUI fields. const ModelState = z .object({ @@ -55,8 +59,20 @@ export namespace KiloTask { return [{ permission: "task", pattern: "*", action: "deny" }, ...rules] } - /** Return saved CLI model for agent, if any. */ - export const resolveModel = Effect.fn("KiloTask.resolveModel")(function* (name: string) { + type Model = { providerID: ProviderID; modelID: ModelID } + type Saved = Model & { variant?: string } + type Choice = { model: Model; variant?: string; sticky?: boolean; direct?: boolean } + + function parse(value: string | null | undefined): Model | undefined { + if (!value) return undefined + const [providerID, ...parts] = value.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ModelID.make(parts.join("/")), + } + } + + const saved = Effect.fn("KiloTask.savedModel")(function* (name: string) { if (Flag.KILO_CLIENT !== "cli") return undefined const file = path.join(Global.Path.state, "model.json") const state = yield* Effect.tryPromise({ @@ -75,4 +91,53 @@ export namespace KiloTask { variant: state?.variant?.[`${model.providerID}/${model.modelID}`], } }) + + /** Resolve the task subagent model while discarding stale unavailable overrides. */ + export const resolveModel = Effect.fn("KiloTask.resolveModel")(function* (input: { + name: string + agent: Pick + config: Pick + parent: Model + }) { + const state = yield* saved(input.name) + const cfg = parse(input.config.subagent_model) + const choices: Array = [ + state + ? { + model: { providerID: state.providerID, modelID: state.modelID }, + variant: state.variant, + sticky: true, + } + : undefined, + input.agent.model ? { model: input.agent.model, variant: input.agent.variant, direct: true } : undefined, + cfg ? { model: cfg, variant: input.config.subagent_variant ?? undefined } : undefined, + ] + + for (const choice of choices) { + if (!choice) continue + if (choice.direct) return { model: choice.model, variant: choice.variant } + const full = yield* Effect.tryPromise(() => + Provider.getModel(choice.model.providerID, choice.model.modelID), + ).pipe( + Effect.catch((err) => + Effect.sync(() => { + log.debug("skipping unavailable task subagent model", { + providerID: choice.model.providerID, + modelID: choice.model.modelID, + err, + }) + return undefined + }), + ), + ) + if (!full) continue + const variant = choice.variant && full.variants?.[choice.variant] ? choice.variant : undefined + return { + model: choice.sticky && variant ? { ...choice.model, variant } : choice.model, + variant, + } + } + + return { model: input.parent, variant: undefined } + }) } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 9bb2a571bfe..24075d02847 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -118,14 +118,18 @@ export const TaskTool = Tool.define( const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - // kilocode_change start — prefer user's CLI-saved pick for this subagent - const saved = yield* KiloTask.resolveModel(next.name) - const model = saved ?? - next.model ?? { + // kilocode_change start — prefer valid subagent overrides, safely inheriting when overrides go stale + const selected = yield* KiloTask.resolveModel({ + name: next.name, + agent: next, + config: cfg, + parent: { modelID: msg.info.modelID, providerID: msg.info.providerID, - } - const variant = saved?.variant ?? (saved ? undefined : next.variant) + }, + }) + const model = selected.model + const variant = selected.variant // kilocode_change end yield* ctx.metadata({ diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index f87415aae97..bafe1e19cd6 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -48,6 +48,44 @@ const cfg = { const savedVariant = "fast" const cfgVariant = "balanced" +const sub = { + providerID: ProviderID.make("sub-provider"), + modelID: ModelID.make("sub-model"), +} +const subVariant = "deep" + +function custom(id: string, model: string, variants: string[] = []) { + return { + name: id, + id, + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + [model]: { + id: model, + name: model, + attachment: false, + reasoning: variants.length > 0, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100_000, output: 10_000 }, + cost: { input: 0, output: 0 }, + options: {}, + variants: Object.fromEntries(variants.map((variant) => [variant, {}])), + }, + }, + options: { apiKey: "test-key", baseURL: "http://localhost:1/v1" }, + } +} + +const catalog = { + provider: { + "saved-provider": custom("saved-provider", "saved-model", [savedVariant]), + "config-provider": custom("config-provider", "config-model", [cfgVariant]), + "sub-provider": custom("sub-provider", "sub-model", [subVariant]), + }, +} const it = testEffect( Layer.mergeAll( @@ -138,7 +176,12 @@ function writeState(input: unknown) { }) } -function run(input: { agent: "pinned" | "worker"; state?: unknown; client?: string }) { +function run(input: { + agent: "pinned" | "worker" + state?: unknown + client?: string + config?: Pick +}) { return provideTmpdirInstance( () => Effect.gen(function* () { @@ -178,6 +221,8 @@ function run(input: { agent: "pinned" | "worker"; state?: unknown; client?: stri }), { config: { + ...catalog, + ...input.config, agent: { worker: { mode: "subagent" }, pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, @@ -268,6 +313,70 @@ describe("tool.task model resolution", () => { ), ) + it.live("configured subagent default model and variant apply to task workers", () => + run({ + agent: "worker", + config: { subagent_model: "sub-provider/sub-model", subagent_variant: subVariant }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(sub) + expect(result.variant).toEqual(subVariant) + expect(result.model).toEqual(sub) + expect(result.metadataVariant).toEqual(subVariant) + }), + ), + ), + ) + + it.live("per-agent task model remains above the configured subagent default", () => + run({ + agent: "pinned", + config: { subagent_model: "sub-provider/sub-model", subagent_variant: subVariant }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(cfg) + expect(result.variant).toEqual(cfgVariant) + expect(result.model).toEqual(cfg) + expect(result.metadataVariant).toEqual(cfgVariant) + }), + ), + ), + ) + + it.live("unavailable configured subagent model falls back to the parent model", () => + run({ + agent: "worker", + config: { subagent_model: "missing-provider/missing-model", subagent_variant: subVariant }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + + it.live("stale configured subagent variant is ignored without dropping its model", () => + run({ + agent: "worker", + config: { subagent_model: "sub-provider/sub-model", subagent_variant: "gone" }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(sub) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(sub) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + it.live("no file and no agent config falls back to parent for worker", () => run({ agent: "worker", @@ -324,6 +433,7 @@ describe("tool.task model resolution", () => { }), { config: { + ...catalog, agent: { worker: { mode: "subagent" }, pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a509296e055..066d3fd4c5e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1326,6 +1326,8 @@ export type Config = { terminal_command_display?: "expanded" | "collapsed" model?: string small_model?: string + subagent_model?: string + subagent_variant?: string default_agent?: string username?: string mode?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 89bd016ba98..624758a6049 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -16460,6 +16460,12 @@ "small_model": { "type": "string" }, + "subagent_model": { + "type": "string" + }, + "subagent_variant": { + "type": "string" + }, "default_agent": { "type": "string" },