diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f1335f6f21a3..e17d907d3315 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -16,6 +16,7 @@ import { iife } from "@/util/iife" import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" +import { Permission } from "@/permission" export namespace MessageV2 { export function isMedia(mime: string) { @@ -207,17 +208,44 @@ export namespace MessageV2 { }) export type CompactionPart = z.infer + export const AgentContext = z.object({ + name: z.string(), + description: z.string().optional(), + mode: z.enum(["subagent", "primary", "all"]), + native: z.boolean().optional(), + hidden: z.boolean().optional(), + topP: z.number().optional(), + temperature: z.number().optional(), + color: z.string().optional(), + permission: Permission.Ruleset, + model: z + .object({ + modelID: ModelID.zod, + providerID: ProviderID.zod, + }) + .optional(), + variant: z.string().optional(), + prompt: z.string().optional(), + options: z.record(z.string(), z.any()), + steps: z.number().int().positive().optional(), + }).meta({ + ref: "AgentContext", + }) + export type AgentContext = z.infer + export const SubtaskPart = PartBase.extend({ type: z.literal("subtask"), prompt: z.string(), description: z.string(), agent: z.string(), + agentContext: AgentContext.optional(), model: z .object({ providerID: ProviderID.zod, modelID: ModelID.zod, }) .optional(), + variant: z.string().optional(), command: z.string().optional(), }).meta({ ref: "SubtaskPart", @@ -359,7 +387,7 @@ export namespace MessageV2 { title: z.string().optional(), body: z.string().optional(), diffs: Snapshot.FileDiff.array(), - }) + }) .optional(), agent: z.string(), model: z.object({ @@ -369,6 +397,7 @@ export namespace MessageV2 { system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), variant: z.string().optional(), + agentContext: AgentContext.optional(), }).meta({ ref: "UserMessage", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5625c571cee9..8640359091c1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -102,6 +102,7 @@ export namespace SessionPrompt { }) .optional(), agent: z.string().optional(), + agentContext: MessageV2.AgentContext.optional(), noReply: z.boolean().optional(), tools: z .record(z.string(), z.boolean()) @@ -159,6 +160,15 @@ export namespace SessionPrompt { }) export type PromptInput = z.infer + async function resolvePromptAgent(input: { agent?: string; agentContext?: MessageV2.AgentContext }) { + if (input.agentContext) return input.agentContext + + const name = input.agent ?? (await Agent.defaultAgent()) + const agent = await Agent.get(name) + if (!agent) throw new Error(`Agent not found: "${name}"`) + return agent + } + export const prompt = fn(PromptInput, async (input) => { const session = await Session.get(input.sessionID) await SessionRevert.cleanup(session) @@ -394,7 +404,29 @@ export namespace SessionPrompt { prompt: task.prompt, description: task.description, subagent_type: task.agent, + ...(task.agentContext?.description ? { subagent_description: task.agentContext.description } : {}), command: task.command, + ...(task.model + ? { + model: `${task.model.providerID}/${task.model.modelID}`, + } + : {}), + ...(task.variant ? { variant: task.variant } : {}), + ...(task.agentContext + ? { + agent_config: { + ...(task.agentContext.prompt ? { prompt: task.agentContext.prompt } : {}), + ...(task.agentContext.temperature !== undefined + ? { temperature: task.agentContext.temperature } + : {}), + ...(task.agentContext.topP !== undefined ? { top_p: task.agentContext.topP } : {}), + ...(task.agentContext.color ? { color: task.agentContext.color } : {}), + ...(task.agentContext.steps !== undefined ? { steps: task.agentContext.steps } : {}), + ...(task.agentContext.permission.length > 0 ? { permission: task.agentContext.permission } : {}), + ...(Object.keys(task.agentContext.options).length > 0 ? { options: task.agentContext.options } : {}), + }, + } + : {}), }, time: { start: Date.now(), @@ -405,7 +437,27 @@ export namespace SessionPrompt { prompt: task.prompt, description: task.description, subagent_type: task.agent, + ...(task.agentContext?.description ? { subagent_description: task.agentContext.description } : {}), command: task.command, + ...(task.model + ? { + model: `${task.model.providerID}/${task.model.modelID}`, + } + : {}), + ...(task.variant ? { variant: task.variant } : {}), + ...(task.agentContext + ? { + agent_config: { + ...(task.agentContext.prompt ? { prompt: task.agentContext.prompt } : {}), + ...(task.agentContext.temperature !== undefined ? { temperature: task.agentContext.temperature } : {}), + ...(task.agentContext.topP !== undefined ? { top_p: task.agentContext.topP } : {}), + ...(task.agentContext.color ? { color: task.agentContext.color } : {}), + ...(task.agentContext.steps !== undefined ? { steps: task.agentContext.steps } : {}), + ...(task.agentContext.permission.length > 0 ? { permission: task.agentContext.permission } : {}), + ...(Object.keys(task.agentContext.options).length > 0 ? { options: task.agentContext.options } : {}), + }, + } + : {}), } await Plugin.trigger( "tool.execute.before", @@ -417,7 +469,10 @@ export namespace SessionPrompt { { args: taskArgs }, ) let executionError: Error | undefined - const taskAgent = await Agent.get(task.agent) + const taskAgent = task.agentContext ?? (await Agent.get(task.agent)) + if (!taskAgent) { + throw new Error(`Task agent not found: "${task.agent}"`) + } const taskCtx: Tool.Context = { agent: task.agent, messageID: assistantMessage.id, @@ -559,7 +614,10 @@ export namespace SessionPrompt { } // normal processing - const agent = await Agent.get(lastUser.agent) + const agent = lastUser.agentContext ?? (await Agent.get(lastUser.agent)) + if (!agent) { + throw new Error(`Agent not found: "${lastUser.agent}"`) + } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -964,7 +1022,7 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + const agent = await resolvePromptAgent(input) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const full = @@ -982,6 +1040,7 @@ export namespace SessionPrompt { }, tools: input.tools, agent: agent.name, + agentContext: input.agentContext, model, system: input.system, format: input.format, @@ -1156,7 +1215,7 @@ export namespace SessionPrompt { const readCtx: Tool.Context = { sessionID: input.sessionID, abort: new AbortController().signal, - agent: input.agent!, + agent: agent.name, messageID: info.id, extra: { bypassCwdCheck: true, model }, messages: [], @@ -1215,7 +1274,7 @@ export namespace SessionPrompt { const listCtx: Tool.Context = { sessionID: input.sessionID, abort: new AbortController().signal, - agent: input.agent!, + agent: agent.name, messageID: info.id, extra: { bypassCwdCheck: true }, messages: [], @@ -1308,7 +1367,7 @@ export namespace SessionPrompt { "chat.message", { sessionID: input.sessionID, - agent: input.agent, + agent: agent.name, model: input.model, messageID: input.messageID, variant: input.variant, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e3781126d0c1..deee89c3f3e0 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,18 +4,35 @@ import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" -import { Identifier } from "../id/id" import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { Permission } from "@/permission" +import { Instance } from "@/project/instance" +import { ModelID, ProviderID } from "@/provider/schema" + +const dynamicAgentConfig = z + .object({ + prompt: z.string().optional(), + temperature: z.number().optional(), + top_p: z.number().optional(), + color: z.string().optional(), + steps: z.number().int().positive().optional(), + permission: z.record(z.string(), z.any()).optional(), + options: z.record(z.string(), z.any()).optional(), + }) + .strict() const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), prompt: z.string().describe("The task for the agent to perform"), subagent_type: z.string().describe("The type of specialized agent to use for this task"), + subagent_description: z + .string() + .describe("Optional specialization for an ad hoc dynamic subagent") + .optional(), task_id: z .string() .describe( @@ -23,8 +40,79 @@ const parameters = z.object({ ) .optional(), command: z.string().describe("The command that triggered this task").optional(), + model: z.string().describe('Optional model override in the format "provider/model"').optional(), + variant: z.string().describe("Optional reasoning or thinking level override").optional(), + agent_config: dynamicAgentConfig.describe("Internal dynamic task agent configuration").optional(), }) +function parseModel(model: string) { + const separator = model.indexOf("/") + if (separator <= 0 || separator === model.length - 1) { + throw new Error(`Invalid model "${model}". Expected "provider/model".`) + } + + return { + providerID: ProviderID.make(model.slice(0, separator)), + modelID: ModelID.make(model.slice(separator + 1)), + } +} + +function buildDynamicAgentPrompt(input: { + name: string + description: string + workingDirectory: string + projectRoot: string + prompt?: string +}) { + return [ + ...(input.prompt ? [input.prompt, ""] : []), + `You are @${input.name}, a dynamic subagent.`, + `Specialization: ${input.description}`, + "", + `Current working directory: ${input.workingDirectory}`, + ...(input.projectRoot !== input.workingDirectory ? [`Project root: ${input.projectRoot}`] : []), + "", + "Treat the specialization as authoritative for this run.", + "Resolve relative paths from the current working directory shown above.", + "Do not invent absolute filesystem paths. If the task gives a relative project path, use that exact relative path unless you verify a different path exists first.", + ].join("\n") +} + +async function buildDynamicAgent(params: z.infer) { + if (!params.subagent_description) return + + const general = await Agent.get("general") + if (!general) { + throw new Error('Dynamic subagents require the native "general" agent to be available.') + } + + return Agent.Info.parse({ + ...general, + name: params.subagent_type, + description: params.subagent_description, + mode: "subagent", + hidden: true, + prompt: buildDynamicAgentPrompt({ + name: params.subagent_type, + description: params.subagent_description, + workingDirectory: Instance.directory, + projectRoot: Instance.worktree, + prompt: params.agent_config?.prompt, + }), + temperature: params.agent_config?.temperature ?? general.temperature, + topP: params.agent_config?.top_p ?? general.topP, + color: params.agent_config?.color ?? general.color, + steps: params.agent_config?.steps ?? general.steps, + options: { + ...general.options, + ...(params.agent_config?.options ?? {}), + }, + permission: params.agent_config?.permission + ? Permission.merge(general.permission, Permission.fromConfig(params.agent_config.permission)) + : general.permission, + }) +} + export const TaskTool = Tool.define("task", async (ctx) => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) @@ -46,6 +134,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { parameters, async execute(params: z.infer, ctx) { const config = await Config.get() + const dynamicAgent = await buildDynamicAgent(params) + const agent = dynamicAgent ?? (await Agent.get(params.subagent_type)) // Skip permission check when user explicitly invoked via @ or command subtask if (!ctx.extra?.bypassAgentCheck) { @@ -60,7 +150,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) } - const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") @@ -105,16 +194,20 @@ export const TaskTool = Tool.define("task", async (ctx) => { const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") - const model = agent.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, - } + const model = + (params.model ? parseModel(params.model) : undefined) ?? + agent.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + const variant = params.variant ?? agent.variant ctx.metadata({ title: params.description, metadata: { sessionId: session.id, model, + ...(variant ? { variant } : {}), }, }) @@ -135,6 +228,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { providerID: model.providerID, }, agent: agent.name, + agentContext: dynamicAgent, + ...(variant ? { variant } : {}), tools: { todowrite: false, todoread: false, @@ -159,6 +254,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { metadata: { sessionId: session.id, model, + ...(variant ? { variant } : {}), }, output, } diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 585cce8f9d0a..1972a19822cc 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -4,6 +4,8 @@ Available agent types and the tools they have access to: {agents} When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. +To create an ad hoc dynamic subagent, provide a fresh subagent_type plus subagent_description. +You may optionally provide model and variant to choose the subagent model and thinking level for this task. 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/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3986271dab96..cabd2ebcd4eb 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -210,3 +210,58 @@ describe("session.prompt agent variant", () => { } }) }) + +describe("session.prompt inline agent context", () => { + test("persists agentContext for dynamic task sessions", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const message = await SessionPrompt.prompt({ + sessionID: session.id, + noReply: true, + agent: "spark-scout", + agentContext: { + name: "spark-scout", + description: "Focused code search subagent", + mode: "subagent", + hidden: true, + permission: [], + options: {}, + prompt: "You are a focused scout.", + }, + model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.4") }, + variant: "high", + parts: [{ type: "text", text: "Inspect apps/studio/src/hooks." }], + }) + + if (message.info.role !== "user") throw new Error("expected user message") + expect(message.info.agent).toBe("spark-scout") + expect(message.info.agentContext?.description).toBe("Focused code search subagent") + expect(message.info.variant).toBe("high") + + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + expect(stored.info.role).toBe("user") + if (stored.info.role !== "user") throw new Error("expected stored user message") + expect(stored.info.agentContext?.prompt).toBe("You are a focused scout.") + expect(stored.info.model).toEqual({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5.4"), + }) + + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index aae48a30ab3f..4c9c9e61c4b3 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -46,4 +46,42 @@ describe("tool.task", () => { }, }) }) + + test("parameters accept dynamic subagent model and variant inputs", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + const task = await TaskTool.init({ agent: build }) + + const parsed = task.parameters.parse({ + description: "Search hooks", + prompt: "Inspect apps/studio/src/hooks for candidates.", + subagent_type: "spark-scout", + subagent_description: "Focused code search subagent", + model: "openai/gpt-5.4", + variant: "high", + agent_config: { + temperature: 0.2, + }, + }) + + expect(parsed.subagent_description).toBe("Focused code search subagent") + expect(parsed.model).toBe("openai/gpt-5.4") + expect(parsed.variant).toBe("high") + expect(parsed.agent_config).toEqual({ temperature: 0.2 }) + }, + }) + }) })