Skip to content

Commit b5d7c51

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 c2bc6d0 commit b5d7c51

7 files changed

Lines changed: 290 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 @@ export 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: 36 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* () {
@@ -101,6 +120,22 @@ export const TaskTool = Tool.define(
101120
)
102121
}
103122

123+
const modelOverride = params.model
124+
const overrideModel = modelOverride === undefined ? undefined : yield* parseModelOverride(modelOverride)
125+
126+
if (overrideModel && modelOverride !== undefined) {
127+
yield* ctx.ask({
128+
permission: "model_override",
129+
patterns: [modelOverride],
130+
always: [modelOverride],
131+
metadata: {
132+
description: params.description,
133+
subagent_type: params.subagent_type,
134+
model: modelOverride,
135+
},
136+
})
137+
}
138+
104139
if (!ctx.extra?.bypassAgentCheck) {
105140
yield* ctx.ask({
106141
permission: id,
@@ -164,7 +199,7 @@ export const TaskTool = Tool.define(
164199
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
165200
const variant = msg.info.variant
166201

167-
const model = next.model ?? {
202+
const model = overrideModel ?? next.model ?? {
168203
modelID: msg.info.modelID,
169204
providerID: msg.info.providerID,
170205
}

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"
@@ -456,6 +456,243 @@ describe("tool.task", () => {
456456
},
457457
)
458458

459+
it.instance(
460+
"execute uses explicit model override before subagent and parent models",
461+
() =>
462+
Effect.gen(function* () {
463+
const { chat, assistant } = yield* seed()
464+
const tool = yield* TaskTool
465+
const def = yield* tool.init()
466+
const calls: unknown[] = []
467+
let seen: SessionPrompt.PromptInput | undefined
468+
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
469+
470+
const result = yield* def.execute(
471+
{
472+
description: "inspect bug",
473+
prompt: "look into the cache key path",
474+
subagent_type: "general",
475+
model: "anthropic/claude-sonnet-4",
476+
},
477+
{
478+
sessionID: chat.id,
479+
messageID: assistant.id,
480+
agent: "build",
481+
abort: new AbortController().signal,
482+
extra: { promptOps },
483+
messages: [],
484+
metadata: () => Effect.void,
485+
ask: (input) =>
486+
Effect.sync(() => {
487+
calls.push(input)
488+
}),
489+
},
490+
)
491+
492+
expect(result.metadata.model.providerID as string).toBe("anthropic")
493+
expect(result.metadata.model.modelID as string).toBe("claude-sonnet-4")
494+
expect((seen?.model?.providerID ?? "") as string).toBe("anthropic")
495+
expect((seen?.model?.modelID ?? "") as string).toBe("claude-sonnet-4")
496+
expect(calls[0]).toEqual({
497+
permission: "model_override",
498+
patterns: ["anthropic/claude-sonnet-4"],
499+
always: ["anthropic/claude-sonnet-4"],
500+
metadata: {
501+
description: "inspect bug",
502+
subagent_type: "general",
503+
model: "anthropic/claude-sonnet-4",
504+
},
505+
})
506+
expect(calls[1]).toEqual({
507+
permission: "task",
508+
patterns: ["general"],
509+
always: ["*"],
510+
metadata: {
511+
description: "inspect bug",
512+
subagent_type: "general",
513+
},
514+
})
515+
}),
516+
{
517+
config: {
518+
agent: {
519+
general: {
520+
model: "openai/gpt-4o-mini",
521+
},
522+
},
523+
},
524+
},
525+
)
526+
527+
it.instance("stops before task permission when model override permission fails", () =>
528+
Effect.gen(function* () {
529+
const { chat, assistant } = yield* seed()
530+
const tool = yield* TaskTool
531+
const def = yield* tool.init()
532+
const calls: unknown[] = []
533+
534+
const exit = yield* def
535+
.execute(
536+
{
537+
description: "inspect bug",
538+
prompt: "look into the cache key path",
539+
subagent_type: "general",
540+
model: "anthropic/claude-sonnet-4",
541+
},
542+
{
543+
sessionID: chat.id,
544+
messageID: assistant.id,
545+
agent: "build",
546+
abort: new AbortController().signal,
547+
extra: { promptOps: stubOps() },
548+
messages: [],
549+
metadata: () => Effect.void,
550+
ask: (input) =>
551+
Effect.sync(() => {
552+
calls.push(input)
553+
}).pipe(
554+
Effect.andThen(
555+
input.permission === "model_override" ? Effect.die(new Error("model override denied")) : Effect.void,
556+
),
557+
),
558+
},
559+
)
560+
.pipe(Effect.exit)
561+
562+
expect(Exit.isFailure(exit)).toBe(true)
563+
expect(calls).toEqual([
564+
{
565+
permission: "model_override",
566+
patterns: ["anthropic/claude-sonnet-4"],
567+
always: ["anthropic/claude-sonnet-4"],
568+
metadata: {
569+
description: "inspect bug",
570+
subagent_type: "general",
571+
model: "anthropic/claude-sonnet-4",
572+
},
573+
},
574+
])
575+
}),
576+
)
577+
578+
it.instance(
579+
"execute uses subagent model when no explicit override is provided",
580+
() =>
581+
Effect.gen(function* () {
582+
const { chat, assistant } = yield* seed()
583+
const tool = yield* TaskTool
584+
const def = yield* tool.init()
585+
let seen: SessionPrompt.PromptInput | undefined
586+
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
587+
588+
const result = yield* def.execute(
589+
{
590+
description: "inspect bug",
591+
prompt: "look into the cache key path",
592+
subagent_type: "general",
593+
},
594+
{
595+
sessionID: chat.id,
596+
messageID: assistant.id,
597+
agent: "build",
598+
abort: new AbortController().signal,
599+
extra: { promptOps },
600+
messages: [],
601+
metadata: () => Effect.void,
602+
ask: () => Effect.void,
603+
},
604+
)
605+
606+
expect(result.metadata.model.providerID as string).toBe("openai")
607+
expect(result.metadata.model.modelID as string).toBe("gpt-4o-mini")
608+
expect((seen?.model?.providerID ?? "") as string).toBe("openai")
609+
expect((seen?.model?.modelID ?? "") as string).toBe("gpt-4o-mini")
610+
}),
611+
{
612+
config: {
613+
agent: {
614+
general: {
615+
model: "openai/gpt-4o-mini",
616+
},
617+
},
618+
},
619+
},
620+
)
621+
622+
it.instance("execute uses parent assistant model when no explicit or subagent model is provided", () =>
623+
Effect.gen(function* () {
624+
const { chat, assistant } = yield* seed()
625+
const tool = yield* TaskTool
626+
const def = yield* tool.init()
627+
let seen: SessionPrompt.PromptInput | undefined
628+
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
629+
630+
const result = yield* def.execute(
631+
{
632+
description: "inspect bug",
633+
prompt: "look into the cache key path",
634+
subagent_type: "general",
635+
},
636+
{
637+
sessionID: chat.id,
638+
messageID: assistant.id,
639+
agent: "build",
640+
abort: new AbortController().signal,
641+
extra: { promptOps },
642+
messages: [],
643+
metadata: () => Effect.void,
644+
ask: () => Effect.void,
645+
},
646+
)
647+
648+
expect(result.metadata.model.providerID).toBe(ref.providerID)
649+
expect(result.metadata.model.modelID).toBe(ref.modelID)
650+
expect(seen?.model?.providerID).toBe(ref.providerID)
651+
expect(seen?.model?.modelID).toBe(ref.modelID)
652+
}),
653+
)
654+
655+
it.instance("rejects invalid model override strings before asking permissions", () =>
656+
Effect.gen(function* () {
657+
const { chat, assistant } = yield* seed()
658+
const tool = yield* TaskTool
659+
const def = yield* tool.init()
660+
661+
yield* Effect.forEach(["gpt-4o", "openai/"], (model) =>
662+
Effect.gen(function* () {
663+
const calls: unknown[] = []
664+
const exit = yield* def
665+
.execute(
666+
{
667+
description: "inspect bug",
668+
prompt: "look into the cache key path",
669+
subagent_type: "general",
670+
model,
671+
},
672+
{
673+
sessionID: chat.id,
674+
messageID: assistant.id,
675+
agent: "build",
676+
abort: new AbortController().signal,
677+
extra: { promptOps: stubOps() },
678+
messages: [],
679+
metadata: () => Effect.void,
680+
ask: (input) =>
681+
Effect.sync(() => {
682+
calls.push(input)
683+
}),
684+
},
685+
)
686+
.pipe(Effect.exit)
687+
688+
expect(Exit.isFailure(exit)).toBe(true)
689+
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(`Invalid model format: "${model}"`)
690+
expect(calls).toHaveLength(0)
691+
}),
692+
)
693+
}),
694+
)
695+
459696
it.instance("rejects background execution when the experiment is disabled", () =>
460697
Effect.gen(function* () {
461698
const { chat, assistant } = yield* seed()

0 commit comments

Comments
 (0)