Skip to content
Closed
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
31 changes: 30 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -207,17 +208,44 @@ export namespace MessageV2 {
})
export type CompactionPart = z.infer<typeof CompactionPart>

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<typeof AgentContext>

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",
Expand Down Expand Up @@ -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({
Expand All @@ -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",
})
Expand Down
71 changes: 65 additions & 6 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -159,6 +160,15 @@ export namespace SessionPrompt {
})
export type PromptInput = z.infer<typeof PromptInput>

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)
Expand Down Expand Up @@ -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(),
Expand All @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 =
Expand All @@ -982,6 +1040,7 @@ export namespace SessionPrompt {
},
tools: input.tools,
agent: agent.name,
agentContext: input.agentContext,
model,
system: input.system,
format: input.format,
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 102 additions & 6 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,115 @@ 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(
"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)",
)
.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<typeof parameters>) {
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"))

Expand All @@ -46,6 +134,8 @@ export const TaskTool = Tool.define("task", async (ctx) => {
parameters,
async execute(params: z.infer<typeof parameters>, 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) {
Expand All @@ -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")
Expand Down Expand Up @@ -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 } : {}),
},
})

Expand All @@ -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,
Expand All @@ -159,6 +254,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
metadata: {
sessionId: session.id,
model,
...(variant ? { variant } : {}),
},
output,
}
Expand Down
Loading
Loading