Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
import type { SessionPrompt } from "../session/prompt"
import { Provider } from "@/provider/provider"
import { Config } from "@/config/config"
import { Effect, Exit, Schema, Scope } from "effect"
import { EffectBridge } from "@/effect/bridge"
Expand Down Expand Up @@ -49,6 +50,14 @@ const BaseParameterFields = {
"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)",
}),
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
model: Schema.optional(Schema.String).annotate({
description: [
'Optional. Run this task on a specific model, written as "provider/model" (for example "anthropic/claude-sonnet-4-5").',
"Omit this parameter to keep the default behaviour: the subagent's own configured model, or your current model if it has none.",
"Only set it when this particular task genuinely needs a different model, since it overrides a model the subagent was deliberately configured with.",
"It applies to this invocation only and does not change the subagent session's model for later invocations.",
].join(" "),
}),
}

const BaseParameters = Schema.Struct(BaseParameterFields)
Expand Down Expand Up @@ -84,6 +93,7 @@ export const TaskTool = Tool.define(
const agent = yield* Agent.Service
const background = yield* BackgroundJob.Service
const config = yield* Config.Service
const provider = yield* Provider.Service
const sessions = yield* Session.Service
const scope = yield* Scope.Scope
const flags = yield* RuntimeFlags.Service
Expand Down Expand Up @@ -133,6 +143,14 @@ export const TaskTool = Tool.define(
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}

// Resolve the explicit per-invocation model here -- before any child session is created, and
// before the foreground/background branch below, so both paths run the same model.
// `Provider.getModel` is the exact call SessionPrompt.getModel makes later, so validating now
// changes *when* an unavailable model fails, not *whether* it fails. Omitting `model` skips
// this entirely, leaving the existing fallback chain and its late validation untouched.
const explicitModel = params.model ? Provider.parseModel(params.model) : undefined
if (explicitModel) yield* provider.getModel(explicitModel.providerID, explicitModel.modelID)

const session = params.task_id
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
Expand Down Expand Up @@ -178,10 +196,12 @@ 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 ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
// Precedence: explicit per-invocation model > subagent's configured model > invoking assistant's model.
const model = explicitModel ??
next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
const metadata = {
parentSessionId: ctx.sessionID,
sessionId: nextSession.id,
Expand All @@ -206,7 +226,9 @@ export const TaskTool = Tool.define(
modelID: model.modelID,
providerID: model.providerID,
},
variant: next.model ? undefined : variant,
// The parent's variant only describes the parent's model, so it is inherited solely when
// the child actually runs that model -- i.e. no explicit and no agent-configured override.
variant: explicitModel || next.model ? undefined : variant,
agent: next.name,
parts,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = `
"description": "A short (3-5 words) description of the task",
"type": "string",
},
"model": {
"description": "Optional. Run this task on a specific model, written as "provider/model" (for example "anthropic/claude-sonnet-4-5"). Omit this parameter to keep the default behaviour: the subagent's own configured model, or your current model if it has none. Only set it when this particular task genuinely needs a different model, since it overrides a model the subagent was deliberately configured with. It applies to this invocation only and does not change the subagent session's model for later invocations.",
"type": "string",
},
"prompt": {
"description": "The task for the agent to perform",
"type": "string",
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/test/tool/parameters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,22 @@ describe("tool parameters", () => {
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true })
expect(parsed.background).toBe(true)
})
test("accepts an optional provider/model string", () => {
const parsed = parse(Task, {
description: "d",
prompt: "p",
subagent_type: "general",
model: "anthropic/claude-sonnet-4-5",
})
expect(parsed.model).toBe("anthropic/claude-sonnet-4-5")
})
test("treats the model as optional", () => {
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
expect(parsed.model).toBeUndefined()
})
test("rejects a non-string model", () => {
expect(accepts(Task, { description: "d", prompt: "p", subagent_type: "general", model: 1 })).toBe(false)
})
test("rejects missing prompt", () => {
expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
})
Expand Down
Loading
Loading