From 6624c1a2b07c0a7871532b0b5175d01a03c6e61e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 28 Aug 2026 16:00:54 +0200 Subject: [PATCH 1/2] feat: allow experimental task model selection --- .changeset/task-model-selection.md | 7 + packages/core/src/v1/config/config.ts | 3 + .../tests/unit/task-model-selection.test.ts | 36 +++ .../components/settings/ExperimentalTab.tsx | 13 ++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 + .../webview-ui/src/types/messages/config.ts | 1 + .../src/kilocode/tool/agent-manager-models.ts | 67 +++--- .../src/kilocode/tool/agent-manager.ts | 141 +----------- .../src/kilocode/tool/model-selection.ts | 117 ++++++++++ .../opencode/src/kilocode/tool/registry.ts | 9 +- packages/opencode/src/kilocode/tool/task.ts | 58 ++++- packages/opencode/src/tool/task.ts | 87 ++++--- .../agent-manager-models-tool.test.ts | 23 +- .../kilocode/server/config-overlay.test.ts | 70 +++++- .../kilocode/tool-registry-indexing.test.ts | 12 + .../test/kilocode/tool-task-model.test.ts | 216 ++++++++++++++++-- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 3 + 38 files changed, 694 insertions(+), 231 deletions(-) create mode 100644 .changeset/task-model-selection.md create mode 100644 packages/kilo-vscode/tests/unit/task-model-selection.test.ts create mode 100644 packages/opencode/src/kilocode/tool/model-selection.ts diff --git a/.changeset/task-model-selection.md b/.changeset/task-model-selection.md new file mode 100644 index 000000000000..fd6c774a293a --- /dev/null +++ b/.changeset/task-model-selection.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": minor +"@kilocode/sdk": minor +"kilo-code": minor +--- + +Allow the orchestrating agent to choose a model, provider, and reasoning effort for each subagent task behind an experimental setting. diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 5dd4d7612cc6..ab51e3e2a61b 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -304,6 +304,9 @@ export const Info = Schema.Struct({ native_notebook_tools: Schema.optional(Schema.Boolean).annotate({ description: "Enable native tools for reading, editing, and executing VS Code notebooks", }), + task_model_selection: Schema.optional(Schema.Boolean).annotate({ + description: "Allow task subagents to select a model, provider, and reasoning effort", + }), speech_to_text_model: Schema.optional(Schema.String).annotate({ description: "Speech-to-text transcription model ID to use for voice input", }), diff --git a/packages/kilo-vscode/tests/unit/task-model-selection.test.ts b/packages/kilo-vscode/tests/unit/task-model-selection.test.ts new file mode 100644 index 000000000000..25ee25597f7b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/task-model-selection.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test" +import { ConfigState } from "../../webview-ui/src/utils/config-utils" + +describe("task subagent model selection config", () => { + it("stages the experimental toggle and discards it", () => { + const state = new ConfigState() + state.handleConfigLoaded({ experimental: { task_model_selection: false } }) + + state.updateConfig({ experimental: { task_model_selection: true } }) + + expect(state.config.experimental?.task_model_selection).toBe(true) + expect(state.draft.experimental?.task_model_selection).toBe(true) + expect(state.dirty).toBe(true) + + state.discardConfig() + + expect(state.config.experimental?.task_model_selection).toBe(false) + expect(state.dirty).toBe(false) + }) + + it("clears the draft after the backend confirms the save", () => { + const state = new ConfigState() + state.handleConfigLoaded({ experimental: { task_model_selection: false } }) + state.updateConfig({ experimental: { task_model_selection: true } }) + state.saveConfig() + + expect(state.saving).toBe(true) + + state.handleConfigUpdated({ experimental: { task_model_selection: true } }) + + expect(state.config.experimental?.task_model_selection).toBe(true) + expect(state.dirty).toBe(false) + expect(state.saving).toBe(false) + expect(state.draft).toEqual({}) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index c59974ab1644..a7465234129d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -217,6 +217,19 @@ const ExperimentalTab: Component = () => { + + updateExperimental("task_model_selection", checked)} + hideLabel + > + {language.t("settings.experimental.taskModelSelection.title")} + + + {/* MCP timeout */} ( "agent_manager_models", Effect.gen(function* () { const provider = yield* Provider.Service - return { - description: DESCRIPTION, - parameters: Params, - execute: (params) => - Effect.gen(function* () { - const providers = yield* provider.list() - const all = entries(providers) - const query = params.query?.trim() - const matches = query ? all.filter((entry) => matchesQuery([entry.name, ...entry.ids], query)) : all - const offset = params.offset ?? 0 - const limit = Math.min(params.limit ?? MAX_LIMIT, MAX_LIMIT) - const models = matches.slice(offset, offset + limit).map(view) - const nextOffset = offset + models.length < matches.length ? offset + models.length : undefined - return { - title: query - ? `${matches.length} model${matches.length === 1 ? "" : "s"} matching "${params.query?.trim()}"` - : `${matches.length} available models`, - output: JSON.stringify({ - models, - offset, - total: matches.length, - nextOffset, - hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Add the task `provider` to force one of the listed providers; otherwise Agent Manager prefers the provider used by the current turn.", + const config = yield* Config.Service + return () => + Effect.gen(function* () { + const cfg = yield* config.get() + const selection = cfg.experimental?.task_model_selection === true + return { + description: selection + ? `${DESCRIPTION}\n\nExperimental Task model selection is enabled. Also use this tool before choosing model, provider, or variant for the task subagent tool. You may choose these settings to suit the subagent task without creating an Agent Manager session.` + : DESCRIPTION, + parameters: Params, + execute: (params) => + Effect.gen(function* () { + const providers = yield* provider.list() + const all = entries(providers) + const query = params.query?.trim() + const matches = query ? all.filter((entry) => matchesQuery([entry.name, ...entry.ids], query)) : all + const offset = params.offset ?? 0 + const limit = Math.min(params.limit ?? MAX_LIMIT, MAX_LIMIT) + const models = matches.slice(offset, offset + limit).map(view) + const nextOffset = offset + models.length < matches.length ? offset + models.length : undefined + return { + title: query + ? `${matches.length} model${matches.length === 1 ? "" : "s"} matching "${params.query?.trim()}"` + : `${matches.length} available models`, + output: JSON.stringify({ + models, + offset, + total: matches.length, + nextOffset, + hint: selection + ? "Pass a model name as `model`, a listed provider ID as `provider`, and a supported reasoning effort as `variant` to task or agent_manager. Task selection is experimental and does not create Agent Manager sessions." + : "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Add the task `provider` to force one of the listed providers; otherwise Agent Manager prefers the provider used by the current turn.", + }), + metadata: { count: models.length, total: matches.length }, + } }), - metadata: { count: models.length, total: matches.length }, - } - }), - } + } + }) }), ) diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 099900d73adf..a74c1ca20ae0 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { AgentManagerEvent, type AgentManagerTask } from "@/kilocode/agent-manager/event" @@ -11,7 +10,7 @@ import { SessionID } from "@/session/schema" import * as ToolJsonSchema from "@/tool/json-schema" import { Tool } from "@/tool/tool" import { Effect, Schema } from "effect" -import { matchesQuery } from "./model-search" +import { selectModel } from "./model-selection" import DESCRIPTION from "./agent-manager.txt" const Task = Schema.Struct({ @@ -172,7 +171,6 @@ const WireParams = Schema.Struct({ type Input = Schema.Schema.Type type Selected = { task?: AgentManagerTask; error?: string } -type Candidate = { providerID: string; model: Provider.Info["models"][string] } type Source = { model: NonNullable; variant?: string } function abort(signal: AbortSignal) { @@ -189,59 +187,9 @@ function run(effect: Effect.Effect, signal: AbortSignal) { return effect.pipe(Effect.raceFirst(abort(signal)), Effect.orDie) } -function candidates(providers: Record): Candidate[] { - return Object.values(providers).flatMap((provider) => - Object.values(provider.models).map((model) => ({ providerID: provider.id, model })), - ) -} - -// Resolve a model query to the candidates for a single logical model (possibly -// offered by several providers). Exact id/name win first so a precise request is -// never drowned out; otherwise fall back to lenient fuzzy matching so the agent -// does not need the exact model name. -function lookup(all: Candidate[], value: string): { pool: Candidate[]; names: string[] } { - const query = value.toLowerCase() - const exactId = all.filter((item) => `${item.providerID}/${item.model.id}`.toLowerCase() === query) - const exactName = exactId.length ? exactId : all.filter((item) => item.model.name.toLowerCase() === query) - const pool = exactName.length - ? exactName - : all.filter((item) => matchesQuery([item.model.name, `${item.providerID}/${item.model.id}`], value)) - const names = [...new Set(pool.map((item) => item.model.name))] - return { pool, names } -} - -// Closest model names to a query that found no full match, so a wrong guess is -// self-correcting without a separate agent_manager_models round-trip. -function suggest(all: Candidate[], value: string): string[] { - const tokens = value - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter(Boolean) - if (tokens.length === 0) return [] - const scored = new Map() - for (const item of all) { - const text = `${item.model.name} ${item.providerID}/${item.model.id}`.toLowerCase().replace(/[^a-z0-9]+/g, "") - const score = tokens.filter((token) => text.includes(token)).length - if (score > 0) scored.set(item.model.name, Math.max(scored.get(item.model.name) ?? 0, score)) - } - return [...scored.entries()] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, 3) - .map((entry) => entry[0]) -} - -// Prefer the provider the user already uses for the invoking turn, then the Kilo Gateway, -// so a model name resolves to the provider with the best chance of working -// without forcing the agent to know about provider plumbing. -function rank(providerID: string, preferred: string | undefined): number { - if (providerID === preferred) return 0 - if (providerID === "kilo") return 1 - return 2 -} - function select( task: Input, - all: Candidate[], + providers: Record, preferred: string | undefined, source: Source | undefined, index: number, @@ -251,80 +199,12 @@ function select( ...(task.name != null ? { name: task.name } : {}), ...(task.branchName != null ? { branchName: task.branchName } : {}), } - const value = task.model?.trim() - const provider = task.provider?.trim() - const variant = task.variant?.trim() - if (!value) { - if (!variant) { - if (!task.prompt?.trim() || !source) return { task: base } - return { task: { ...base, ...source } } - } - if (!source) { - return { error: `Task ${index + 1} variant override requires an available current model.` } - } - const active = all.find( - (item) => item.providerID === source.model.providerID && item.model.id === source.model.modelID, - ) - if (!active) { - return { - error: `Task ${index + 1} current model is no longer available: ${source.model.providerID}/${source.model.modelID}. Specify a model override.`, - } - } - if (!active.model.variants || !Object.hasOwn(active.model.variants, variant)) { - const available = Object.keys(active.model.variants ?? {}) - return { - error: `Task ${index + 1} variant "${variant}" is not available for ${active.model.name}. Available variants: ${available.join(", ") || "none"}`, - } - } - return { task: { ...base, model: source.model, variant } } - } - - const scope = provider ? all.filter((item) => item.providerID === provider) : all - if (provider && scope.length === 0) { - return { - error: `Task ${index + 1} provider is not available for model selection: ${provider}. Requested model: ${value}.`, - } - } - - const { pool, names } = lookup(scope, value) - if (pool.length === 0) { - const close = suggest(scope, value) - const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : "" - return { - error: provider - ? `Task ${index + 1} model is not available from provider "${provider}": ${value}.${hint} Use agent_manager_models to search models.` - : `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, - } - } - if (names.length > 1) { - return { - error: `Task ${index + 1} model "${value}" is ambiguous and matches several models: ${names.slice(0, 5).join(", ")}. Use a more specific name.`, - } - } - - const eligible = variant - ? pool.filter((item) => item.model.variants && Object.hasOwn(item.model.variants, variant)) - : pool - if (variant && eligible.length === 0) { - const available = [...new Set(pool.flatMap((item) => Object.keys(item.model.variants ?? {})))] - return { - error: `Task ${index + 1} variant "${variant}" is not available for ${names[0]}. Available variants: ${available.join(", ") || "none"}`, - } - } - - const chosen = [...eligible].sort( - (a, b) => - rank(a.providerID, preferred) - rank(b.providerID, preferred) || - a.providerID.localeCompare(b.providerID) || - a.model.id.localeCompare(b.model.id), - )[0]! - return { - task: { - ...base, - model: { providerID: chosen.model.providerID, modelID: chosen.model.id }, - ...(variant ? { variant } : {}), - }, + if (!task.model?.trim() && !task.variant?.trim()) { + return { task: task.prompt?.trim() && source ? { ...base, ...source } : base } } + const selected = selectModel(task, providers, source, preferred) + if ("error" in selected) return { error: `Task ${index + 1} ${selected.error}` } + return { task: { ...base, ...selected } } } export const AgentManagerTool = Tool.define< @@ -498,7 +378,6 @@ export const AgentManagerTool = Tool.define< : undefined const need = params.tasks.some((task) => task.model?.trim() || task.provider?.trim() || task.variant?.trim()) const providers = need ? yield* provider.list() : undefined - const all = providers ? candidates(providers) : [] const preferred = need ? (source?.model.providerID ?? (yield* provider.defaultModel().pipe( @@ -506,7 +385,7 @@ export const AgentManagerTool = Tool.define< Effect.catch(() => Effect.succeed(undefined)), ))) : undefined - const selected = params.tasks.map((task, index) => select(task, all, preferred, source, index)) + const selected = params.tasks.map((task, index) => select(task, providers ?? {}, preferred, source, index)) const errors = selected.flatMap((item) => (item.error ? [item.error] : [])) if (errors.length > 0) { return { @@ -548,9 +427,7 @@ export const AgentManagerTool = Tool.define< // and the user can confirm the resolution without opening the session. const resolved = tasks.flatMap((task, index) => { if (!params.tasks[index]?.model?.trim() || !task.model) return [] - const name = all.find( - (item) => item.providerID === task.model!.providerID && item.model.id === task.model!.modelID, - )?.model.name + const name = providers?.[task.model.providerID]?.models[task.model.modelID]?.name const label = task.name?.trim() || task.branchName?.trim() || "session" const variant = task.variant ? ` · ${task.variant}` : "" return [`- ${label}: ${name ?? task.model.modelID} (${task.model.providerID})${variant}`] diff --git a/packages/opencode/src/kilocode/tool/model-selection.ts b/packages/opencode/src/kilocode/tool/model-selection.ts new file mode 100644 index 000000000000..2e235d9086ed --- /dev/null +++ b/packages/opencode/src/kilocode/tool/model-selection.ts @@ -0,0 +1,117 @@ +import type { Provider } from "@/provider/provider" +import { matchesQuery } from "./model-search" + +type Source = { + model: { providerID: Provider.Model["providerID"]; modelID: Provider.Model["id"] } + variant?: string +} +type Candidate = { providerID: string; model: Provider.Info["models"][string] } +type Selection = Source | { error: string } + +function lookup(all: Candidate[], value: string) { + const query = value.toLowerCase() + const exact = all.filter((item) => `${item.providerID}/${item.model.id}`.toLowerCase() === query) + const named = exact.length ? exact : all.filter((item) => item.model.name.toLowerCase() === query) + const pool = named.length + ? named + : all.filter((item) => matchesQuery([item.model.name, `${item.providerID}/${item.model.id}`], value)) + return { pool, names: [...new Set(pool.map((item) => item.model.name))] } +} + +function suggest(all: Candidate[], value: string) { + const tokens = value + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + const scored = new Map() + for (const item of all) { + const text = `${item.model.name} ${item.providerID}/${item.model.id}`.toLowerCase().replace(/[^a-z0-9]+/g, "") + const score = tokens.filter((token) => text.includes(token)).length + if (score > 0) scored.set(item.model.name, Math.max(scored.get(item.model.name) ?? 0, score)) + } + return [...scored.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 3) + .map((entry) => entry[0]) +} + +function rank(provider: string, preferred?: string) { + if (provider === preferred) return 0 + if (provider === "kilo") return 1 + return 2 +} + +export function selectModel( + input: { model?: string | null; provider?: string | null; variant?: string | null }, + providers: Record, + source?: Source, + preferred: string | undefined = source?.model.providerID, +): Selection { + const all = Object.values(providers).flatMap((provider) => + Object.values(provider.models).map((model) => ({ providerID: provider.id, model })), + ) + const value = input.model?.trim() + const provider = input.provider?.trim() + const variant = input.variant?.trim() + if (!value) { + if (provider) return { error: "provider requires a model." } + if (!source) return { error: "variant override requires an available current model." } + if (!variant) return source + const active = all.find( + (item) => item.providerID === source.model.providerID && item.model.id === source.model.modelID, + ) + if (!active) { + return { + error: `current model is no longer available: ${source.model.providerID}/${source.model.modelID}. Specify a model override.`, + } + } + if (!active.model.variants || !Object.hasOwn(active.model.variants, variant)) { + return { + error: `variant "${variant}" is not available for ${active.model.name}. Available variants: ${Object.keys(active.model.variants ?? {}).join(", ") || "none"}`, + } + } + return { model: source.model, variant } + } + + const scope = provider ? all.filter((item) => item.providerID === provider) : all + if (provider && scope.length === 0) { + return { error: `provider is not available for model selection: ${provider}. Requested model: ${value}.` } + } + const { pool, names } = lookup(scope, value) + if (pool.length === 0) { + const close = suggest(scope, value) + const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : "" + return { + error: provider + ? `model is not available from provider "${provider}": ${value}.${hint} Use agent_manager_models to search models.` + : `model is not available: ${value}.${hint} Use agent_manager_models to search models.`, + } + } + if (names.length > 1) { + return { + error: `model "${value}" is ambiguous and matches several models: ${names.slice(0, 5).join(", ")}. Use a more specific name.`, + } + } + const eligible = variant + ? pool.filter((item) => item.model.variants && Object.hasOwn(item.model.variants, variant)) + : pool + if (variant && eligible.length === 0) { + const available = [...new Set(pool.flatMap((item) => Object.keys(item.model.variants ?? {})))] + return { + error: `variant "${variant}" is not available for ${names.at(0)}. Available variants: ${available.join(", ") || "none"}`, + } + } + const chosen = [...eligible] + .sort( + (a, b) => + rank(a.providerID, preferred) - rank(b.providerID, preferred) || + a.providerID.localeCompare(b.providerID) || + a.model.id.localeCompare(b.model.id), + ) + .at(0) + if (!chosen) return { error: `model is not available: ${value}. Use agent_manager_models to search models.` } + return { + model: { providerID: chosen.model.providerID, modelID: chosen.model.id }, + ...(variant ? { variant } : {}), + } +} diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 53dfa3132ae1..17383b17e00b 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -206,7 +206,9 @@ export namespace KiloToolRegistry { notebookEdit?: Tool.Def notebookExecute?: Tool.Def }, - cfg: { experimental?: { image_generation?: boolean; native_notebook_tools?: boolean } }, + cfg: { + experimental?: { image_generation?: boolean; native_notebook_tools?: boolean; task_model_selection?: boolean } + }, ): Tool.Def[] { return [ ...(cfg.experimental?.image_generation === true ? [tools.image] : []), @@ -217,7 +219,10 @@ export namespace KiloToolRegistry { ...(Flag.KILO_CLIENT === "vscode" ? [tools.chart] : []), ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), ...(Flag.KILO_CLIENT === "cli" && tools.terminal ? [tools.terminal] : []), - ...(Flag.KILO_CLIENT === "vscode" ? [tools.managerModels, tools.manager] : []), + ...(Flag.KILO_CLIENT === "vscode" || cfg.experimental?.task_model_selection === true + ? [tools.managerModels] + : []), + ...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []), ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.native_notebook_tools === true && tools.notebookRead && diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 08ed2a046696..12d4f1916ca8 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file import { Effect, Schema } from "effect" import path from "path" import { Permission } from "@/permission" @@ -13,6 +12,7 @@ import type { Agent } from "../../agent/agent" import type { Config } from "../../config/config" import { Provider } from "../../provider/provider" import z from "zod" +import { selectModel } from "./model-selection" const log = Log.create({ service: "kilocode-task-model" }) @@ -33,6 +33,24 @@ const ModelState = z .passthrough() export namespace KiloTask { + export const ModelFields = { + model: Schema.optional(Schema.String).annotate({ + description: + "Optional subagent model name or qualified provider/model ID from agent_manager_models. Choose a model suited to this task; omit to use the normal subagent model.", + }), + provider: Schema.optional(Schema.String).annotate({ + description: + "Optional provider ID from agent_manager_models. Requires model; omit to prefer the current turn's provider, then Kilo Gateway.", + }), + variant: Schema.optional(Schema.String).annotate({ + description: + "Optional reasoning effort variant from agent_manager_models. Can be used without model to change only the normal subagent model's reasoning effort.", + }), + } + + export const modelDescription = + "Experimental subagent model selection is enabled. You may choose model, provider, and variant (reasoning effort) for each task based on its complexity, cost, and latency needs. Use agent_manager_models to find available models, providers, and variants before selecting them; do not guess. This does not create Agent Manager sessions. Omit these fields to keep the normal subagent defaults. Resumed tasks keep their last model and variant unless overridden. A model override does not inherit the parent's reasoning effort; specify variant when a particular effort is needed." + /** Reject primary agents used as subagents */ export function validate(info: Agent.Info, name: string) { if (info.mode === "primary") throw new Error(`Agent "${name}" is a primary agent and cannot be used as a subagent`) @@ -146,8 +164,7 @@ export namespace KiloTask { } }) - /** Resolve the task subagent model while discarding stale unavailable overrides. */ - export const resolveModel = Effect.fn("KiloTask.resolveModel")(function* (input: { + const defaults = Effect.fn("KiloTask.defaultModel")(function* (input: { name: string agent: Pick config: Pick @@ -212,6 +229,41 @@ export namespace KiloTask { return { model: input.parent, variant } }) + export const resolveModel = Effect.fn("KiloTask.resolveModel")(function* ( + input: Parameters[0] & { + enabled?: boolean + selection?: { model?: string; provider?: string; variant?: string } + resume?: Session.Info["model"] + }, + ) { + const selection = input.selection ?? {} + const requested = Object.values(selection).some((value) => value !== undefined) + if (requested && !input.enabled) { + return yield* Effect.fail( + new Error("Task model selection requires experimental.task_model_selection=true in Kilo config"), + ) + } + if (requested && Object.values(selection).some((value) => value !== undefined && !value.trim())) { + return yield* Effect.fail(new Error("Task model, provider, and variant must not be empty when specified")) + } + if (selection.provider && !selection.model) { + return yield* Effect.fail(new Error("Task provider requires a model")) + } + const source = selection.model + ? undefined + : input.enabled && input.resume + ? { + model: { providerID: input.resume.providerID, modelID: input.resume.id }, + variant: input.resume.variant === "default" ? undefined : input.resume.variant, + } + : yield* defaults(input) + if (!requested && source) return source + const providers = yield* input.provider.list() + const selected = selectModel(selection, providers, source, input.parent.providerID) + if ("error" in selected) return yield* Effect.fail(new Error(`Task ${selected.error}`)) + return selected + }) + export function workflow(value: unknown): Workflow | undefined { if (!value || typeof value !== "object") return undefined const item = (value as { workflow?: unknown }).workflow diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f43608f7f538..87b822499eca 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -63,6 +63,7 @@ const BaseParameters = Schema.Struct(BaseParameterFields) export const Parameters = Schema.Struct({ ...BaseParameterFields, + ...KiloTask.ModelFields, // kilocode_change background: Schema.optional(Schema.Boolean).annotate({ description: "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress", @@ -107,6 +108,7 @@ export const TaskTool = Tool.define( ctx: Tool.Context, ) { const cfg = yield* config.get() + const selection = cfg.experimental?.task_model_selection === true // kilocode_change const runInBackground = params.background === true if (runInBackground && !flags.experimentalBackgroundSubagents) { return yield* Effect.fail(new Error("Background subagents require KILO_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true")) @@ -164,6 +166,29 @@ export const TaskTool = Tool.define( new Error(`Cannot resume session ${params.task_id}: not a child of the current session`), ) // kilocode_change - prevent cross-session task resume } + // kilocode_change start + const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) + const source = { modelID: msg.info.modelID, providerID: msg.info.providerID } + const selected = yield* KiloTask.resolveModel({ + name: next.name, + agent: next, + config: cfg, + parent: source, + variant: msg.info.variant, + workflow: KiloTask.workflow(ctx.extra), + provider, + enabled: selection, + selection: { model: params.model, provider: params.provider, variant: params.variant }, + resume: session?.model, + }) + const model = selected.model + const variant = selected.variant + const reasoning = msg.info.variant + // kilocode_change end // kilocode_change start — inherit edit/bash/MCP restrictions from calling agent const caller = yield* agent.get(ctx.agent) const rules = KiloTask.inherited({ caller, session: parent, mcp: cfg.mcp }) @@ -208,28 +233,6 @@ export const TaskTool = Tool.define( ) // kilocode_change end - const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe( - Effect.provideService(Database.Service, database), - Effect.orDie, - ) - if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - - // 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, - }, - variant: msg.info.variant, - workflow: KiloTask.workflow(ctx.extra), // kilocode_change - provider, - }) - const model = selected.model - const variant = selected.variant - // kilocode_change end const metadata = { parentSessionId: ctx.sessionID, sessionId: nextSession.id, @@ -295,7 +298,12 @@ export const TaskTool = Tool.define( .prompt({ sessionID: ctx.sessionID, agent: currentParent.agent ?? ctx.agent, - variant, + model: selection + ? currentParent.model + ? { providerID: currentParent.model.providerID, modelID: currentParent.model.id } + : source + : undefined, + variant: selection ? (currentParent.model?.variant ?? reasoning) : variant, parts: [ { type: "text", @@ -466,14 +474,29 @@ export const TaskTool = Tool.define( ) }) - return { - description: flags.experimentalBackgroundSubagents - ? [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n") - : DESCRIPTION, - parameters: Parameters, - jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters), - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => - run(params, ctx).pipe(Effect.orDie), - } + // kilocode_change start + return () => + Effect.gen(function* () { + const cfg = yield* config.get() + const selection = cfg.experimental?.task_model_selection === true + return { + description: [ + DESCRIPTION, + ...(flags.experimentalBackgroundSubagents ? [BACKGROUND_DESCRIPTION] : []), + ...(selection ? [KiloTask.modelDescription] : []), + ].join("\n\n"), + parameters: Parameters, + jsonSchema: ToolJsonSchema.fromSchema( + Schema.Struct({ + ...BaseParameters.fields, + ...(flags.experimentalBackgroundSubagents ? { background: Parameters.fields.background } : {}), + ...(selection ? KiloTask.ModelFields : {}), + }), + ), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), + } + }) + // kilocode_change end }), ) diff --git a/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts index 28134446dbdb..d89a7349afe1 100644 --- a/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer, ManagedRuntime } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" import { AgentManagerModelsTool } from "../../src/kilocode/tool/agent-manager-models" import { Provider } from "../../src/provider/provider" import { MessageID, SessionID } from "../../src/session/schema" @@ -52,6 +53,7 @@ const runtime = ManagedRuntime.make( Layer.mergeAll( AppNodeBuilder.build(Truncate.node), AppNodeBuilder.build(Agent.node), + AppNodeBuilder.build(Config.node), AppNodeBuilder.build(CrossSpawnSpawner.node), Layer.mock(Provider.Service, { list: () => Effect.succeed(providers) }), ), @@ -68,13 +70,15 @@ const ctx = { ask: () => Effect.void, } -function run(params: Record) { +function run(params: Record, selection = false) { return runtime.runPromise( - provideTmpdirInstance(() => - Effect.gen(function* () { - const tool = yield* Tool.init(yield* AgentManagerModelsTool) - return yield* tool.execute(params, ctx) - }), + provideTmpdirInstance( + () => + Effect.gen(function* () { + const tool = yield* Tool.init(yield* AgentManagerModelsTool) + return yield* tool.execute(params, ctx) + }), + { config: { experimental: { task_model_selection: selection } } }, ).pipe(Effect.scoped), ) } @@ -84,6 +88,13 @@ function json(value: string): T { } describe("agent_manager_models tool", () => { + test("explains Task selection only when the experiment is enabled", async () => { + const disabled = await run({ query: "shared" }) + const enabled = await run({ query: "shared" }, true) + expect(json<{ hint: string }>(disabled.output).hint).not.toContain("to task or agent_manager") + expect(json<{ hint: string }>(enabled.output).hint).toContain("to task or agent_manager") + }) + test("returns models grouped by name, capped at 20", async () => { const result = await run({}) const output = json<{ models: Array<{ name: string }>; total: number; nextOffset?: number }>(result.output) diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index 08fecaf87370..7b176880cddf 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -94,6 +94,57 @@ async function setGlobal(dir: string, value: Config.Info) { } describe("config overlay routes", () => { + test("saving task model selection refreshes cached tools without restarting the server", async () => { + await using global = await tmpdir() + await using project = await tmpdir() + await using other = await tmpdir() + ;(Global.Path as { config: string }).config = global.path + const target = Server.Default().app + const provider = { + enabled_providers: ["test"], + provider: { + test: { + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "test", baseURL: "http://localhost:1/v1" }, + models: { model: { name: "Test", limit: { context: 10000, output: 1000 } } }, + }, + }, + } + await config(project.path, provider) + await config(other.path, provider) + const check = async (dir: string, enabled: boolean) => { + const tools = await json< + Array<{ + id: string + description: string + parameters: { properties: Record } + }> + >(await request(target, dir, "/experimental/tool?provider=test&model=model")) + const task = tools.find((tool) => tool.id === "task") + expect(task).toBeDefined() + for (const field of ["model", "provider", "variant"]) { + expect(Object.hasOwn(task!.parameters.properties, field)).toBe(enabled) + } + expect(task!.description.includes("Experimental subagent model selection is enabled")).toBe(enabled) + if (enabled) expect(tools.some((tool) => tool.id === "agent_manager_models")).toBe(true) + } + await check(project.path, false) + await check(other.path, false) + for (const enabled of [true, false, true]) { + const saved = await json( + await request(target, project.path, "/config/overlay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scope: "global", set: { experimental: { task_model_selection: enabled } } }), + }), + ) + expect(saved.effective?.experimental?.task_model_selection).toBe(enabled) + expect(await Bun.file(saved.targets.global.path).text()).toContain(`"task_model_selection": ${enabled}`) + await check(project.path, enabled) + await check(other.path, enabled) + } + }) + test("writes a missing project target atomically", async () => { await using project = await tmpdir() const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path }) @@ -700,16 +751,15 @@ describe("config overlay routes", () => { const edit = body.effective.permission.edit const after = await json(await req(project.path, "/agent")) - expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask") - expect( - Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action, - ).toBe("ask") - expect(body.collections.permission.find((item) => item.key === "edit")).toMatchObject({ - source: "project", - overridden: true, - }) - }, - ) + expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask") + expect(Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action).toBe( + "ask", + ) + expect(body.collections.permission.find((item) => item.key === "edit")).toMatchObject({ + source: "project", + overridden: true, + }) + }) test.serial("refreshes agent permissions after global permission update", async () => { await using global = await tmpdir() diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 6c66d6446dd3..9301edb5de6a 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -375,6 +375,18 @@ describe("kilocode tool registry indexing", () => { "send_file", ]) + for (const client of ["cli", "run", "acp"]) { + process.env["KILO_CLIENT"] = client + const enabled = KiloToolRegistry.extra(tools, { experimental: { task_model_selection: true } }).map( + (tool) => tool.id, + ) + expect(enabled).toContain("agent_manager_models") + expect(enabled).not.toContain("agent_manager") + expect( + KiloToolRegistry.extra(tools, { experimental: { task_model_selection: false } }).map((tool) => tool.id), + ).not.toContain("agent_manager_models") + } + process.env["KILO_CLIENT"] = "vscode" expect(KiloToolRegistry.extra(tools, {}).map((tool) => tool.id)).toEqual([ "semantic_search", diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index 62ee105a839e..6a102d2efbce 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -1,7 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, beforeAll, describe, expect } from "bun:test" -import { Effect } from "effect" +import { Cause, Deferred, Effect, Exit } from "effect" import { Database } from "@opencode-ai/core/database/database" import fs from "fs/promises" import path from "path" @@ -207,6 +207,9 @@ function run(input: { client?: string variant?: string config?: Pick + enabled?: boolean + selection?: { model?: string; provider?: string; variant?: string } + resume?: Session.Info["model"] }) { return provideTmpdirInstance( () => @@ -215,28 +218,41 @@ function run(input: { if (input.state) yield* writeState(input.state) const { chat, assistant } = yield* seed(input.agent, input.variant) + const sessions = yield* Session.Service + const child = input.resume ? yield* sessions.create({ parentID: chat.id, model: input.resume }) : undefined const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined const promptOps = stubOps({ onPrompt: (value) => (seen = value) }) - const result = yield* def.execute( - { - description: `run ${input.agent}`, - prompt: "inspect resolution", - subagent_type: input.agent, - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps, bypassAgentCheck: true }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) + const result = yield* def + .execute( + { + description: `run ${input.agent}`, + prompt: "inspect resolution", + subagent_type: input.agent, + task_id: child?.id, + ...input.selection, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, bypassAgentCheck: true }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe( + Effect.onError(() => + Effect.gen(function* () { + expect(seen).toBeUndefined() + expect(yield* sessions.children(chat.id)).toHaveLength(child ? 1 : 0) + }), + ), + ) return { prompt: seen?.model, @@ -249,6 +265,7 @@ function run(input: { config: { ...catalog, ...input.config, + experimental: { task_model_selection: input.enabled ?? false }, agent: { worker: { mode: "subagent" }, pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, @@ -259,6 +276,169 @@ function run(input: { } describe("tool.task model resolution", () => { + for (const example of [ + { selection: { model: "sub-provider/sub-model", variant: subVariant }, model: sub, variant: subVariant }, + { selection: { model: "SUB model", provider: "sub-provider" }, model: sub, variant: undefined }, + { selection: { variant: overrideVariant }, model: cfg, variant: overrideVariant }, + { selection: {}, model: cfg, variant: cfgVariant }, + ]) { + it.live(`selects ${JSON.stringify(example.selection)} when enabled`, () => + run({ agent: "pinned", enabled: true, selection: example.selection, variant: inherited }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(example.model) + expect(result.variant).toEqual(example.variant) + expect(result.model).toEqual(example.model) + expect(result.metadataVariant).toEqual(example.variant) + }), + ), + ), + ) + } + + for (const example of [ + { enabled: false, selection: { model: "sub-model" }, error: "experimental.task_model_selection=true" }, + { enabled: false, selection: { variant: "full" }, error: "experimental.task_model_selection=true" }, + { enabled: true, selection: { provider: "sub-provider" }, error: "provider requires a model" }, + { enabled: true, selection: { model: "missing" }, error: "model is not available" }, + { enabled: true, selection: { model: "model" }, error: "is ambiguous" }, + { enabled: true, selection: { model: "sub-model", provider: "missing" }, error: "provider is not available" }, + { enabled: true, selection: { model: "sub-model", variant: "missing" }, error: "Available variants:" }, + { enabled: true, selection: { variant: "missing" }, error: "Available variants:" }, + { enabled: true, selection: { model: " " }, error: "must not be empty" }, + { enabled: true, selection: { variant: "__proto__" }, error: "Available variants:" }, + ]) { + it.live(`rejects ${JSON.stringify(example.selection)} with selection ${example.enabled}`, () => + run({ agent: "worker", enabled: example.enabled, selection: example.selection }).pipe( + Effect.exit, + Effect.tap((result) => + Effect.sync(() => { + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) expect(Cause.pretty(result.cause)).toContain(example.error) + }), + ), + ), + ) + } + + for (const enabled of [false, true]) { + it.live(`uses ${enabled ? "persisted" : "normal"} defaults on resume`, () => + run({ + agent: "pinned", + enabled, + resume: { id: sub.modelID, providerID: sub.providerID, variant: subVariant }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(enabled ? sub : cfg) + expect(result.variant).toEqual(enabled ? subVariant : cfgVariant) + }), + ), + ), + ) + } + + it.live("allows a reasoning override on a resumed model", () => + run({ + agent: "pinned", + enabled: true, + resume: { id: sub.modelID, providerID: sub.providerID, variant: subVariant }, + selection: { variant: overrideVariant }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(sub) + expect(result.variant).toEqual(overrideVariant) + }), + ), + ), + ) + + for (const enabled of [undefined, false, true]) { + for (const background of [false, true]) { + it.live(`advertises selection ${enabled} independently of background ${background}`, () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const tool = yield* TaskTool.pipe( + Effect.provide(RuntimeFlags.layer({ experimentalBackgroundSubagents: background })), + ) + const def = yield* tool.init() + const fields = def.jsonSchema?.properties ?? {} + for (const field of ["model", "provider", "variant"]) expect(field in fields).toBe(enabled === true) + expect("background" in fields).toBe(background) + expect(def.description.includes("Experimental subagent model selection is enabled")).toBe( + enabled === true, + ) + }), + { config: { experimental: { task_model_selection: enabled } } }, + ), + ) + } + } + + it.live("keeps the latest parent model and reasoning when a background override completes", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed("background", inherited) + const sessions = yield* Session.Service + const notified = yield* Deferred.make() + const calls: SessionPrompt.PromptInput[] = [] + const tool = yield* TaskTool.pipe( + Effect.provide(RuntimeFlags.layer({ experimentalBackgroundSubagents: true })), + ) + const def = yield* tool.init() + const promptOps: TaskPromptOps = { + ...stubOps(), + prompt: (input) => + Effect.gen(function* () { + calls.push(input) + if (input.sessionID === chat.id) yield* Deferred.succeed(notified, input) + if (input.sessionID !== chat.id) { + yield* sessions.setAgentModel({ + sessionID: chat.id, + agent: "build", + model: { id: cfg.modelID, providerID: cfg.providerID, variant: cfgVariant }, + time: Date.now(), + }) + } + return reply(input, "done") + }), + } + const result = yield* def.execute( + { + description: "background selection", + prompt: "inspect selection", + subagent_type: "general", + background: true, + model: "sub-model", + provider: "sub-provider", + variant: subVariant, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, bypassAgentCheck: true }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const notice = yield* Deferred.await(notified).pipe(Effect.timeout("5 seconds")) + expect(result.metadata.model).toEqual(sub) + expect(result.metadata.variant).toEqual(subVariant) + expect(calls.at(0)?.model).toEqual(sub) + expect(calls.at(0)?.variant).toEqual(subVariant) + expect(notice.model).toEqual(cfg) + expect(notice.variant).toEqual(cfgVariant) + }), + { config: { ...catalog, experimental: { task_model_selection: true } } }, + ), + ) + it.live("saved model beats agent config for pinned", () => run({ agent: "pinned", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c6f27033345f..77634a839840 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2680,6 +2680,7 @@ export type Config = { image_generation?: boolean image_generation_model?: string native_notebook_tools?: boolean + task_model_selection?: boolean speech_to_text_model?: string openTelemetry?: boolean primary_tools?: Array diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0f24e06896d4..27c2db5fbe2d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -34780,6 +34780,9 @@ "native_notebook_tools": { "type": "boolean" }, + "task_model_selection": { + "type": "boolean" + }, "speech_to_text_model": { "type": "string" }, From 7929565cad35ab09923205e82fc9b8456a57dab0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 28 Aug 2026 16:12:43 +0200 Subject: [PATCH 2/2] test(cli): refresh task model selection schema snapshot --- .../test/tool/__snapshots__/parameters.test.ts.snap | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index a31700b7c0e2..48d253985a86 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -376,10 +376,18 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` "description": "A short (3-5 words) description of the task", "type": "string", }, + "model": { + "description": "Optional subagent model name or qualified provider/model ID from agent_manager_models. Choose a model suited to this task; omit to use the normal subagent model.", + "type": "string", + }, "prompt": { "description": "The task for the agent to perform", "type": "string", }, + "provider": { + "description": "Optional provider ID from agent_manager_models. Requires model; omit to prefer the current turn's provider, then Kilo Gateway.", + "type": "string", + }, "subagent_type": { "description": "The type of specialized agent to use for this task", "type": "string", @@ -388,6 +396,10 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` "description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", "type": "string", }, + "variant": { + "description": "Optional reasoning effort variant from agent_manager_models. Can be used without model to change only the normal subagent model's reasoning effort.", + "type": "string", + }, }, "required": [ "description",