Skip to content

Commit 2e15ec3

Browse files
committed
Add native dynamic task agent support
1 parent 832b8e2 commit 2e15ec3

6 files changed

Lines changed: 292 additions & 13 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { iife } from "@/util/iife"
1616
import type { SystemError } from "bun"
1717
import type { Provider } from "@/provider/provider"
1818
import { ModelID, ProviderID } from "@/provider/schema"
19+
import { Permission } from "@/permission"
1920

2021
export namespace MessageV2 {
2122
export function isMedia(mime: string) {
@@ -207,17 +208,44 @@ export namespace MessageV2 {
207208
})
208209
export type CompactionPart = z.infer<typeof CompactionPart>
209210

211+
export const AgentContext = z.object({
212+
name: z.string(),
213+
description: z.string().optional(),
214+
mode: z.enum(["subagent", "primary", "all"]),
215+
native: z.boolean().optional(),
216+
hidden: z.boolean().optional(),
217+
topP: z.number().optional(),
218+
temperature: z.number().optional(),
219+
color: z.string().optional(),
220+
permission: Permission.Ruleset,
221+
model: z
222+
.object({
223+
modelID: ModelID.zod,
224+
providerID: ProviderID.zod,
225+
})
226+
.optional(),
227+
variant: z.string().optional(),
228+
prompt: z.string().optional(),
229+
options: z.record(z.string(), z.any()),
230+
steps: z.number().int().positive().optional(),
231+
}).meta({
232+
ref: "AgentContext",
233+
})
234+
export type AgentContext = z.infer<typeof AgentContext>
235+
210236
export const SubtaskPart = PartBase.extend({
211237
type: z.literal("subtask"),
212238
prompt: z.string(),
213239
description: z.string(),
214240
agent: z.string(),
241+
agentContext: AgentContext.optional(),
215242
model: z
216243
.object({
217244
providerID: ProviderID.zod,
218245
modelID: ModelID.zod,
219246
})
220247
.optional(),
248+
variant: z.string().optional(),
221249
command: z.string().optional(),
222250
}).meta({
223251
ref: "SubtaskPart",
@@ -359,7 +387,7 @@ export namespace MessageV2 {
359387
title: z.string().optional(),
360388
body: z.string().optional(),
361389
diffs: Snapshot.FileDiff.array(),
362-
})
390+
})
363391
.optional(),
364392
agent: z.string(),
365393
model: z.object({
@@ -369,6 +397,7 @@ export namespace MessageV2 {
369397
system: z.string().optional(),
370398
tools: z.record(z.string(), z.boolean()).optional(),
371399
variant: z.string().optional(),
400+
agentContext: AgentContext.optional(),
372401
}).meta({
373402
ref: "UserMessage",
374403
})

packages/opencode/src/session/prompt.ts

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export namespace SessionPrompt {
102102
})
103103
.optional(),
104104
agent: z.string().optional(),
105+
agentContext: MessageV2.AgentContext.optional(),
105106
noReply: z.boolean().optional(),
106107
tools: z
107108
.record(z.string(), z.boolean())
@@ -159,6 +160,15 @@ export namespace SessionPrompt {
159160
})
160161
export type PromptInput = z.infer<typeof PromptInput>
161162

