diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 22e4e5671c89..b687028a92ae 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -26,6 +26,10 @@ export const Parameters = Schema.Struct({ "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 model to use for this subagent (e.g., 'gpt-4-vision-preview', 'claude-3-opus', 'llama-3.1-8b'). Overrides the agent's default model. Format: 'modelID' or 'providerID/modelID'.", + }), }) export const TaskTool = Tool.define( @@ -104,10 +108,18 @@ export const TaskTool = Tool.define( const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - const model = next.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, - } + // Determine model: params.model > agent default > parent message model + const model = params.model + ? (params.model.includes("/") + ? (() => { + const [providerID, modelID] = params.model.split("/") + return { modelID, providerID } + })() + : { modelID: params.model, providerID: msg.info.providerID }) + : next.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } yield* ctx.metadata({ title: params.description, diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index fba8470d1b4b..6ddb5f835abb 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -2,6 +2,14 @@ Launch a new agent to handle complex, multistep tasks autonomously. When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. +Available parameters: +- description: A short (3-5 words) description of the task +- prompt: The task for the agent to perform +- subagent_type: The type of specialized agent to use (general, explore) +- task_id: (Optional) Resume a previous task session +- command: (Optional) The command that triggered this task +- model: (Optional) Model to use for this subagent. Format: 'modelID' (e.g., 'gpt-4-vision-preview') or 'providerID/modelID' (e.g., 'openai/gpt-4-vision-preview'). Overrides the agent's default model. + When to use the Task tool: - When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py") diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index f75fcf84b8a9..36fda79395c4 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -362,8 +362,115 @@ describe("tool.task", () => { }), ) - it.instance( - "execute shapes child permissions for task, todowrite, and primary tools", + it.instance("execute uses model parameter when 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: "analyze image", + prompt: "Analyze this screenshot", + subagent_type: "general", + model: "gpt-4-vision-preview", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.model).toEqual({ + modelID: "gpt-4-vision-preview", + providerID: ref.providerID, + }) + expect(result.metadata.model).toEqual({ + modelID: "gpt-4-vision-preview", + providerID: ref.providerID, + }) + }), + ) + + it.instance("execute uses providerID/modelID format when model contains slash", () => + 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: "research task", + prompt: "Research this topic", + subagent_type: "general", + model: "openai/gpt-4-turbo", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.model).toEqual({ + modelID: "gpt-4-turbo", + providerID: "openai", + }) + expect(result.metadata.model).toEqual({ + modelID: "gpt-4-turbo", + providerID: "openai", + }) + }), + ) + + it.instance("execute falls back to agent model when model param not 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: "regular task", + prompt: "Do the thing", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + // Should use parent message model when no agent default and no param + expect(seen?.model).toEqual({ + modelID: ref.modelID, + providerID: ref.providerID, + }) + }), + ) () => Effect.gen(function* () { const sessions = yield* Session.Service