Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export namespace Command {
// https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
skill: z.string().optional(),
hints: z.array(z.string()),
})
.meta({
Expand Down Expand Up @@ -88,9 +89,11 @@ export namespace Command {
return command.template
},
subtask: command.subtask,
skill: command.skill,
hints: hints(command.template),
}
}

for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ export namespace Config {
agent: z.string().optional(),
model: z.string().optional(),
subtask: z.boolean().optional(),
skill: z.string().optional(),
})
export type Command = z.infer<typeof Command>

Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next"
import { Skill } from "@/skill"
import { ConfigMarkdown } from "@/config/markdown"
import path from "path"

export namespace LLM {
const log = Log.create({ service: "llm" })
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export namespace MessageV2 {
description: z.string(),
agent: z.string(),
command: z.string().optional(),
skill: z.string().optional(),
})
export type SubtaskPart = z.infer<typeof SubtaskPart>

Expand Down Expand Up @@ -305,6 +306,7 @@ export namespace MessageV2 {
modelID: z.string(),
}),
system: z.string().optional(),
skill: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
variant: z.string().optional(),
}).meta({
Expand Down
103 changes: 101 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Skill } from "../skill"

// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
Expand Down Expand Up @@ -91,6 +92,7 @@ export namespace SessionPrompt {
})
.optional(),
agent: z.string().optional(),
skill: z.string().optional(),
noReply: z.boolean().optional(),
tools: z
.record(z.string(), z.boolean())
Expand Down Expand Up @@ -165,12 +167,33 @@ export namespace SessionPrompt {
})
}
if (permissions.length > 0) {
session.permission = permissions
await Session.update(session.id, (draft) => {
draft.permission = permissions
draft.permission = [...(draft.permission ?? []), ...permissions]
})
}

if (input.skill) {
const skill = await Skill.get(input.skill)
if (skill) {
const dir = path.resolve(path.dirname(skill.location))
const rules: PermissionNext.Rule[] = [
{
permission: "*",
pattern: dir,
action: "allow",
},
{
permission: "*",
pattern: dir + "/*",
action: "allow",
},
]
await Session.update(session.id, (draft) => {
draft.permission = [...(draft.permission ?? []), ...rules]
})
}
}

if (input.noReply === true) {
return message
}
Expand Down Expand Up @@ -310,6 +333,8 @@ export namespace SessionPrompt {
history: msgs,
})

if (step === 1 && (await injectSkillContext(sessionID, lastUser))) continue

const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
const task = tasks.pop()

Expand Down Expand Up @@ -355,6 +380,7 @@ export namespace SessionPrompt {
description: task.description,
subagent_type: task.agent,
command: task.command,
skill: task.skill,
},
time: {
start: Date.now(),
Expand All @@ -366,6 +392,7 @@ export namespace SessionPrompt {
description: task.description,
subagent_type: task.agent,
command: task.command,
skill: task.skill,
}
await Plugin.trigger(
"tool.execute.before",
Expand Down Expand Up @@ -624,6 +651,76 @@ export namespace SessionPrompt {
throw new Error("Impossible")
})

async function injectSkillContext(sessionID: string, lastUser: MessageV2.User) {
if (!lastUser.skill) return false
const skill = await Skill.get(lastUser.skill)
if (!skill) return false

const parsed = await ConfigMarkdown.parse(skill.location)
const dir = path.dirname(skill.location)
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")

const modelInfo = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
const assistantMsgID = Identifier.ascending("message")
await Session.updateMessage({
id: assistantMsgID,
sessionID,
role: "assistant",
parentID: lastUser.id,
agent: lastUser.agent,
mode: lastUser.agent,
modelID: modelInfo.id,
providerID: modelInfo.providerID,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: Date.now(), completed: Date.now() },
finish: "tool-calls",
} satisfies MessageV2.Assistant)

await Session.updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsgID,
sessionID,
type: "tool",
tool: "skill",
callID: ulid(),
state: {
status: "completed",
input: { name: lastUser.skill },
output,
title: `Loaded skill: ${skill.name}`,
metadata: { name: skill.name, dir },
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)

const summaryUserMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: lastUser.agent,
model: lastUser.model,
}
await Session.updateMessage(summaryUserMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: summaryUserMsg.id,
sessionID,
type: "text",
text: "Summarize the task tool output above and continue with your task.",
synthetic: true,
} satisfies MessageV2.TextPart)

return true
}

async function lastModel(sessionID: string) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
Expand Down Expand Up @@ -805,6 +902,7 @@ export namespace SessionPrompt {
agent: agent.name,
model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
system: input.system,
skill: input.skill,
variant: input.variant,
}

Expand Down Expand Up @@ -1451,6 +1549,7 @@ export namespace SessionPrompt {
agent: agent.name,
description: command.description ?? "",
command: input.command,
skill: command.skill,
// TODO: how can we make task tool accept a more complex input?
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
},
Expand Down
67 changes: 50 additions & 17 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Skill } from "../skill"
import { ConfigMarkdown } from "../config/markdown"
import path from "path"
import { PermissionNext } from "@/permission/next"
import { ulid } from "ulid"
import { Instance } from "../project/instance"

export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
Expand All @@ -27,8 +33,31 @@ export const TaskTool = Tool.define("task", async () => {
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
session_id: z.string().describe("Existing Task session to continue").optional(),
command: z.string().describe("The command that triggered this task").optional(),
skill: z.string().describe("Optional skill to load into the subagent context").optional(),
}),
async execute(params, 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`)

if (params.skill) {
const skill = await Skill.get(params.skill)
if (!skill) {
const all = await Skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(
`Skill "${params.skill}" not found. Available skills: ${available || "none"}. Please choose from one of the available skills or select a more specialist task tool subagent.`,
)
}

// Check if the agent has permission for this skill
const result = PermissionNext.evaluate("skill", params.skill, agent.permission)
if (result.action === "deny") {
throw new Error(
`Agent "${params.subagent_type}" does not have permission to use the skill "${params.skill}". Please select a different agent or ensure the agent has the necessary permissions.`,
)
}
}

const config = await Config.get()
await ctx.ask({
permission: "task",
Expand All @@ -37,36 +66,39 @@ export const TaskTool = Tool.define("task", async () => {
metadata: {
description: params.description,
subagent_type: params.subagent_type,
skill: params.skill,
},
})

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 session = await iife(async () => {
if (params.session_id) {
const found = await Session.get(params.session_id).catch(() => {})
if (found) return found
}

const permission: PermissionNext.Ruleset = [
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "todoread",
pattern: "*",
action: "deny",
},
{
permission: "task",
pattern: "*",
action: "deny",
},
]

return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
permission: [
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "todoread",
pattern: "*",
action: "deny",
},
{
permission: "task",
pattern: "*",
action: "deny",
},
...permission,
...(config.experimental?.primary_tools?.map((t) => ({
pattern: "*",
action: "allow" as const,
Expand Down Expand Up @@ -129,6 +161,7 @@ export const TaskTool = Tool.define("task", async () => {
providerID: model.providerID,
},
agent: agent.name,
skill: params.skill,
tools: {
todowrite: false,
todoread: false,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/task.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ When NOT to use the Task tool:
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
3. Each agent invocation is stateless unless you provide a session_id. Your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
3. Each agent invocation is stateless unless you provide a session_id. Your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. You can also provide an optional skill name to load specialized instructions into the subagent's context.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
Expand Down