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:
@@ -20,6 +20,7 @@ export interface AvailableSkill {
|
||||
export interface AvailableCategory {
|
||||
name: string
|
||||
description: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export function categorizeTools(toolNames: string[]): AvailableTool[] {
|
||||
|
||||
90
src/index.ts
90
src/index.ts
@@ -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({
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user