Merge pull request #1551 from code-yeongyu/fix/plan-agent-dynamic-skills

fix(delegate-task): make plan agent categories/skills dynamic
This commit is contained in:
YeonGyu-Kim
2026-02-06 17:48:35 +09:00
committed by GitHub
7 changed files with 268 additions and 79 deletions

View File

@@ -20,6 +20,7 @@ export interface AvailableSkill {
export interface AvailableCategory {
name: string
description: string
model?: string
}
export function categorizeTools(toolNames: string[]): AvailableTool[] {

View File

@@ -56,8 +56,10 @@ import {
discoverOpencodeProjectSkills,
mergeSkills,
} from "./features/opencode-skill-loader";
import type { SkillScope } from "./features/opencode-skill-loader/types";
import { createBuiltinSkills } from "./features/builtin-skills";
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder";
import {
setMainSession,
getMainSessionID,
@@ -84,6 +86,10 @@ import {
createTaskList,
createTaskUpdateTool,
} from "./tools";
import {
CATEGORY_DESCRIPTIONS,
DEFAULT_CATEGORIES,
} from "./tools/delegate-task/constants";
import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { initTaskToastManager } from "./features/task-toast-manager";
@@ -394,33 +400,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const browserProvider =
pluginConfig.browser_automation_engine?.provider ?? "playwright";
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
const delegateTask = createDelegateTask({
manager: backgroundManager,
client: ctx.client,
directory: ctx.directory,
userCategories: pluginConfig.categories,
gitMasterConfig: pluginConfig.git_master,
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
browserProvider,
disabledSkills,
onSyncSessionCreated: async (event) => {
log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
});
await tmuxSessionManager.onSessionCreated({
type: "session.created",
properties: {
info: {
id: event.sessionID,
parentID: event.parentID,
title: event.title,
},
},
});
},
});
const systemMcpNames = getSystemMcpServerNames();
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => {
if (skill.mcpConfig) {
@@ -447,6 +426,63 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
projectSkills,
opencodeProjectSkills,
);
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
if (scope === "user" || scope === "opencode") return "user";
if (scope === "project" || scope === "opencode-project") return "project";
return "plugin";
}
const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({
name: skill.name,
description: skill.definition.description ?? "",
location: mapScopeToLocation(skill.scope),
}));
const mergedCategories = pluginConfig.categories
? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories }
: DEFAULT_CATEGORIES;
const availableCategories = Object.entries(mergedCategories).map(
([name, categoryConfig]) => ({
name,
description:
pluginConfig.categories?.[name]?.description
?? CATEGORY_DESCRIPTIONS[name]
?? "General tasks",
model: categoryConfig.model,
}),
);
const delegateTask = createDelegateTask({
manager: backgroundManager,
client: ctx.client,
directory: ctx.directory,
userCategories: pluginConfig.categories,
gitMasterConfig: pluginConfig.git_master,
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
browserProvider,
disabledSkills,
availableCategories,
availableSkills,
onSyncSessionCreated: async (event) => {
log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
});
await tmuxSessionManager.onSessionCreated({
type: "session.created",
properties: {
info: {
id: event.sessionID,
parentID: event.parentID,
title: event.title,
},
},
});
},
});
const skillMcpManager = new SkillMcpManager();
const getSessionIDForMcp = () => getMainSessionID() || "";
const skillTool = createSkillTool({

View File

@@ -1,4 +1,8 @@
import type { CategoryConfig } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks.
@@ -231,7 +235,7 @@ export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
* then summarize user requirements and clarify uncertainties before proceeding.
* Also MANDATES dependency graphs, parallel execution analysis, and category+skill recommendations.
*/
export const PLAN_AGENT_SYSTEM_PREPEND = `<system>
export const PLAN_AGENT_SYSTEM_PREPEND_STATIC_BEFORE_SKILLS = `<system>
BEFORE you begin planning, you MUST first understand the user's request deeply.
MANDATORY CONTEXT GATHERING PROTOCOL:
@@ -337,39 +341,9 @@ WHY THIS MATTERS:
FOR EVERY TASK, YOU MUST RECOMMEND:
1. Which CATEGORY to use for delegation
2. Which SKILLS to load for the delegated agent
`
### AVAILABLE CATEGORIES
| Category | Best For | Model |
|----------|----------|-------|
| \`visual-engineering\` | Frontend, UI/UX, design, styling, animation | google/gemini-3-pro |
| \`ultrabrain\` | Complex architecture, deep logical reasoning | openai/gpt-5.3-codex |
| \`artistry\` | Highly creative/artistic tasks, novel ideas | google/gemini-3-pro |
| \`quick\` | Trivial tasks - single file, typo fixes | anthropic/claude-haiku-4-5 |
| \`unspecified-low\` | Moderate effort, doesn't fit other categories | anthropic/claude-sonnet-4-5 |
| \`unspecified-high\` | High effort, doesn't fit other categories | anthropic/claude-opus-4-6 |
| \`writing\` | Documentation, prose, technical writing | google/gemini-3-flash |
### AVAILABLE SKILLS (ALWAYS EVALUATE ALL)
Skills inject specialized expertise into the delegated agent.
YOU MUST evaluate EVERY skill and justify inclusions/omissions.
| Skill | Domain |
|-------|--------|
| \`agent-browser\` | Browser automation, web testing |
| \`frontend-ui-ux\` | Stunning UI/UX design |
| \`git-master\` | Atomic commits, git operations |
| \`dev-browser\` | Persistent browser state automation |
| \`typescript-programmer\` | Production TypeScript code |
| \`python-programmer\` | Production Python code |
| \`svelte-programmer\` | Svelte components |
| \`golang-tui-programmer\` | Go TUI with Charmbracelet |
| \`python-debugger\` | Interactive Python debugging |
| \`data-scientist\` | DuckDB/Polars data processing |
| \`prompt-engineer\` | AI prompt optimization |
### REQUIRED OUTPUT FORMAT
export const PLAN_AGENT_SYSTEM_PREPEND_STATIC_AFTER_SKILLS = `### REQUIRED OUTPUT FORMAT
For EACH task, include a recommendation block:
@@ -508,6 +482,58 @@ WHY THIS FORMAT IS MANDATORY:
`
function renderPlanAgentCategoryRows(categories: AvailableCategory[]): string[] {
const sorted = [...categories].sort((a, b) => a.name.localeCompare(b.name))
return sorted.map((category) => {
const bestFor = category.description || category.name
const model = category.model || ""
return `| \`${category.name}\` | ${bestFor} | ${model} |`
})
}
function renderPlanAgentSkillRows(skills: AvailableSkill[]): string[] {
const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name))
return sorted.map((skill) => {
const firstSentence = skill.description.split(".")[0] || skill.description
const domain = firstSentence.trim() || skill.name
return `| \`${skill.name}\` | ${domain} |`
})
}
export function buildPlanAgentSkillsSection(
categories: AvailableCategory[] = [],
skills: AvailableSkill[] = []
): string {
const categoryRows = renderPlanAgentCategoryRows(categories)
const skillRows = renderPlanAgentSkillRows(skills)
return `### AVAILABLE CATEGORIES
| Category | Best For | Model |
|----------|----------|-------|
${categoryRows.join("\n")}
### AVAILABLE SKILLS (ALWAYS EVALUATE ALL)
Skills inject specialized expertise into the delegated agent.
YOU MUST evaluate EVERY skill and justify inclusions/omissions.
| Skill | Domain |
|-------|--------|
${skillRows.join("\n")}`
}
export function buildPlanAgentSystemPrepend(
categories: AvailableCategory[] = [],
skills: AvailableSkill[] = []
): string {
return [
PLAN_AGENT_SYSTEM_PREPEND_STATIC_BEFORE_SKILLS,
buildPlanAgentSkillsSection(categories, skills),
PLAN_AGENT_SYSTEM_PREPEND_STATIC_AFTER_SKILLS,
].join("\n\n")
}
/**
* List of agent names that should be treated as plan agents.
* Case-insensitive matching is used.
@@ -524,4 +550,3 @@ export function isPlanAgent(agentName: string | undefined): boolean {
const lowerName = agentName.toLowerCase().trim()
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name))
}

View File

@@ -1,14 +1,22 @@
import { PLAN_AGENT_SYSTEM_PREPEND, isPlanAgent } from "./constants"
import type { BuildSystemContentInput } from "./types"
import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
/**
* Build the system content to inject into the agent prompt.
* Combines skill content, category prompt append, and plan agent system prepend.
*/
export function buildSystemContent(input: BuildSystemContentInput): string | undefined {
const { skillContent, categoryPromptAppend, agentName } = input
const {
skillContent,
categoryPromptAppend,
agentName,
availableCategories,
availableSkills,
} = input
const planAgentPrepend = isPlanAgent(agentName) ? PLAN_AGENT_SYSTEM_PREPEND : ""
const planAgentPrepend = isPlanAgent(agentName)
? buildPlanAgentSystemPrepend(availableCategories, availableSkills)
: ""
if (!skillContent && !categoryPromptAppend && !planAgentPrepend) {
return undefined

View File

@@ -1994,56 +1994,137 @@ describe("sisyphus-task", () => {
test("prepends plan agent system prompt when agentName is 'plan'", () => {
// given
const { buildSystemContent } = require("./tools")
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
const { buildPlanAgentSystemPrepend } = require("./constants")
const availableCategories = [
{
name: "deep",
description: "Goal-oriented autonomous problem-solving",
model: "openai/gpt-5.3-codex",
},
]
const availableSkills = [
{
name: "typescript-programmer",
description: "Production TypeScript code.",
location: "plugin",
},
]
// when
const result = buildSystemContent({ agentName: "plan" })
const result = buildSystemContent({
agentName: "plan",
availableCategories,
availableSkills,
})
// then
expect(result).toContain("<system>")
expect(result).toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
expect(result).toBe(PLAN_AGENT_SYSTEM_PREPEND)
expect(result).toContain("### AVAILABLE CATEGORIES")
expect(result).toContain("`deep`")
expect(result).not.toContain("prompt-engineer")
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
})
test("prepends plan agent system prompt when agentName is 'prometheus'", () => {
// given
const { buildSystemContent } = require("./tools")
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
const { buildPlanAgentSystemPrepend } = require("./constants")
const availableCategories = [
{
name: "ultrabrain",
description: "Complex architecture, deep logical reasoning",
model: "openai/gpt-5.3-codex",
},
]
const availableSkills = [
{
name: "git-master",
description: "Atomic commits, git operations.",
location: "plugin",
},
]
// when
const result = buildSystemContent({ agentName: "prometheus" })
const result = buildSystemContent({
agentName: "prometheus",
availableCategories,
availableSkills,
})
// then
expect(result).toContain("<system>")
expect(result).toBe(PLAN_AGENT_SYSTEM_PREPEND)
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
})
test("prepends plan agent system prompt when agentName is 'Prometheus' (case insensitive)", () => {
// given
const { buildSystemContent } = require("./tools")
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
const { buildPlanAgentSystemPrepend } = require("./constants")
const availableCategories = [
{
name: "quick",
description: "Trivial tasks",
model: "anthropic/claude-haiku-4-5",
},
]
const availableSkills = [
{
name: "dev-browser",
description: "Persistent browser state automation.",
location: "plugin",
},
]
// when
const result = buildSystemContent({ agentName: "Prometheus" })
const result = buildSystemContent({
agentName: "Prometheus",
availableCategories,
availableSkills,
})
// then
expect(result).toContain("<system>")
expect(result).toBe(PLAN_AGENT_SYSTEM_PREPEND)
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
})
test("combines plan agent prepend with skill content", () => {
// given
const { buildSystemContent } = require("./tools")
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants")
const { buildPlanAgentSystemPrepend } = require("./constants")
const skillContent = "You are a planning expert"
const availableCategories = [
{
name: "writing",
description: "Documentation, prose, technical writing",
model: "google/gemini-3-flash",
},
]
const availableSkills = [
{
name: "python-programmer",
description: "Production Python code.",
location: "plugin",
},
]
const planPrepend = buildPlanAgentSystemPrepend(availableCategories, availableSkills)
// when
const result = buildSystemContent({ skillContent, agentName: "plan" })
const result = buildSystemContent({
skillContent,
agentName: "plan",
availableCategories,
availableSkills,
})
// then
expect(result).toContain(PLAN_AGENT_SYSTEM_PREPEND)
expect(result).toContain(planPrepend)
expect(result).toContain(skillContent)
expect(result!.indexOf(PLAN_AGENT_SYSTEM_PREPEND)).toBeLessThan(result!.indexOf(skillContent))
expect(result!.indexOf(planPrepend)).toBeLessThan(result!.indexOf(skillContent))
})
test("does not prepend plan agent prompt for non-plan agents", () => {

View File

@@ -3,6 +3,10 @@ import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants"
import { log } from "../../shared"
import { buildSystemContent } from "./prompt-builder"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
import {
resolveSkillContent,
resolveParentContext,
@@ -26,6 +30,20 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const categoryNames = Object.keys(allCategories)
const categoryExamples = categoryNames.map(k => `'${k}'`).join(", ")
const availableCategories: AvailableCategory[] = options.availableCategories
?? Object.entries(allCategories).map(([name, categoryConfig]) => {
const userDesc = userCategories?.[name]?.description
const builtinDesc = CATEGORY_DESCRIPTIONS[name]
const description = userDesc || builtinDesc || "General tasks"
return {
name,
description,
model: categoryConfig.model,
}
})
const availableSkills: AvailableSkill[] = options.availableSkills ?? []
const categoryList = categoryNames.map(name => {
const userDesc = userCategories?.[name]?.description
const builtinDesc = CATEGORY_DESCRIPTIONS[name]
@@ -150,7 +168,13 @@ Prompts MUST be in English.`
})
if (isUnstableAgent && isRunInBackgroundExplicitlyFalse) {
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend, agentName: agentToUse })
const systemContent = buildSystemContent({
skillContent,
categoryPromptAppend,
agentName: agentToUse,
availableCategories,
availableSkills,
})
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
}
} else {
@@ -162,7 +186,13 @@ Prompts MUST be in English.`
categoryModel = resolution.categoryModel
}
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend, agentName: agentToUse })
const systemContent = buildSystemContent({
skillContent,
categoryPromptAppend,
agentName: agentToUse,
availableCategories,
availableSkills,
})
if (runInBackground) {
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent)

View File

@@ -1,6 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
export type OpencodeClient = PluginInput["client"]
@@ -42,6 +46,8 @@ export interface DelegateTaskToolOptions {
sisyphusJuniorModel?: string
browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
availableCategories?: AvailableCategory[]
availableSkills?: AvailableSkill[]
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
}
@@ -49,4 +55,6 @@ export interface BuildSystemContentInput {
skillContent?: string
categoryPromptAppend?: string
agentName?: string
availableCategories?: AvailableCategory[]
availableSkills?: AvailableSkill[]
}