diff --git a/.changeset/agent-manager-inherit-model.md b/.changeset/agent-manager-inherit-model.md new file mode 100644 index 00000000000..4753b1647e4 --- /dev/null +++ b/.changeset/agent-manager-inherit-model.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Inherit the current model and reasoning variant when Agent Manager starts sessions without explicit overrides. diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 446672c539b..cc3cdb6be4a 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -153,7 +153,7 @@ The tool supports two modes: | `worktree` | Creates one Agent Manager git worktree and session per task | | `local` | Creates Agent Manager sessions in the current workspace without git worktree isolation | -Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. A task with an initial prompt can also specify a `model` (by name, e.g. `Claude Opus 4.1`) and one of that model's reasoning `variant` values. Agent Manager resolves the provider for the chosen model, preferring the provider used by the current default model and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Tasks without those fields use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. +Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Agent Manager resolves the provider for a model override, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context. diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.ts b/packages/opencode/src/kilocode/tool/agent-manager-models.ts index 3f84c13b143..6b0ddef5d5b 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.ts @@ -89,7 +89,7 @@ export const AgentManagerModelsTool = Tool.define< offset, total: matches.length, nextOffset, - hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one you use by default.", + hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one used by the current turn.", }), metadata: { count: models.length, total: matches.length }, } diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.txt b/packages/opencode/src/kilocode/tool/agent-manager-models.txt index aa6ee917194..8c73797bde6 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.txt @@ -2,4 +2,4 @@ Search the models available to Agent Manager sessions and inspect their reasonin Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, because you select a model and Agent Manager chooses the provider for you. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. -Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider you use by default and falling back to the Kilo Gateway, so you do not need to choose a provider yourself. +Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider used by the current turn and falling back to the Kilo Gateway, so you do not need to choose a provider yourself. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 2eb7c48e98d..b042e26ee34 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -1,6 +1,7 @@ // kilocode_change - new file import { Bus } from "@/bus" import { AgentManagerEvent, type AgentManagerTask } from "@/kilocode/agent-manager/event" +import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" import { Provider } from "@/provider/provider" import { Tool } from "@/tool/tool" import { Effect, Schema } from "effect" @@ -13,10 +14,11 @@ const Task = Schema.Struct({ branchName: Schema.optional(Schema.String).annotate({ description: "Git branch name seed for worktree mode" }), model: Schema.optional(Schema.String).annotate({ description: - "Model name from agent_manager_models (e.g. 'Claude Opus 4.1'). Agent Manager picks the provider. A qualified provider/model ID is also accepted to force a specific provider.", + "Optional model override from agent_manager_models (e.g. 'Claude Opus 4.1'). Omit unless the user requests a different model. Agent Manager otherwise inherits the current turn's model. A qualified provider/model ID is also accepted to force a specific provider.", }), variant: Schema.optional(Schema.String).annotate({ - description: "Reasoning variant name for this model, from agent_manager_models", + description: + "Optional reasoning variant override from agent_manager_models. Specify it without model to override the inherited model's variant. Omit both to inherit the current turn's selection.", }), }).check( Schema.makeFilter((task) => @@ -28,7 +30,7 @@ const Task = Schema.Struct({ task.model?.trim() && !task.prompt?.trim() ? "A task model requires an initial prompt" : undefined, ), Schema.makeFilter((task) => - task.variant?.trim() && !task.model?.trim() ? "A task variant requires a model" : undefined, + task.variant?.trim() && !task.prompt?.trim() ? "A task variant requires an initial prompt" : undefined, ), ) @@ -48,6 +50,7 @@ export const Params = 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 candidates(providers: Record): Candidate[] { return Object.values(providers).flatMap((provider) => @@ -90,7 +93,7 @@ function suggest(all: Candidate[], value: string): string[] { .map((entry) => entry[0]) } -// Prefer the provider the user already uses by default, then the Kilo Gateway, +// 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 { @@ -99,14 +102,44 @@ function rank(providerID: string, preferred: string | undefined): number { return 2 } -function select(task: Input, all: Candidate[], preferred: string | undefined, index: number): Selected { +function select( + task: Input, + all: Candidate[], + preferred: string | undefined, + source: Source | undefined, + index: number, +): Selected { const base = { ...(task.prompt !== undefined ? { prompt: task.prompt } : {}), ...(task.name !== undefined ? { name: task.name } : {}), ...(task.branchName !== undefined ? { branchName: task.branchName } : {}), } const value = task.model?.trim() - if (!value) return { task: base } + 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 { pool, names } = lookup(all, value) if (pool.length === 0) { @@ -122,7 +155,6 @@ function select(task: Input, all: Candidate[], preferred: string | undefined, in } } - const variant = task.variant?.trim() const eligible = variant ? pool.filter((item) => item.model.variants && Object.hasOwn(item.model.variants, variant)) : pool @@ -133,7 +165,12 @@ function select(task: Input, all: Candidate[], preferred: string | undefined, in } } - const chosen = [...eligible].sort((a, b) => rank(a.providerID, preferred) - rank(b.providerID, preferred))[0]! + 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, @@ -158,15 +195,26 @@ export const AgentManagerTool = Tool.define< parameters: Params, execute: (params, ctx) => Effect.gen(function* () { - const need = params.tasks.some((task) => task.model?.trim()) + const msg = KiloSessionMessageOrder.latest(ctx.messages).user + const source: Source | undefined = msg + ? { + model: { + providerID: msg.model.providerID, + modelID: msg.model.modelID, + }, + ...(msg.model.variant ? { variant: msg.model.variant } : {}), + } + : undefined + const need = params.tasks.some((task) => task.model?.trim() || task.variant?.trim()) const all = need ? candidates(yield* provider.list()) : [] const preferred = need - ? yield* provider.defaultModel().pipe( + ? (source?.model.providerID ?? + (yield* provider.defaultModel().pipe( Effect.map((model) => model.providerID as string), Effect.catch(() => Effect.succeed(undefined)), - ) + ))) : undefined - const selected = params.tasks.map((task, index) => select(task, all, preferred, index)) + const selected = params.tasks.map((task, index) => select(task, all, preferred, source, index)) const errors = selected.flatMap((item) => (item.error ? [item.error] : [])) if (errors.length > 0) { return { @@ -199,8 +247,8 @@ export const AgentManagerTool = Tool.define< // Echo how each named model resolved (provider + variant) so the agent // and the user can confirm the resolution without opening the session. - const resolved = tasks.flatMap((task) => { - if (!task.model) return [] + 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 diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 6a9bd883c47..a7ccaaf0d5b 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -6,7 +6,7 @@ Modes: - `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. - `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation. -Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. Specify `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider you use by default and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Tasks that omit `model` and `variant` use the normal defaults. The agent and base branch settings always use the normal defaults. +Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. By default, omit `model` and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes. diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 3c6bff4509d..6a5bce9923d 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -10,6 +10,7 @@ import { Tool } from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" import { Agent } from "../../src/agent/agent" import { Provider } from "../../src/provider/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" const providers = { test: { @@ -39,6 +40,14 @@ const providers = { name: "Zeta Provider", models: { "zeta/only": { id: "zeta/only", providerID: "zeta", name: "Gateway Only", variants: { low: {} } }, + "zeta/shared": { id: "zeta/shared", providerID: "zeta", name: "External Shared", variants: {} }, + }, + } as unknown as Provider.Info, + alpha: { + id: "alpha", + name: "Alpha Provider", + models: { + "alpha/shared": { id: "alpha/shared", providerID: "alpha", name: "External Shared", variants: {} }, }, } as unknown as Provider.Info, } @@ -76,13 +85,41 @@ const ctx = { callID: "call_agent_manager", agent: "build", abort: AbortSignal.any([]), - messages: [], + messages: [] as Tool.Context["messages"], metadata: () => Effect.void, ask: () => Effect.void, } +function message( + id: string, + provider: string, + model: string, + variant?: string, + created = 1, +): Tool.Context["messages"][number] { + return { + info: { + id: MessageID.make(id), + sessionID: ctx.sessionID, + role: "user", + time: { created }, + agent: "build", + model: { + providerID: ProviderID.make(provider), + modelID: ModelID.make(model), + ...(variant ? { variant } : {}), + }, + }, + parts: [], + } +} + // Run one local task and return the resolved task published on the Start event. -function publish(rt: ReturnType, task: Record) { +function publish( + rt: ReturnType, + task: Record, + messages: Tool.Context["messages"] = ctx.messages, +) { return rt.runPromise( provideTmpdirInstance(() => Effect.gen(function* () { @@ -93,7 +130,7 @@ function publish(rt: ReturnType, task: Record Effect.sync(off)) - yield* tool.execute({ mode: "local", tasks: [task] }, { ...ctx, ask: () => Effect.void }) + yield* tool.execute({ mode: "local", tasks: [task] }, { ...ctx, messages, ask: () => Effect.void }) const event = yield* Queue.take(events).pipe(Effect.timeout("2 seconds")) return event.tasks[0] }), @@ -125,6 +162,56 @@ describe("agent_manager tool", () => { ]) }) + test("inherits the latest invoking model and variant when omitted", async () => { + const task = await publish(runtime, { prompt: "Fix" }, [ + message("msg_current", "kilo", "kilo/shared", "low", 2), + message("msg_old", "test", "reasoning/model", "high", 1), + ]) + + expect(String(task?.model?.providerID)).toBe("kilo") + expect(String(task?.model?.modelID)).toBe("kilo/shared") + expect(task?.variant).toBe("low") + }) + + test("leaves prepared sessions on normal defaults", async () => { + const task = await publish(runtime, { name: "Prepared" }, [ + message("msg_current", "test", "reasoning/model", "high"), + ]) + + expect(task?.model).toBeUndefined() + expect(task?.variant).toBeUndefined() + }) + + test("explicit model and variant override the invoking selection", async () => { + const task = await publish(runtime, { prompt: "Fix", model: "test/reasoning/model", variant: "high" }, [ + message("msg_current", "kilo", "kilo/shared", "low"), + ]) + + expect(String(task?.model?.providerID)).toBe("test") + expect(String(task?.model?.modelID)).toBe("reasoning/model") + expect(task?.variant).toBe("high") + }) + + test("does not inherit a variant when only the model is overridden", async () => { + const task = await publish(runtime, { prompt: "Fix", model: "Gateway Only" }, [ + message("msg_current", "test", "reasoning/model", "high"), + ]) + + expect(String(task?.model?.providerID)).toBe("kilo") + expect(String(task?.model?.modelID)).toBe("kilo/only") + expect(task?.variant).toBeUndefined() + }) + + test("overrides only the inherited variant when model is omitted", async () => { + const task = await publish(runtime, { prompt: "Fix", variant: "high" }, [ + message("msg_current", "test", "reasoning/model", "low"), + ]) + + expect(String(task?.model?.providerID)).toBe("test") + expect(String(task?.model?.modelID)).toBe("reasoning/model") + expect(task?.variant).toBe("high") + }) + test("publishes validated model and variant selections", async () => { const tool = await init() @@ -172,6 +259,20 @@ describe("agent_manager tool", () => { await rt.dispose() }) + test("prefers the invoking provider for an explicit model override", async () => { + const task = await publish(runtime, { prompt: "Fix", model: "Shared", variant: "low" }, [ + message("msg_current", "kilo", "kilo/only", "low"), + ]) + expect(String(task?.model?.providerID)).toBe("kilo") + expect(String(task?.model?.modelID)).toBe("kilo/shared") + }) + + test("uses a stable provider tie-breaker for explicit model overrides", async () => { + const task = await publish(runtime, { prompt: "Fix", model: "External Shared" }) + expect(String(task?.model?.providerID)).toBe("alpha") + expect(String(task?.model?.modelID)).toBe("alpha/shared") + }) + test("resolves an approximate, reordered model name", async () => { const task = await publish(runtime, { prompt: "Fix", model: "model reasoning" }) expect(String(task?.model?.providerID)).toBe("test") @@ -245,6 +346,28 @@ describe("agent_manager tool", () => { expect(result.metadata.count).toBe(0) }) + test("rejects unavailable variant-only overrides before requesting permission", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix issue", variant: "toString" }] }, + { + ...ctx, + messages: [message("msg_current", "test", "reasoning/model", "low")], + ask: (input: unknown) => Effect.sync(() => calls.push(input)), + }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain('variant "toString" is not available for Reasoning Model') + expect(result.metadata.count).toBe(0) + }) + test("rejects inherited provider and model properties", async () => { const tool = await init()