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
commit 728eaaeb44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 268 additions and 79 deletions

View File

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

View File

@ -56,8 +56,10 @@ import {
discoverOpencodeProjectSkills, discoverOpencodeProjectSkills,
mergeSkills, mergeSkills,
} from "./features/opencode-skill-loader"; } from "./features/opencode-skill-loader";
import type { SkillScope } from "./features/opencode-skill-loader/types";
import { createBuiltinSkills } from "./features/builtin-skills"; import { createBuiltinSkills } from "./features/builtin-skills";
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder";
import { import {
setMainSession, setMainSession,
getMainSessionID, getMainSessionID,
@ -84,6 +86,10 @@ import {
createTaskList, createTaskList,
createTaskUpdateTool, createTaskUpdateTool,
} from "./tools"; } from "./tools";
import {
CATEGORY_DESCRIPTIONS,
DEFAULT_CATEGORIES,
} from "./tools/delegate-task/constants";
import { BackgroundManager } from "./features/background-agent"; import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager"; import { SkillMcpManager } from "./features/skill-mcp-manager";
import { initTaskToastManager } from "./features/task-toast-manager"; import { initTaskToastManager } from "./features/task-toast-manager";
@ -394,33 +400,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const browserProvider = const browserProvider =
pluginConfig.browser_automation_engine?.provider ?? "playwright"; pluginConfig.browser_automation_engine?.provider ?? "playwright";
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []); 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 systemMcpNames = getSystemMcpServerNames();
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => { const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => {
if (skill.mcpConfig) { if (skill.mcpConfig) {
@ -447,6 +426,63 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
projectSkills, projectSkills,
opencodeProjectSkills, 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 skillMcpManager = new SkillMcpManager();
const getSessionIDForMcp = () => getMainSessionID() || ""; const getSessionIDForMcp = () => getMainSessionID() || "";
const skillTool = createSkillTool({ const skillTool = createSkillTool({

View File

@ -1,4 +1,8 @@
import type { CategoryConfig } from "../../config/schema" import type { CategoryConfig } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context> export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks. 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. * then summarize user requirements and clarify uncertainties before proceeding.
* Also MANDATES dependency graphs, parallel execution analysis, and category+skill recommendations. * 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. BEFORE you begin planning, you MUST first understand the user's request deeply.
MANDATORY CONTEXT GATHERING PROTOCOL: MANDATORY CONTEXT GATHERING PROTOCOL:
@ -337,39 +341,9 @@ WHY THIS MATTERS:
FOR EVERY TASK, YOU MUST RECOMMEND: FOR EVERY TASK, YOU MUST RECOMMEND:
1. Which CATEGORY to use for delegation 1. Which CATEGORY to use for delegation
2. Which SKILLS to load for the delegated agent 2. Which SKILLS to load for the delegated agent
`
### AVAILABLE CATEGORIES export const PLAN_AGENT_SYSTEM_PREPEND_STATIC_AFTER_SKILLS = `### REQUIRED OUTPUT FORMAT
| 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
For EACH task, include a recommendation block: 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. * List of agent names that should be treated as plan agents.
* Case-insensitive matching is used. * Case-insensitive matching is used.
@ -524,4 +550,3 @@ export function isPlanAgent(agentName: string | undefined): boolean {
const lowerName = agentName.toLowerCase().trim() const lowerName = agentName.toLowerCase().trim()
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name)) 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 type { BuildSystemContentInput } from "./types"
import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
/** /**
* Build the system content to inject into the agent prompt. * Build the system content to inject into the agent prompt.
* Combines skill content, category prompt append, and plan agent system prepend. * Combines skill content, category prompt append, and plan agent system prepend.
*/ */
export function buildSystemContent(input: BuildSystemContentInput): string | undefined { 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) { if (!skillContent && !categoryPromptAppend && !planAgentPrepend) {
return undefined return undefined

View File

@ -1994,56 +1994,137 @@ describe("sisyphus-task", () => {
test("prepends plan agent system prompt when agentName is 'plan'", () => { test("prepends plan agent system prompt when agentName is 'plan'", () => {
// given // given
const { buildSystemContent } = require("./tools") 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 // when
const result = buildSystemContent({ agentName: "plan" }) const result = buildSystemContent({
agentName: "plan",
availableCategories,
availableSkills,
})
// then // then
expect(result).toContain("<system>") expect(result).toContain("<system>")
expect(result).toContain("MANDATORY CONTEXT GATHERING PROTOCOL") 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'", () => { test("prepends plan agent system prompt when agentName is 'prometheus'", () => {
// given // given
const { buildSystemContent } = require("./tools") 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 // when
const result = buildSystemContent({ agentName: "prometheus" }) const result = buildSystemContent({
agentName: "prometheus",
availableCategories,
availableSkills,
})
// then // then
expect(result).toContain("<system>") 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)", () => { test("prepends plan agent system prompt when agentName is 'Prometheus' (case insensitive)", () => {
// given // given
const { buildSystemContent } = require("./tools") 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 // when
const result = buildSystemContent({ agentName: "Prometheus" }) const result = buildSystemContent({
agentName: "Prometheus",
availableCategories,
availableSkills,
})
// then // then
expect(result).toContain("<system>") 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", () => { test("combines plan agent prepend with skill content", () => {
// given // given
const { buildSystemContent } = require("./tools") const { buildSystemContent } = require("./tools")
const { PLAN_AGENT_SYSTEM_PREPEND } = require("./constants") const { buildPlanAgentSystemPrepend } = require("./constants")
const skillContent = "You are a planning expert" 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 // when
const result = buildSystemContent({ skillContent, agentName: "plan" }) const result = buildSystemContent({
skillContent,
agentName: "plan",
availableCategories,
availableSkills,
})
// then // then
expect(result).toContain(PLAN_AGENT_SYSTEM_PREPEND) expect(result).toContain(planPrepend)
expect(result).toContain(skillContent) 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", () => { 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 { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants"
import { log } from "../../shared" import { log } from "../../shared"
import { buildSystemContent } from "./prompt-builder" import { buildSystemContent } from "./prompt-builder"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
import { import {
resolveSkillContent, resolveSkillContent,
resolveParentContext, resolveParentContext,
@ -26,6 +30,20 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const categoryNames = Object.keys(allCategories) const categoryNames = Object.keys(allCategories)
const categoryExamples = categoryNames.map(k => `'${k}'`).join(", ") 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 categoryList = categoryNames.map(name => {
const userDesc = userCategories?.[name]?.description const userDesc = userCategories?.[name]?.description
const builtinDesc = CATEGORY_DESCRIPTIONS[name] const builtinDesc = CATEGORY_DESCRIPTIONS[name]
@ -150,7 +168,13 @@ Prompts MUST be in English.`
}) })
if (isUnstableAgent && isRunInBackgroundExplicitlyFalse) { 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) return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
} }
} else { } else {
@ -162,7 +186,13 @@ Prompts MUST be in English.`
categoryModel = resolution.categoryModel categoryModel = resolution.categoryModel
} }
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend, agentName: agentToUse }) const systemContent = buildSystemContent({
skillContent,
categoryPromptAppend,
agentName: agentToUse,
availableCategories,
availableSkills,
})
if (runInBackground) { if (runInBackground) {
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent) return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent)

View File

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