163+
async function resolvePromptAgent(input: { agent?: string; agentContext?: MessageV2.AgentContext }) {
164+
if (input.agentContext) return input.agentContext
165+
166+
const name = input.agent ?? (await Agent.defaultAgent())
167+
const agent = await Agent.get(name)
168+
if (!agent) throw new Error(`Agent not found: "${name}"`)
169+
return agent
170+
}
171+
162172
export const prompt = fn(PromptInput, async (input) => {
163173
const session = await Session.get(input.sessionID)
164174
await SessionRevert.cleanup(session)
@@ -394,7 +404,29 @@ export namespace SessionPrompt {
394404
prompt: task.prompt,
395405
description: task.description,
396406
subagent_type: task.agent,
407+
...(task.agentContext?.description ? { subagent_description: task.agentContext.description } : {}),
397408
command: task.command,
409+
...(task.model
410+
? {
411+
model: `${task.model.providerID}/${task.model.modelID}`,
412+
}
413+
: {}),
414+
...(task.variant ? { variant: task.variant } : {}),
415+
...(task.agentContext
416+
? {
417+
agent_config: {
418+
...(task.agentContext.prompt ? { prompt: task.agentContext.prompt } : {}),
419+
...(task.agentContext.temperature !== undefined
420+
? { temperature: task.agentContext.temperature }
421+
: {}),
422+
...(task.agentContext.topP !== undefined ? { top_p: task.agentContext.topP } : {}),
423+
...(task.agentContext.color ? { color: task.agentContext.color } : {}),
424+
...(task.agentContext.steps !== undefined ? { steps: task.agentContext.steps } : {}),
425+
...(task.agentContext.permission.length > 0 ? { permission: task.agentContext.permission } : {}),
426+
...(Object.keys(task.agentContext.options).length > 0 ? { options: task.agentContext.options } : {}),
427+
},
428+
}
429+
: {}),
398430
},
399431
time: {
400432
start: Date.now(),
@@ -405,7 +437,27 @@ export namespace SessionPrompt {
405437
prompt: task.prompt,
406438
description: task.description,
407439
subagent_type: task.agent,
440+
...(task.agentContext?.description ? { subagent_description: task.agentContext.description } : {}),
408441
command: task.command,
442+
...(task.model
443+
? {
444+
model: `${task.model.providerID}/${task.model.modelID}`,
445+
}
446+
: {}),
447+
...(task.variant ? { variant: task.variant } : {}),
448+
...(task.agentContext
449+
? {
450+
agent_config: {
451+
...(task.agentContext.prompt ? { prompt: task.agentContext.prompt } : {}),
452+
...(task.agentContext.temperature !== undefined ? { temperature: task.agentContext.temperature } : {}),
453+
...(task.agentContext.topP !== undefined ? { top_p: task.agentContext.topP } : {}),
454+
...(task.agentContext.color ? { color: task.agentContext.color } : {}),
455+
...(task.agentContext.steps !== undefined ? { steps: task.agentContext.steps } : {}),
456+
...(task.agentContext.permission.length > 0 ? { permission: task.agentContext.permission } : {}),
457+
...(Object.keys(task.agentContext.options).length > 0 ? { options: task.agentContext.options } : {}),
458+
},
459+
}
460+
: {}),
409461
}
410462
await Plugin.trigger(
411463
"tool.execute.before",
@@ -417,7 +469,10 @@ export namespace SessionPrompt {
417469
{ args: taskArgs },
418470
)
419471
let executionError: Error | undefined
420-
const taskAgent = await Agent.get(task.agent)
472+
const taskAgent = task.agentContext ?? (await Agent.get(task.agent))
473+
if (!taskAgent) {
474+
throw new Error(`Task agent not found: "${task.agent}"`)
475+
}
421476
const taskCtx: Tool.Context = {
422477
agent: task.agent,
423478
messageID: assistantMessage.id,
@@ -559,7 +614,10 @@ export namespace SessionPrompt {
559614
}
560615

561616
// normal processing
562-
const agent = await Agent.get(lastUser.agent)
617+
const agent = lastUser.agentContext ?? (await Agent.get(lastUser.agent))
618+
if (!agent) {
619+
throw new Error(`Agent not found: "${lastUser.agent}"`)
620+
}
563621
const maxSteps = agent.steps ?? Infinity
564622
const isLastStep = step >= maxSteps
565623
msgs = await insertReminders({
@@ -964,7 +1022,7 @@ export namespace SessionPrompt {
9641022
}
9651023

9661024
async function createUserMessage(input: PromptInput) {
967-
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
1025+
const agent = await resolvePromptAgent(input)
9681026

9691027
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
9701028
const full =
@@ -982,6 +1040,7 @@ export namespace SessionPrompt {
9821040
},
9831041
tools: input.tools,
9841042
agent: agent.name,
1043+
agentContext: input.agentContext,
9851044
model,
9861045
system: input.system,
9871046
format: input.format,
@@ -1156,7 +1215,7 @@ export namespace SessionPrompt {
11561215
const readCtx: Tool.Context = {
11571216
sessionID: input.sessionID,
11581217
abort: new AbortController().signal,
1159-
agent: input.agent!,
1218+
agent: agent.name,
11601219
messageID: info.id,
11611220
extra: { bypassCwdCheck: true, model },
11621221
messages: [],
@@ -1215,7 +1274,7 @@ export namespace SessionPrompt {
12151274
const listCtx: Tool.Context = {
12161275
sessionID: input.sessionID,
12171276
abort: new AbortController().signal,
1218-
agent: input.agent!,
1277+
agent: agent.name,
12191278
messageID: info.id,
12201279
extra: { bypassCwdCheck: true },
12211280
messages: [],
@@ -1308,7 +1367,7 @@ export namespace SessionPrompt {
13081367
"chat.message",
13091368
{
13101369
sessionID: input.sessionID,
1311-
agent: input.agent,
1370+
agent: agent.name,
13121371
model: input.model,
13131372
messageID: input.messageID,
13141373
variant: input.variant,

packages/opencode/src/tool/task.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,115 @@ import z from "zod"
44
import { Session } from "../session"
55
import { SessionID, MessageID } from "../session/schema"
66
import { MessageV2 } from "../session/message-v2"
7-
import { Identifier } from "../id/id"
87
import { Agent } from "../agent/agent"
98
import { SessionPrompt } from "../session/prompt"
109
import { iife } from "@/util/iife"
1110
import { defer } from "@/util/defer"
1211
import { Config } from "../config/config"
1312
import { Permission } from "@/permission"
13+
import { Instance } from "@/project/instance"
14+
import { ModelID, ProviderID } from "@/provider/schema"
15+
16+
const dynamicAgentConfig = z
17+
.object({
18+
prompt: z.string().optional(),
19+
temperature: z.number().optional(),
20+
top_p: z.number().optional(),
21+
color: z.string().optional(),
22+
steps: z.number().int().positive().optional(),
23+
permission: z.record(z.string(), z.any()).optional(),
24+
options: z.record(z.string(), z.any()).optional(),
25+
})
26+
.strict()
1427

1528
const parameters = z.object({
1629
description: z.string().describe("A short (3-5 words) description of the task"),
1730
prompt: z.string().describe("The task for the agent to perform"),
1831
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
32+
subagent_description: z
33+
.string()
34+
.describe("Optional specialization for an ad hoc dynamic subagent")
35+
.optional(),
1936
task_id: z
2037
.string()
2138
.describe(
2239
"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)",
2340
)
2441
.optional(),
2542
command: z.string().describe("The command that triggered this task").optional(),
43+
model: z.string().describe('Optional model override in the format "provider/model"').optional(),
44+
variant: z.string().describe("Optional reasoning or thinking level override").optional(),
45+
agent_config: dynamicAgentConfig.describe("Internal dynamic task agent configuration").optional(),
2646
})
2747

48+
function parseModel(model: string) {
49+
const separator = model.indexOf("/")
50+
if (separator <= 0 || separator === model.length - 1) {
51+
throw new Error(`Invalid model "${model}". Expected "provider/model".`)
52+
}
53+
54+
return {
55+
providerID: ProviderID.make(model.slice(0, separator)),
56+
modelID: ModelID.make(model.slice(separator + 1)),
57+
}
58+
}
59+
60+
function buildDynamicAgentPrompt(input: {
61+
name: string
62+
description: string
63+
workingDirectory: string
64+
projectRoot: string
65+
prompt?: string
66+
}) {
67+
return [
68+
...(input.prompt ? [input.prompt, ""] : []),
69+
`You are @${input.name}, a dynamic subagent.`,
70+
`Specialization: ${input.description}`,
71+
"",
72+
`Current working directory: ${input.workingDirectory}`,
73+
...(input.projectRoot !== input.workingDirectory ? [`Project root: ${input.projectRoot}`] : []),
74+
"",
75+
"Treat the specialization as authoritative for this run.",
76+
"Resolve relative paths from the current working directory shown above.",
77+
"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.",
78+
].join("\n")
79+
}
80+
81+
async function buildDynamicAgent(params: z.infer<typeof parameters>) {
82+
if (!params.subagent_description) return
83+
84+
const general = await Agent.get("general")
85+
if (!general) {
86+
throw new Error('Dynamic subagents require the native "general" agent to be available.')
87+
}
88+
89+
return Agent.Info.parse({
90+
...general,
91+
name: params.subagent_type,
92+
description: params.subagent_description,
93+
mode: "subagent",
94+
hidden: true,
95+
prompt: buildDynamicAgentPrompt({
96+
name: params.subagent_type,
97+
description: params.subagent_description,
98+
workingDirectory: Instance.directory,
99+
projectRoot: Instance.worktree,
100+
prompt: params.agent_config?.prompt,
101+
}),
102+
temperature: params.agent_config?.temperature ?? general.temperature,
103+
topP: params.agent_config?.top_p ?? general.topP,
104+
color: params.agent_config?.color ?? general.color,
105+
steps: params.agent_config?.steps ?? general.steps,
106+
options: {
107+
...general.options,
108+
...(params.agent_config?.options ?? {}),
109+
},
110+
permission: params.agent_config?.permission
111+
? Permission.merge(general.permission, Permission.fromConfig(params.agent_config.permission))
112+
: general.permission,
113+
})
114+
}
115+
28116
export const TaskTool = Tool.define("task", async (ctx) => {
29117
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
30118

@@ -46,6 +134,8 @@ export const TaskTool = Tool.define("task", async (ctx) => {
46134
parameters,
47135
async execute(params: z.infer<typeof parameters>, ctx) {
48136
const config = await Config.get()
137+
const dynamicAgent = await buildDynamicAgent(params)
138+
const agent = dynamicAgent ?? (await Agent.get(params.subagent_type))
49139

50140
// Skip permission check when user explicitly invoked via @ or command subtask
51141
if (!ctx.extra?.bypassAgentCheck) {
@@ -60,7 +150,6 @@ export const TaskTool = Tool.define("task", async (ctx) => {
60150
})
61151
}
62152

63-
const agent = await Agent.get(params.subagent_type)
64153
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
65154

66155
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
@@ -105,16 +194,20 @@ export const TaskTool = Tool.define("task", async (ctx) => {
105194
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
106195
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
107196

108-
const model = agent.model ?? {
109-
modelID: msg.info.modelID,
110-
providerID: msg.info.providerID,
111-
}
197+
const model =
198+
(params.model ? parseModel(params.model) : undefined) ??
199+
agent.model ?? {
200+
modelID: msg.info.modelID,
201+
providerID: msg.info.providerID,
202+
}
203+
const variant = params.variant ?? agent.variant
112204

113205
ctx.metadata({
114206
title: params.description,
115207
metadata: {
116208
sessionId: session.id,
117209
model,
210+
...(variant ? { variant } : {}),
118211
},
119212
})
120213

@@ -135,6 +228,8 @@ export const TaskTool = Tool.define("task", async (ctx) => {
135228
providerID: model.providerID,
136229
},
137230
agent: agent.name,
231+
agentContext: dynamicAgent,
232+
...(variant ? { variant } : {}),
138233
tools: {
139234
todowrite: false,
140235
todoread: false,
@@ -159,6 +254,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
159254
metadata: {
160255
sessionId: session.id,
161256
model,
257+
...(variant ? { variant } : {}),
162258
},
163259
output,
164260
}

0 commit comments

Comments
 (0)