Skip to content

Commit 77d6c3b

Browse files
feat(opencode): add task model override
Add an optional `model` parameter to the task tool (provider/model format) that overrides the subagent's model, gated behind a new `model_override` permission that defaults to deny. Selection precedence is the `model` parameter, then the subagent's configured model, then the parent assistant message model. Ported from anomalyco#29447 (adapted for the Effect layer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 787a213 commit 77d6c3b

7 files changed

Lines changed: 291 additions & 2 deletions

File tree

packages/core/src/v1/config/permission.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const InputObject = Schema.StructWithRest(
2828
question: Schema.optional(Action),
2929
webfetch: Schema.optional(Action),
3030
websearch: Schema.optional(Action),
31+
model_override: Schema.optional(Rule),
3132
lsp: Schema.optional(Rule),
3233
doom_loop: Schema.optional(Action),
3334
skill: Schema.optional(Rule),

packages/opencode/src/agent/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const layer = Layer.effect(
119119
const defaults = Permission.fromConfig({
120120
"*": "allow",
121121
doom_loop: "ask",
122+
model_override: "deny",
122123
external_directory: {
123124
"*": "ask",
124125
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),

packages/opencode/src/tool/task.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { Agent } from "../agent/agent"
1010
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
1111
import type { SessionPrompt } from "../session/prompt"
1212
import { Config } from "@/config/config"
13+
import { ModelV2 } from "@opencode-ai/core/model"
14+
import { ProviderV2 } from "@opencode-ai/core/provider"
1315
import { Effect, Exit, Schema, Scope } from "effect"
1416
import { EffectBridge } from "@/effect/bridge"
1517
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -44,6 +46,10 @@ const BaseParameterFields = {
4446
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
4547
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
4648
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
49+
model: Schema.optional(Schema.String).annotate({
50+
description:
51+
"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.",
52+
}),
4753
task_id: Schema.optional(Schema.String).annotate({
4854
description:
4955
"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,19 @@ function renderOutput(input: {
7884
].join("\n")
7985
}
8086

87+
function parseModelOverride(model: string): Effect.Effect<{ modelID: ModelV2.ID; providerID: ProviderV2.ID }, Error> {
88+
const slash = model.indexOf("/")
89+
if (slash <= 0 || slash === model.length - 1) {
90+
return Effect.fail(
91+
new Error(`Invalid model format: "${model}". Expected provider/model (e.g. anthropic/claude-sonnet-4)`),
92+
)
93+
}
94+
return Effect.succeed({
95+
providerID: ProviderV2.ID.make(model.slice(0, slash)),
96+
modelID: ModelV2.ID.make(model.slice(slash + 1)),
97+
})
98+
}
99+
81100
export const TaskTool = Tool.define(
82101
id,
83102
Effect.gen(function* () {
@@ -116,6 +135,23 @@ export const TaskTool = Tool.define(
116135
)
117136
}
118137

138+
const modelOverride = params.model
139+
const overrideModel = modelOverride === undefined ? undefined : yield* parseModelOverride(modelOverride)
140+
141+
if (overrideModel && modelOverride !== undefined) {
142+
yield* ctx.ask({
143+
permission: "model_override",
144+
patterns: [modelOverride],
145+
always: [modelOverride],
146+
metadata: {
147+
description: params.description,
148+
subagent_type: params.subagent_type,
149+
model: modelOverride,
150+
},
151+
})
152+
}
153+
}
154+
119155
if (!ctx.extra?.bypassAgentCheck) {
120156
yield* ctx.ask({
121157
permission: id,
@@ -178,7 +214,7 @@ export const TaskTool = Tool.define(
178214
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
179215
const variant = msg.info.variant
180216

181-
const model = next.model ?? {
217+
const model = overrideModel ?? next.model ?? {
182218
modelID: msg.info.modelID,
183219
providerID: msg.info.providerID,
184220
}

packages/opencode/src/tool/task.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ When NOT to use the Task tool:
88
- 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
99
- If no available agent is a good fit for the task, use other tools directly
1010

11+
Model selection:
12+
- Each agent has a default model (usually inherited from the parent session).
13+
- 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`).
14+
- 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" }`).
15+
- Model selection precedence is `model` parameter, then the subagent's configured model, then the parent assistant message model.
16+
1117

1218
Usage notes:
1319
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses

packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = `
313313
"description": "A short (3-5 words) description of the task",
314314
"type": "string",
315315
},
316+
"model": {
317+
"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.",
318+
"type": "string",
319+
},
316320
"prompt": {
317321
"description": "The task for the agent to perform",
318322
"type": "string",

packages/opencode/test/tool/parameters.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@ describe("tool parameters", () => {
243243
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true })
244244
expect(parsed.background).toBe(true)
245245
})
246+
test("accepts optional model override", () => {
247+
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", model: "openai/gpt-4o" })
248+
expect(parsed.model).toBe("openai/gpt-4o")
249+
})
246250
test("rejects missing prompt", () => {
247251
expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
248252
})

packages/opencode/test/tool/task.test.ts

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
33
import { Database } from "@opencode-ai/core/database/database"
44
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
55
import { SessionProjector } from "@opencode-ai/core/session/projector"
6-
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
6+
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
77
import { Agent } from "../../src/agent/agent"
88
import { BackgroundJob } from "@/background/job"
99
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -536,6 +536,243 @@ describe("tool.task", () => {
536536
},
537537
)
538538

539+
it.instance(
540+
"execute uses explicit model override before subagent and parent models",
541+
() =>
542+
Effect.gen(function* () {
543+
const { chat, assistant } = yield* seed()
544+
const tool = yield* TaskTool
545+
const def = yield* tool.init()
546+
const calls: unknown[] = []
547+
let seen: SessionPrompt.PromptInput | undefined
548+
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
549+
550+
const result = yield* def.execute(
551+
{
552+
description: "inspect bug",
553+
prompt: "look into the cache key path",
554+
subagent_type: "general",
555+
model: "anthropic/claude-sonnet-4",
556+
},
557+
{
558+
sessionID: chat.id,
559+
messageID: assistant.id,
560+
agent: "build",
561+
abort: new AbortController().signal,
562+
extra: { promptOps },
563+
messages: [],
564+
metadata: () => Effect.void,
565+
ask: (input) =>
566+
Effect.sync(() => {
567+
calls.push(input)
568+
}),
569+
},
570+
)
571+
572+
expect(result.metadata.model.providerID as string).toBe("anthropic")
573+
expect(result.metadata.model.modelID as string).toBe("claude-sonnet-4")
574+
expect((seen?.model?.providerID ?? "") as string).toBe("anthropic")
575+
expect((seen?.model?.modelID ?? "") as string).toBe("claude-sonnet-4")
576+
expect(calls[0]).toEqual({
577+
permission: "model_override",
578+
patterns: ["anthropic/claude-sonnet-4"],
579+
always: ["anthropic/claude-sonnet-4"],
580+
metadata: {
581+
description: "inspect bug",
582+
subagent_type: "general",
583+
model: "anthropic/claude-sonnet-4",
584+
},
585+
})
586+
expect(calls[1]).toEqual({
587+
permission: "task",
588+
patterns: ["general"],
589+
always: ["*"],
590+
metadata: {
591+
description: "inspect bug",
592+
subagent_type: "general",
593+
},
594+
})
595+
}),
596+
{
597+
config: {
598+
agent: {
599+
general: {
600+
model: "openai/gpt-4o-mini",
601+
},
602+
},
603+
},
604+
},
605+
)
606+
607+
it.instance("stops before task permission when model override permission fails", () =>
608+
Effect.gen(function* () {
609+
const { chat, assistant } = yield* seed()
610+
const tool = yield* TaskTool
611+
const def = yield* tool.init()
612+
const calls: unknown[] = []
613+
614+
const exit = yield* def
615+
.execute(
616+
{
617+
description: "inspect bug",
618+
prompt: "look into the cache key path",
619+
subagent_type: "general",
620+
model: "anthropic/claude-sonnet-4",
621+
},
622+
{
623+
sessionID: chat.id,
624+
messageID: assistant.id,
625+
agent: "build",
626+
abort: new AbortController().signal,
627+
extra: { promptOps: stubOps() },
628+
messages: [],
629+
metadata: () => Effect.void,
630+
ask: (input) =>
631+
Effect.sync(() => {
632+
calls.push(input)
633+
}).pipe(
634+
Effect.andThen(
635+
input.permission === "model_override" ? Effect.die(new Error("model override denied")) : Effect.void,
636+
),
637+
),
638+
},
639+
)
640+
.pipe(Effect.exit)
641+
642+
expect(Exit.isFailure(exit)).toBe(true)
643+
expect(calls).toEqual([
644+
{
645+
permission: "model_override",
646+
patterns: ["anthropic/claude-sonnet-4"],
647+
always: ["anthropic/claude-sonnet-4"],
648+
metadata: {
649+
description: "inspect bug",
650+
subagent_type: "general",
651+
model: "anthropic/claude-sonnet-4",
652+
},
653+
},
654+
])
655+
}),
656+
)
657+
658+
it.instance(
659+
"execute uses subagent model when no explicit override is provided",
660+
() =>
661+
Effect.gen(function* () {
662+
const { chat, assistant } = yield* seed()
663+
const tool = yield* TaskTool
664+
const def = yield* tool.init()
665+
let seen: SessionPrompt.PromptInput | undefined
666+
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
667+
668+
const result = yield* def.execute(
669+
{
670+
description: "inspect bug",
671+
prompt: "look into the cache key path",
672+
subagent_type: "general",
673+
},
674+
{
675+
sessionID: chat.id,
676+
messageID: assistant.id,
677+
agent: "build",
678+
abort: new AbortController().signal,
679+
extra: { promptOps },
680+
messages: [],
681+
metadata: () => Effect.void,
682+
ask: () => Effect.void,
683+
},
684+
)
685+
686+
expect(result.metadata.model.providerID as string).toBe("openai")
687+
expect(result.metadata.model.modelID as string).toBe("gpt-4o-mini")
688+
expect((seen?.model?.providerID ?? "") as string).toBe("openai")
689+
expect((seen?.model?.modelID ?? "") as string).toBe("gpt-4o-mini")
690+
}),
691+
{
692+
config: {
693+
agent: {
694+
general: {
695+
model: "openai/gpt-4o-mini",
696+
},
697+
},
698+
},
699+
},
700+
)
701+
702+
it.instance("execute uses parent assistant model when no explicit or subagent model is provided", () =>
703+
Effect.gen(function* () {
704+
const { chat, assistant } = yield* seed()
705+
const tool = yield* TaskTool
706+
const def = yield* tool.init()
707+
let seen: SessionPrompt.PromptInput | undefined
708+
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
709+
710+
const result = yield* def.execute(
711+
{
712+
description: "inspect bug",
713+
prompt: "look into the cache key path",
714+
subagent_type: "general",
715+
},
716+
{
717+
sessionID: chat.id,
718+
messageID: assistant.id,
719+
agent: "build",
720+
abort: new AbortController().signal,
721+
extra: { promptOps },
722+
messages: [],
723+
metadata: () => Effect.void,
724+
ask: () => Effect.void,
725+
},
726+
)
727+
728+
expect(result.metadata.model.providerID).toBe(ref.providerID)
729+
expect(result.metadata.model.modelID).toBe(ref.modelID)
730+
expect(seen?.model?.providerID).toBe(ref.providerID)
731+
expect(seen?.model?.modelID).toBe(ref.modelID)
732+
}),
733+
)
734+
735+
it.instance("rejects invalid model override strings before asking permissions", () =>
736+
Effect.gen(function* () {
737+
const { chat, assistant } = yield* seed()
738+
const tool = yield* TaskTool
739+
const def = yield* tool.init()
740+
741+
yield* Effect.forEach(["gpt-4o", "openai/"], (model) =>
742+
Effect.gen(function* () {
743+
const calls: unknown[] = []
744+
const exit = yield* def
745+
.execute(
746+
{
747+
description: "inspect bug",
748+
prompt: "look into the cache key path",
749+
subagent_type: "general",
750+
model,
751+
},
752+
{
753+
sessionID: chat.id,
754+
messageID: assistant.id,
755+
agent: "build",
756+
abort: new AbortController().signal,
757+
extra: { promptOps: stubOps() },
758+
messages: [],
759+
metadata: () => Effect.void,
760+
ask: (input) =>
761+
Effect.sync(() => {
762+
calls.push(input)
763+
}),
764+
},
765+
)
766+
.pipe(Effect.exit)
767+
768+
expect(Exit.isFailure(exit)).toBe(true)
769+
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(`Invalid model format: "${model}"`)
770+
expect(calls).toHaveLength(0)
771+
}),
772+
)
773+
}),
774+
)
775+
539776
it.instance("rejects background execution when the experiment is disabled", () =>
540777
Effect.gen(function* () {
541778
const { chat, assistant } = yield* seed()

0 commit comments

Comments
 (0)