diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e9..f6ebd766cbb 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -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({ @@ -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, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f62581db369..a8dddc7c604 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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 diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index fc701588d57..461b0b081b5 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -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" }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2f8b1720d3a..965e16cd042 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -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 @@ -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({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 634539ade41..315d64c758d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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 @@ -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()) @@ -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 } @@ -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() @@ -355,6 +380,7 @@ export namespace SessionPrompt { description: task.description, subagent_type: task.agent, command: task.command, + skill: task.skill, }, time: { start: Date.now(), @@ -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", @@ -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 @@ -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, } @@ -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 ?? ""), }, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 112edc3dc88..87744ab0425 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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")) @@ -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", @@ -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, @@ -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, diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 7af2a6f60dd..c5c5315af03 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -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.