Merge pull request #10443 from Kilo-Org/subagent-model-reasoning-config

feat: configure task subagent model defaults
This commit is contained in:
Marius
2026-05-26 10:58:57 +02:00
committed by GitHub
33 changed files with 337 additions and 14 deletions
+7
View File
@@ -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({
+67 -2
View File
@@ -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<Agent.Info, "model" | "variant">
config: Pick<Config.Info, "subagent_model" | "subagent_variant">
parent: Model
}) {
const state = yield* saved(input.name)
const cfg = parse(input.config.subagent_model)
const choices: Array<Choice | undefined> = [
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 }
})
}
+10 -6
View File
@@ -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({
@@ -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<Config.Info, "subagent_model" | "subagent_variant">
}) {
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 },