From 473ed0591acb4bdfecdd06512a603179d49e88eb Mon Sep 17 00:00:00 2001 From: kobicovaldev Date: Tue, 26 May 2026 12:37:02 -0700 Subject: [PATCH] feat(opencode): add task model override --- packages/opencode/src/agent/agent.ts | 1 + packages/opencode/src/config/permission.ts | 1 + packages/opencode/src/tool/task.ts | 38 ++- packages/opencode/src/tool/task.txt | 6 + .../__snapshots__/parameters.test.ts.snap | 4 + .../opencode/test/tool/parameters.test.ts | 4 + packages/opencode/test/tool/task.test.ts | 241 +++++++++++++++++- 7 files changed, 293 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 064a59f59ed1..b595b4ed4bce 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -106,6 +106,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/config/permission.ts b/packages/opencode/src/config/permission.ts index 1092ae2b7e14..791a5a9ad6cb 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -29,6 +29,7 @@ const InputObject = Schema.StructWithRest( websearch: Schema.optional(Action), repo_clone: Schema.optional(Rule), repo_overview: Schema.optional(Rule), + model_override: Schema.optional(Rule), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a9a29debbcd1..9079c0393a48 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,6 +9,7 @@ import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" +import { ModelID, ProviderID } from "../provider/schema" import { Cause, Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -35,6 +36,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)", @@ -93,6 +98,19 @@ function errorText(error: unknown) { return String(error) } +function parseModelOverride(model: string): Effect.Effect<{ modelID: ModelID; providerID: ProviderID }, 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: ProviderID.make(model.slice(0, slash)), + modelID: ModelID.make(model.slice(slash + 1)), + }) +} + export const TaskTool = Tool.define( id, Effect.gen(function* () { @@ -114,6 +132,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({ @@ -161,7 +197,7 @@ export const TaskTool = Tool.define( const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - 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 e2ac605005b4..248b9404808a 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 1be32979ddfe..508cc61822a5 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 3a124be81b0c..d82268558d51 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -239,6 +239,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 17e7fbea614f..e7fb7eaa7c79 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" @@ -445,6 +445,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()