diff --git a/packages/core/src/v1/config/permission.ts b/packages/core/src/v1/config/permission.ts index 475dc7bbf3f2..d387619b6866 100644 --- a/packages/core/src/v1/config/permission.ts +++ b/packages/core/src/v1/config/permission.ts @@ -28,6 +28,7 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), + model_override: Schema.optional(Rule), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 86dc417ca58b..6d7aca71944a 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -107,6 +107,7 @@ export const layer = Layer.effect( const defaults = Permission.fromConfig({ "*": "allow", doom_loop: "ask", + model_override: "deny", external_directory: { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index dac340184cfc..e4ceac6e561d 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,7 +10,9 @@ import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" -import { Effect, Exit, Schema, Scope } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Cause, Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@opencode-ai/core/database/database" @@ -44,6 +46,10 @@ const BaseParameterFields = { description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + model: Schema.optional(Schema.String).annotate({ + description: + "Override the model for this subagent. Format: provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o). Takes precedence over the agent's configured model.", + }), task_id: Schema.optional(Schema.String).annotate({ 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)", @@ -78,6 +84,24 @@ function renderOutput(input: { ].join("\n") } +function errorText(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +function parseModelOverride(model: string): Effect.Effect<{ modelID: ModelV2.ID; providerID: ProviderV2.ID }, Error> { + const slash = model.indexOf("/") + if (slash <= 0 || slash === model.length - 1) { + return Effect.fail( + new Error(`Invalid model format: "${model}". Expected provider/model (e.g. anthropic/claude-sonnet-4)`), + ) + } + return Effect.succeed({ + providerID: ProviderV2.ID.make(model.slice(0, slash)), + modelID: ModelV2.ID.make(model.slice(slash + 1)), + }) +} + export const TaskTool = Tool.define( id, Effect.gen(function* () { @@ -100,6 +124,24 @@ export const TaskTool = Tool.define( new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"), ) } + const modelOverride = params.model + const overrideModel = + modelOverride === undefined + ? undefined + : yield* parseModelOverride(modelOverride) + + if (overrideModel && modelOverride !== undefined) { + yield* ctx.ask({ + permission: "model_override", + patterns: [modelOverride], + always: [modelOverride], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + model: modelOverride, + }, + }) + } if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ @@ -168,7 +210,7 @@ export const TaskTool = Tool.define( if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) const variant = msg.info.variant - const model = next.model ?? { + const model = overrideModel ?? next.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index c5e412f409d9..f34adefe9e95 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -8,6 +8,12 @@ When NOT to use the Task tool: - If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly - If no available agent is a good fit for the task, use other tools directly +Model selection: +- Each agent has a default model (usually inherited from the parent session). +- You can override the model by passing the `model` parameter in `provider/model` format (e.g. `anthropic/claude-sonnet-4`, `openai/gpt-4o`, `google/gemini-2.5-pro`). +- Model overrides require the `model_override` permission. By default this permission is denied. The user can allow specific models or providers in their config (e.g. `"model_override": { "anthropic/*": "allow" }`). +- Model selection precedence is `model` parameter, then the subagent's configured model, then the parent assistant message model. + Usage notes: 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index b187b191c1f5..1be58b9dae89 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -331,6 +331,10 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` "description": "A short (3-5 words) description of the task", "type": "string", }, + "model": { + "description": "Override the model for this subagent. Format: provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o). Takes precedence over the agent's configured model.", + "type": "string", + }, "prompt": { "description": "The task for the agent to perform", "type": "string", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 4e56c61d23c4..c579cd08596f 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -246,6 +246,10 @@ describe("tool parameters", () => { const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true }) expect(parsed.background).toBe(true) }) + test("accepts optional model override", () => { + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", model: "openai/gpt-4o" }) + expect(parsed.model).toBe("openai/gpt-4o") + }) test("rejects missing prompt", () => { expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 5a45cfee3e2f..798ecb6c81ec 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" -import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" @@ -448,6 +448,245 @@ describe("tool.task", () => { }, ) + it.instance( + "execute uses explicit model override before subagent and parent models", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + expect(result.metadata.model.providerID as string).toBe("anthropic") + expect(result.metadata.model.modelID as string).toBe("claude-sonnet-4") + expect((seen?.model?.providerID ?? "") as string).toBe("anthropic") + expect((seen?.model?.modelID ?? "") as string).toBe("claude-sonnet-4") + expect(calls[0]).toEqual({ + permission: "model_override", + patterns: ["anthropic/claude-sonnet-4"], + always: ["anthropic/claude-sonnet-4"], + metadata: { + description: "inspect bug", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + }) + expect(calls[1]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + { + config: { + agent: { + general: { + model: "openai/gpt-4o-mini", + }, + }, + }, + }, + ) + + it.instance("stops before task permission when model override permission fails", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }).pipe( + Effect.andThen( + input.permission === "model_override" + ? Effect.die(new Error("model override denied")) + : Effect.void, + ), + ), + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(calls).toEqual([ + { + permission: "model_override", + patterns: ["anthropic/claude-sonnet-4"], + always: ["anthropic/claude-sonnet-4"], + metadata: { + description: "inspect bug", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + }, + ]) + }), + ) + + it.instance( + "execute uses subagent model when no explicit override is provided", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.model.providerID as string).toBe("openai") + expect(result.metadata.model.modelID as string).toBe("gpt-4o-mini") + expect((seen?.model?.providerID ?? "") as string).toBe("openai") + expect((seen?.model?.modelID ?? "") as string).toBe("gpt-4o-mini") + }), + { + config: { + agent: { + general: { + model: "openai/gpt-4o-mini", + }, + }, + }, + }, + ) + + it.instance("execute uses parent assistant model when no explicit or subagent model is provided", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.model.providerID).toBe(ref.providerID) + expect(result.metadata.model.modelID).toBe(ref.modelID) + expect(seen?.model?.providerID).toBe(ref.providerID) + expect(seen?.model?.modelID).toBe(ref.modelID) + }), + ) + + it.instance("rejects invalid model override strings before asking permissions", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + yield* Effect.forEach(["gpt-4o", "openai/"], (model) => + Effect.gen(function* () { + const calls: unknown[] = [] + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(`Invalid model format: "${model}"`) + expect(calls).toHaveLength(0) + }), + ) + }), + ) + it.instance("rejects background execution when the experiment is disabled", () => Effect.gen(function* () { const { chat, assistant } = yield* seed()