refactor: migrate delegate_task to task tool with metadata fixes

- Rename delegate_task tool to task across codebase (100 files)
- Update model references: claude-opus-4-6 → 4-5, gpt-5.3-codex → 5.2-codex
- Add tool-metadata-store to restore metadata overwritten by fromPlugin()
- Add session ID polling for BackgroundManager task sessions
- Await async ctx.metadata() calls in tool executors
- Add ses_ prefix guard to getMessageDir for performance
- Harden BackgroundManager with idle deferral and error handling
- Fix duplicate task key in sisyphus-junior test object literals
- Fix unawaited showOutputToUser in ast_grep_replace
- Fix background=true → run_in_background=true in ultrawork prompt
- Fix duplicate task/task references in docs and comments
This commit is contained in:
YeonGyu-Kim
2026-02-06 16:01:54 +09:00
parent f1c794e63e
commit a691a3ac0a
78 changed files with 1182 additions and 403 deletions
+4 -4
View File
@@ -459,13 +459,13 @@ YOU MUST END YOUR RESPONSE WITH THIS SECTION.
1. **Wave 1**: Fire these tasks IN PARALLEL (no dependencies)
\`\`\`
delegate_task(category="...", load_skills=[...], run_in_background=false, prompt="Task 1: ...")
delegate_task(category="...", load_skills=[...], run_in_background=false, prompt="Task N: ...")
task(category="...", load_skills=[...], run_in_background=false, prompt="Task 1: ...")
task(category="...", load_skills=[...], run_in_background=false, prompt="Task N: ...")
\`\`\`
2. **Wave 2**: After Wave 1 completes, fire next wave IN PARALLEL
\`\`\`
delegate_task(category="...", load_skills=[...], run_in_background=false, prompt="Task 2: ...")
task(category="...", load_skills=[...], run_in_background=false, prompt="Task 2: ...")
\`\`\`
3. Continue until all waves complete
@@ -476,7 +476,7 @@ YOU MUST END YOUR RESPONSE WITH THIS SECTION.
WHY THIS FORMAT IS MANDATORY:
- Caller can directly copy TODO items
- Wave grouping enables parallel execution
- Each task has clear delegate_task parameters
- Each task has clear task parameters
- QA criteria ensure verifiable completion
</FINAL_OUTPUT_FOR_CALLER>
+73 -38
View File
@@ -16,6 +16,7 @@ import { log, getAgentToolRestrictions, resolveModelPipeline, promptWithModelSug
import { fetchAvailableModels, isModelAvailable } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { storeToolMetadata } from "../../features/tool-metadata-store"
const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior"
@@ -67,7 +68,7 @@ export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContex
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
log("[delegate_task] parentAgent resolution", {
log("[task] parentAgent resolution", {
sessionID: ctx.sessionID,
messageDir,
ctxAgent: ctx.agent,
@@ -111,7 +112,7 @@ export async function executeBackgroundContinuation(
parentAgent: parentContext.agent,
})
ctx.metadata?.({
const bgContMeta = {
title: `Continue: ${task.description}`,
metadata: {
prompt: args.prompt,
@@ -122,7 +123,11 @@ export async function executeBackgroundContinuation(
sessionId: task.sessionID,
command: args.command,
},
})
}
await ctx.metadata?.(bgContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta)
}
return `Background task continued.
@@ -165,7 +170,7 @@ export async function executeSyncContinuation(
})
}
ctx.metadata?.({
const syncContMeta = {
title: `Continue: ${args.description}`,
metadata: {
prompt: args.prompt,
@@ -176,7 +181,11 @@ export async function executeSyncContinuation(
sync: true,
command: args.command,
},
})
}
await ctx.metadata?.(syncContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta)
}
try {
let resumeAgent: string | undefined
@@ -207,13 +216,12 @@ export async function executeSyncContinuation(
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false,
delegate_task: false,
call_omo_agent: true,
question: false,
},
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
@@ -316,17 +324,17 @@ export async function executeUnstableAgentTask(
category: args.category,
})
const WAIT_FOR_SESSION_INTERVAL_MS = 100
const WAIT_FOR_SESSION_TIMEOUT_MS = 30000
const timing = getTimingConfig()
const waitStart = Date.now()
while (!task.sessionID && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
let sessionID = task.sessionID
while (!sessionID && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS))
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = manager.getTask(task.id)
sessionID = updated?.sessionID
}
const sessionID = task.sessionID
if (!sessionID) {
return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), {
operation: "Launch monitored background task",
@@ -336,7 +344,7 @@ export async function executeUnstableAgentTask(
})
}
ctx.metadata?.({
const bgTaskMeta = {
title: args.description,
metadata: {
prompt: args.prompt,
@@ -348,7 +356,11 @@ export async function executeUnstableAgentTask(
sessionId: sessionID,
command: args.command,
},
})
}
await ctx.metadata?.(bgTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta)
}
const startTime = new Date()
const timingCfg = getTimingConfig()
@@ -463,7 +475,23 @@ export async function executeBackgroundTask(
category: args.category,
})
ctx.metadata?.({
// OpenCode TUI's `Task` tool UI calculates toolcalls by looking up
// `props.metadata.sessionId` and then counting tool parts in that session.
// BackgroundManager.launch() returns immediately (pending) before the session exists,
// so we must wait briefly for the session to be created to set metadata correctly.
const timing = getTimingConfig()
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = manager.getTask(task.id)
sessionId = updated?.sessionID
}
const unstableMeta = {
title: args.description,
metadata: {
prompt: args.prompt,
@@ -472,10 +500,14 @@ export async function executeBackgroundTask(
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: task.sessionID,
sessionId: sessionId ?? "pending",
command: args.command,
},
})
}
await ctx.metadata?.(unstableMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta)
}
return `Background task launched.
@@ -487,7 +519,7 @@ Status: ${task.status}
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
<task_metadata>
session_id: ${task.sessionID}
session_id: ${sessionId}
</task_metadata>`
} catch (error) {
return formatDetailedError(error, {
@@ -542,13 +574,13 @@ export async function executeSyncTask(
subagentSessions.add(sessionID)
if (onSyncSessionCreated) {
log("[delegate_task] Invoking onSyncSessionCreated callback", { sessionID, parentID: parentContext.sessionID })
log("[task] Invoking onSyncSessionCreated callback", { sessionID, parentID: parentContext.sessionID })
await onSyncSessionCreated({
sessionID,
parentID: parentContext.sessionID,
title: args.description,
}).catch((err) => {
log("[delegate_task] onSyncSessionCreated callback failed", { error: String(err) })
log("[task] onSyncSessionCreated callback failed", { error: String(err) })
})
await new Promise(r => setTimeout(r, 200))
}
@@ -568,7 +600,7 @@ export async function executeSyncTask(
})
}
ctx.metadata?.({
const syncTaskMeta = {
title: args.description,
metadata: {
prompt: args.prompt,
@@ -581,18 +613,21 @@ export async function executeSyncTask(
sync: true,
command: args.command,
},
})
}
await ctx.metadata?.(syncTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta)
}
try {
const allowDelegateTask = isPlanAgent(agentToUse)
const allowTask = isPlanAgent(agentToUse)
await promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: {
agent: agentToUse,
system: systemContent,
tools: {
task: false,
delegate_task: allowDelegateTask,
task: allowTask,
call_omo_agent: true,
question: false,
},
@@ -630,11 +665,11 @@ export async function executeSyncTask(
let stablePolls = 0
let pollCount = 0
log("[delegate_task] Starting poll loop", { sessionID, agentToUse })
log("[task] Starting poll loop", { sessionID, agentToUse })
while (Date.now() - pollStart < syncTiming.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
log("[delegate_task] Aborted by user", { sessionID })
log("[task] Aborted by user", { sessionID })
if (toastManager && taskId) toastManager.removeTask(taskId)
return `Task aborted.\n\nSession ID: ${sessionID}`
}
@@ -647,7 +682,7 @@ export async function executeSyncTask(
const sessionStatus = allStatuses[sessionID]
if (pollCount % 10 === 0) {
log("[delegate_task] Poll status", {
log("[task] Poll status", {
sessionID,
pollCount,
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
@@ -675,7 +710,7 @@ export async function executeSyncTask(
if (currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= syncTiming.STABILITY_POLLS_REQUIRED) {
log("[delegate_task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount })
log("[task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount })
break
}
} else {
@@ -685,7 +720,7 @@ export async function executeSyncTask(
}
if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) {
log("[delegate_task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls })
log("[task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls })
}
const messagesResult = await client.session.messages({
@@ -928,7 +963,7 @@ Sisyphus-Junior is spawned automatically when you specify a category. Pick the a
return {
agentToUse: "",
categoryModel: undefined,
error: `You are prometheus. You cannot delegate to prometheus via delegate_task.
error: `You are prometheus. You cannot delegate to prometheus via task.
Create the work plan directly - that's your job as the planning agent.`,
}
@@ -955,7 +990,7 @@ Create the work plan directly - that's your job as the planning agent.`,
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot call primary agent "${isPrimaryAgent.name}" via delegate_task. Primary agents are top-level orchestrators.`,
error: `Cannot call primary agent "${isPrimaryAgent.name}" via task. Primary agents are top-level orchestrators.`,
}
}
+1
View File
@@ -18,6 +18,7 @@ export function parseModelString(model: string): { providerID: string; modelID:
* Get the message directory for a session, checking both direct and nested paths.
*/
export function getMessageDir(sessionID: string): string | null {
if (!sessionID.startsWith("ses_")) return null
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
@@ -0,0 +1,65 @@
const { describe, test, expect } = require("bun:test")
import { executeBackgroundTask } from "./executor"
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
describe("task tool metadata awaiting", () => {
test("executeBackgroundTask awaits ctx.metadata before returning", async () => {
// given
let metadataResolved = false
const abort = new AbortController()
const ctx: ToolContextWithMetadata = {
sessionID: "ses_parent",
messageID: "msg_parent",
agent: "sisyphus",
abort: abort.signal,
metadata: async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 50))
metadataResolved = true
},
}
const args: DelegateTaskArgs = {
load_skills: [],
description: "Test task",
prompt: "Do something",
run_in_background: true,
subagent_type: "explore",
}
const executorCtx = {
manager: {
launch: async () => ({
id: "task_1",
description: "Test task",
prompt: "Do something",
agent: "explore",
status: "pending",
sessionID: "ses_child",
}),
getTask: () => undefined,
},
} as any
const parentContext = {
sessionID: "ses_parent",
messageID: "msg_parent",
}
// when
const result = await executeBackgroundTask(
args,
ctx,
executorCtx,
parentContext,
"explore",
undefined,
undefined,
)
// then
expect(result).toContain("Background task launched")
expect(metadataResolved).toBe(true)
})
})
+138 -12
View File
@@ -1,4 +1,5 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES } from "./constants"
import { resolveCategoryConfig } from "./tools"
import type { CategoryConfig } from "../../config/schema"
@@ -207,6 +208,66 @@ describe("sisyphus-task", () => {
})
describe("category delegation config validation", () => {
test("fills subagent_type as sisyphus-junior when category is provided without subagent_type", async () => {
// given
const { createDelegateTask } = require("./tools")
const mockManager = {
launch: async () => ({
id: "task-123",
status: "pending",
description: "Test task",
agent: "sisyphus-junior",
sessionID: "test-session",
}),
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) },
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
const args: {
description: string
prompt: string
category: string
run_in_background: boolean
load_skills: string[]
subagent_type?: string
} = {
description: "Quick category test",
prompt: "Do something",
category: "quick",
run_in_background: true,
load_skills: [],
}
// when
await tool.execute(args, toolContext)
// then
expect(args.subagent_type).toBe("sisyphus-junior")
}, { timeout: 10000 })
test("proceeds without error when systemDefaultModel is undefined", async () => {
// given a mock client with no model in config
const { createDelegateTask } = require("./tools")
@@ -304,6 +365,71 @@ describe("sisyphus-task", () => {
})
})
describe("background metadata sessionId", () => {
test("should wait for background sessionId and set metadata for TUI toolcall counting", async () => {
//#given - manager.launch returns before sessionID is available
const { createDelegateTask } = require("./tools")
const tasks = new Map<string, { id: string; sessionID?: string; status: string; description: string; agent: string }>()
const mockManager = {
getTask: (id: string) => tasks.get(id),
launch: async () => {
const task = { id: "bg_1", status: "pending", description: "Test task", agent: "explore" }
tasks.set(task.id, task)
setTimeout(() => {
tasks.set(task.id, { ...task, status: "running", sessionID: "ses_child" })
}, 20)
return task
},
}
const mockClient = {
app: { agents: async () => ({ data: [{ name: "explore", mode: "subagent" }] }) },
config: { get: async () => ({}) },
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const metadataCalls: Array<{ title?: string; metadata?: Record<string, unknown> }> = []
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: (input: { title?: string; metadata?: Record<string, unknown> }) => {
metadataCalls.push(input)
},
}
const args = {
description: "Explore task",
prompt: "Explore features directory deeply",
subagent_type: "explore",
run_in_background: true,
load_skills: [],
}
//#when
const result = await tool.execute(args, toolContext)
//#then - metadata should include sessionId (camelCase) once it's available
expect(String(result)).toContain("Background task launched")
const sessionIdCall = metadataCalls.find((c) => c.metadata?.sessionId === "ses_child")
expect(sessionIdCall).toBeDefined()
})
})
describe("resolveCategoryConfig", () => {
test("returns null for unknown category without user config", () => {
// given
@@ -1894,7 +2020,7 @@ describe("sisyphus-task", () => {
describe("browserProvider propagation", () => {
test("should resolve agent-browser skill when browserProvider is passed", async () => {
// given - delegate_task configured with browserProvider: "agent-browser"
// given - task configured with browserProvider: "agent-browser"
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -1949,7 +2075,7 @@ describe("sisyphus-task", () => {
}, { timeout: 20000 })
test("should NOT resolve agent-browser skill when browserProvider is not set", async () => {
// given - delegate_task without browserProvider (defaults to playwright)
// given - task without browserProvider (defaults to playwright)
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
@@ -2720,8 +2846,8 @@ describe("sisyphus-task", () => {
}, { timeout: 20000 })
})
describe("prometheus subagent delegate_task permission", () => {
test("prometheus subagent should have delegate_task permission enabled", async () => {
describe("prometheus subagent task permission", () => {
test("prometheus subagent should have task permission enabled", async () => {
// given - sisyphus delegates to prometheus
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -2759,7 +2885,7 @@ describe("sisyphus-task", () => {
// when - sisyphus delegates to prometheus
await tool.execute(
{
description: "Test prometheus delegate_task permission",
description: "Test prometheus task permission",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
@@ -2768,11 +2894,11 @@ describe("sisyphus-task", () => {
toolContext
)
// then - prometheus should have delegate_task permission
expect(promptBody.tools.delegate_task).toBe(true)
// then - prometheus should have task permission
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
test("non-prometheus subagent should NOT have delegate_task permission", async () => {
test("non-prometheus subagent should NOT have task permission", async () => {
// given - sisyphus delegates to oracle (non-prometheus)
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -2810,7 +2936,7 @@ describe("sisyphus-task", () => {
// when - sisyphus delegates to oracle
await tool.execute(
{
description: "Test oracle no delegate_task permission",
description: "Test oracle no task permission",
prompt: "Consult on architecture",
subagent_type: "oracle",
run_in_background: false,
@@ -2819,8 +2945,8 @@ describe("sisyphus-task", () => {
toolContext
)
// then - oracle should NOT have delegate_task permission
expect(promptBody.tools.delegate_task).toBe(false)
// then - oracle should NOT have task permission
expect(promptBody.tools.task).toBe(false)
}, { timeout: 20000 })
})
+9 -2
View File
@@ -86,6 +86,13 @@ Prompts MUST be in English.`
async execute(args: DelegateTaskArgs, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
if (args.category && !args.subagent_type) {
args.subagent_type = "sisyphus-junior"
}
await ctx.metadata?.({
title: args.description,
})
if (args.run_in_background === undefined) {
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`)
}
@@ -116,7 +123,7 @@ Prompts MUST be in English.`
return executeSyncContinuation(args, ctx, options)
}
if (args.category && args.subagent_type) {
if (args.category && args.subagent_type && args.subagent_type !== "sisyphus-junior") {
return `Invalid arguments: Provide EITHER category OR subagent_type, not both.`
}
@@ -157,7 +164,7 @@ Prompts MUST be in English.`
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
log("[delegate_task] unstable agent detection", {
log("[task] unstable agent detection", {
category: args.category,
actualModel,
isUnstableAgent,
+6 -1
View File
@@ -28,7 +28,12 @@ export interface ToolContextWithMetadata {
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void | Promise<void>
/**
* Tool call ID injected by OpenCode's internal context (not in plugin ToolContext type,
* but present at runtime via spread in fromPlugin()). Used for metadata store keying.
*/
callID?: string
}
export interface SyncSessionCreatedEvent {