mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #10443 from Kilo-Org/subagent-model-reasoning-config
feat: configure task subagent model defaults
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user