Files
oh-my-opencode/src/tools/sisyphus-task/tools.ts
T
justsisyphus 4de2399f22 fix(hooks): use API instead of filesystem to resolve model info for session.prompt
Previously, continuation hooks (todo-continuation, boulder-continuation, ralph-loop)
and background tasks resolved model info from filesystem cache, which could be stale
or missing. This caused session.prompt to fallback to default model (Sonnet) instead
of using the originally configured model (e.g., Opus).

Now all session.prompt calls first try API (session.messages) to get current model
info, with filesystem as fallback if API fails.

Affected files:
- todo-continuation-enforcer.ts
- sisyphus-orchestrator/index.ts
- ralph-loop/index.ts
- background-agent/manager.ts
- sisyphus-task/tools.ts
- hook-message-injector/index.ts (export ToolPermission type)
2026-01-16 02:33:44 +09:00

759 lines
27 KiB
TypeScript

import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import type { SisyphusTaskArgs } from "./types"
import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema"
import { SISYPHUS_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants"
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
import { discoverSkills } from "../../features/opencode-skill-loader"
import { getTaskToastManager } from "../../features/task-toast-manager"
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
type OpencodeClient = PluginInput["client"]
const SISYPHUS_JUNIOR_AGENT = "Sisyphus-Junior"
const CATEGORY_EXAMPLES = Object.keys(DEFAULT_CATEGORIES).map(k => `'${k}'`).join(", ")
function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
}
return undefined
}
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
interface ErrorContext {
operation: string
args?: SisyphusTaskArgs
sessionID?: string
agent?: string
category?: string
}
function formatDetailedError(error: unknown, ctx: ErrorContext): string {
const message = error instanceof Error ? error.message : String(error)
const stack = error instanceof Error ? error.stack : undefined
const lines: string[] = [
`${ctx.operation} failed`,
"",
`**Error**: ${message}`,
]
if (ctx.sessionID) {
lines.push(`**Session ID**: ${ctx.sessionID}`)
}
if (ctx.agent) {
lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`)
}
if (ctx.args) {
lines.push("", "**Arguments**:")
lines.push(`- description: "${ctx.args.description}"`)
lines.push(`- category: ${ctx.args.category ?? "(none)"}`)
lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`)
lines.push(`- run_in_background: ${ctx.args.run_in_background}`)
lines.push(`- skills: [${ctx.args.skills?.join(", ") ?? ""}]`)
if (ctx.args.resume) {
lines.push(`- resume: ${ctx.args.resume}`)
}
}
if (stack) {
lines.push("", "**Stack Trace**:")
lines.push("```")
lines.push(stack.split("\n").slice(0, 10).join("\n"))
lines.push("```")
}
return lines.join("\n")
}
type ToolContextWithMetadata = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}
function resolveCategoryConfig(
categoryName: string,
options: {
userCategories?: CategoriesConfig
parentModelString?: string
systemDefaultModel?: string
}
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null {
const { userCategories, parentModelString, systemDefaultModel } = options
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
const userConfig = userCategories?.[categoryName]
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""
if (!defaultConfig && !userConfig) {
return null
}
// Model priority: user override > parent model (inherit) > category default > system default
// Parent model takes precedence over category default so custom providers work out-of-box
const model = userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? systemDefaultModel
const config: CategoryConfig = {
...defaultConfig,
...userConfig,
model,
}
let promptAppend = defaultPromptAppend
if (userConfig?.prompt_append) {
promptAppend = defaultPromptAppend
? defaultPromptAppend + "\n\n" + userConfig.prompt_append
: userConfig.prompt_append
}
return { config, promptAppend, model }
}
export interface SisyphusTaskToolOptions {
manager: BackgroundManager
client: OpencodeClient
directory: string
userCategories?: CategoriesConfig
gitMasterConfig?: GitMasterConfig
}
export interface BuildSystemContentInput {
skillContent?: string
categoryPromptAppend?: string
}
export function buildSystemContent(input: BuildSystemContentInput): string | undefined {
const { skillContent, categoryPromptAppend } = input
if (!skillContent && !categoryPromptAppend) {
return undefined
}
if (skillContent && categoryPromptAppend) {
return `${skillContent}\n\n${categoryPromptAppend}`
}
return skillContent || categoryPromptAppend
}
export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefinition {
const { manager, client, directory, userCategories, gitMasterConfig } = options
return tool({
description: SISYPHUS_TASK_DESCRIPTION,
args: {
description: tool.schema.string().describe("Short task description"),
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
category: tool.schema.string().optional().describe(`Category name (e.g., ${CATEGORY_EXAMPLES}). Mutually exclusive with subagent_type.`),
subagent_type: tool.schema.string().optional().describe("Agent name directly (e.g., 'oracle', 'explore'). Mutually exclusive with category."),
run_in_background: tool.schema.boolean().describe("Run in background. MUST be explicitly set. Use false for task delegation, true only for parallel exploration."),
resume: tool.schema.string().optional().describe("Session ID to resume - continues previous agent session with full context"),
skills: tool.schema.array(tool.schema.string()).describe("Array of skill names to prepend to the prompt. Use [] if no skills needed."),
},
async execute(args: SisyphusTaskArgs, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
if (args.run_in_background === undefined) {
return `❌ Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`
}
if (args.skills === undefined) {
return `❌ Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills needed.`
}
const runInBackground = args.run_in_background === true
let skillContent: string | undefined
if (args.skills.length > 0) {
const { resolved, notFound } = await resolveMultipleSkillsAsync(args.skills, { gitMasterConfig })
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
const available = allSkills.map(s => s.name).join(", ")
return `❌ Skills not found: ${notFound.join(", ")}. Available: ${available}`
}
skillContent = Array.from(resolved.values()).join("\n\n")
}
const messageDir = getMessageDir(ctx.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
log("[sisyphus_task] parentAgent resolution", {
sessionID: ctx.sessionID,
messageDir,
ctxAgent: ctx.agent,
sessionAgent,
firstMessageAgent,
prevMessageAgent: prevMessage?.agent,
resolvedParentAgent: parentAgent,
})
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
: undefined
if (args.resume) {
if (runInBackground) {
try {
const task = await manager.resume({
sessionId: args.resume,
prompt: args.prompt,
parentSessionID: ctx.sessionID,
parentMessageID: ctx.messageID,
parentModel,
parentAgent,
})
ctx.metadata?.({
title: `Resume: ${task.description}`,
metadata: { sessionId: task.sessionID },
})
return `Background task resumed.
Task ID: ${task.id}
Session ID: ${task.sessionID}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
Agent continues with full previous context preserved.
Use \`background_output\` with task_id="${task.id}" to check progress.`
} catch (error) {
return formatDetailedError(error, {
operation: "Resume background task",
args,
sessionID: args.resume,
})
}
}
const toastManager = getTaskToastManager()
const taskId = `resume_sync_${args.resume.slice(0, 8)}`
const startTime = new Date()
if (toastManager) {
toastManager.addTask({
id: taskId,
description: args.description,
agent: "resume",
isBackground: false,
})
}
ctx.metadata?.({
title: `Resume: ${args.description}`,
metadata: { sessionId: args.resume, sync: true },
})
try {
let resumeAgent: string | undefined
let resumeModel: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await client.session.messages({ path: { id: args.resume } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string } }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model) {
resumeAgent = info.agent
resumeModel = info.model
break
}
}
} catch {
const resumeMessageDir = getMessageDir(args.resume)
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
resumeAgent = resumeMessage?.agent
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined
}
await client.session.prompt({
path: { id: args.resume },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: {
task: false,
sisyphus_task: false,
call_omo_agent: true,
},
parts: [{ type: "text", text: args.prompt }],
},
})
} catch (promptError) {
if (toastManager) {
toastManager.removeTask(taskId)
}
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
return `❌ Failed to send resume prompt: ${errorMessage}\n\nSession ID: ${args.resume}`
}
// Wait for message stability after prompt completes
const POLL_INTERVAL_MS = 500
const MIN_STABILITY_TIME_MS = 5000
const STABILITY_POLLS_REQUIRED = 3
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < 60000) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
const elapsed = Date.now() - pollStart
if (elapsed < MIN_STABILITY_TIME_MS) continue
const messagesCheck = await client.session.messages({ path: { id: args.resume } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({
path: { id: args.resume },
})
if (messagesResult.error) {
if (toastManager) {
toastManager.removeTask(taskId)
}
return `❌ Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.resume}`
}
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as Array<{
info?: { role?: string; time?: { created?: number } }
parts?: Array<{ type?: string; text?: string }>
}>
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (toastManager) {
toastManager.removeTask(taskId)
}
if (!lastMessage) {
return `❌ No assistant response found.\n\nSession ID: ${args.resume}`
}
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
return `Task resumed and completed in ${duration}.
Session ID: ${args.resume}
---
${textContent || "(No text output)"}`
}
if (args.category && args.subagent_type) {
return `❌ Invalid arguments: Provide EITHER category OR subagent_type, not both.`
}
if (!args.category && !args.subagent_type) {
return `❌ Invalid arguments: Must provide either category or subagent_type.`
}
// Fetch OpenCode config at boundary to get system default model
let systemDefaultModel: string | undefined
try {
const openCodeConfig = await client.config.get()
systemDefaultModel = (openCodeConfig as { model?: string })?.model
} catch {
// Config fetch failed, proceed without system default
systemDefaultModel = undefined
}
let agentToUse: string
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
let categoryPromptAppend: string | undefined
const parentModelString = parentModel
? `${parentModel.providerID}/${parentModel.modelID}`
: undefined
let modelInfo: ModelFallbackInfo | undefined
if (args.category) {
const resolved = resolveCategoryConfig(args.category, {
userCategories,
parentModelString,
systemDefaultModel,
})
if (!resolved) {
return `❌ Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
}
// Determine model source by comparing against the actual resolved model
const actualModel = resolved.model
const userDefinedModel = userCategories?.[args.category]?.model
const categoryDefaultModel = DEFAULT_CATEGORIES[args.category]?.model
if (!actualModel) {
return `❌ No model configured. Set a model in your OpenCode config, plugin config, or use a category with a default model.`
}
if (!parseModelString(actualModel)) {
return `❌ Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
}
switch (actualModel) {
case userDefinedModel:
modelInfo = { model: actualModel, type: "user-defined" }
break
case parentModelString:
modelInfo = { model: actualModel, type: "inherited" }
break
case categoryDefaultModel:
modelInfo = { model: actualModel, type: "category-default" }
break
case systemDefaultModel:
modelInfo = { model: actualModel, type: "system-default" }
break
}
agentToUse = SISYPHUS_JUNIOR_AGENT
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel
? (resolved.config.variant
? { ...parsedModel, variant: resolved.config.variant }
: parsedModel)
: undefined
categoryPromptAppend = resolved.promptAppend || undefined
} else {
if (!args.subagent_type?.trim()) {
return `❌ Agent name cannot be empty.`
}
const agentName = args.subagent_type.trim()
agentToUse = agentName
// Validate agent exists and is callable (not a primary agent)
try {
const agentsResult = await client.app.agents()
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all" }
const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[]
const callableAgents = agents.filter((a) => a.mode !== "primary")
const callableNames = callableAgents.map((a) => a.name)
if (!callableNames.includes(agentToUse)) {
const isPrimaryAgent = agents.some((a) => a.name === agentToUse && a.mode === "primary")
if (isPrimaryAgent) {
return `❌ Cannot call primary agent "${agentToUse}" via sisyphus_task. Primary agents are top-level orchestrators.`
}
const availableAgents = callableNames
.sort()
.join(", ")
return `❌ Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`
}
} catch {
// If we can't fetch agents, proceed anyway - the session.prompt will fail with a clearer error
}
}
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend })
if (runInBackground) {
try {
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: agentToUse,
parentSessionID: ctx.sessionID,
parentMessageID: ctx.messageID,
parentModel,
parentAgent,
model: categoryModel,
skills: args.skills,
skillContent: systemContent,
})
ctx.metadata?.({
title: args.description,
metadata: { sessionId: task.sessionID, category: args.category },
})
return `Background task launched.
Task ID: ${task.id}
Session ID: ${task.sessionID}
Description: ${task.description}
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
Status: ${task.status}
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch background task",
args,
agent: agentToUse,
category: args.category,
})
}
}
const toastManager = getTaskToastManager()
let taskId: string | undefined
let syncSessionID: string | undefined
try {
const parentSession = client.session.get
? await client.session.get({ path: { id: ctx.sessionID } }).catch(() => null)
: null
const parentDirectory = parentSession?.data?.directory ?? directory
const createResult = await client.session.create({
body: {
parentID: ctx.sessionID,
title: `Task: ${args.description}`,
},
query: {
directory: parentDirectory,
},
})
if (createResult.error) {
return `❌ Failed to create session: ${createResult.error}`
}
const sessionID = createResult.data.id
syncSessionID = sessionID
subagentSessions.add(sessionID)
taskId = `sync_${sessionID.slice(0, 8)}`
const startTime = new Date()
if (toastManager) {
toastManager.addTask({
id: taskId,
description: args.description,
agent: agentToUse,
isBackground: false,
skills: args.skills,
modelInfo,
})
}
ctx.metadata?.({
title: args.description,
metadata: { sessionId: sessionID, category: args.category, sync: true },
})
try {
await client.session.prompt({
path: { id: sessionID },
body: {
agent: agentToUse,
system: systemContent,
tools: {
task: false,
sisyphus_task: false,
call_omo_agent: true,
},
parts: [{ type: "text", text: args.prompt }],
...(categoryModel ? { model: categoryModel } : {}),
},
})
} catch (promptError) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
return formatDetailedError(new Error(`Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`), {
operation: "Send prompt to agent",
args,
sessionID,
agent: agentToUse,
category: args.category,
})
}
return formatDetailedError(promptError, {
operation: "Send prompt",
args,
sessionID,
agent: agentToUse,
category: args.category,
})
}
// Poll for session completion with stability detection
// The session may show as "idle" before messages appear, so we also check message stability
const POLL_INTERVAL_MS = 500
const MAX_POLL_TIME_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10000 // Minimum 10s before accepting completion
const STABILITY_POLLS_REQUIRED = 3
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
let pollCount = 0
log("[sisyphus_task] Starting poll loop", { sessionID, agentToUse })
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
log("[sisyphus_task] Aborted by user", { sessionID })
if (toastManager && taskId) toastManager.removeTask(taskId)
return `Task aborted.\n\nSession ID: ${sessionID}`
}
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
pollCount++
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
if (pollCount % 10 === 0) {
log("[sisyphus_task] Poll status", {
sessionID,
pollCount,
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
sessionStatus: sessionStatus?.type ?? "not_in_status",
stablePolls,
lastMsgCount,
})
}
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
const elapsed = Date.now() - pollStart
if (elapsed < MIN_STABILITY_TIME_MS) {
continue
}
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= STABILITY_POLLS_REQUIRED) {
log("[sisyphus_task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount })
break
}
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
if (Date.now() - pollStart >= MAX_POLL_TIME_MS) {
log("[sisyphus_task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls })
}
const messagesResult = await client.session.messages({
path: { id: sessionID },
})
if (messagesResult.error) {
return `❌ Error fetching result: ${messagesResult.error}\n\nSession ID: ${sessionID}`
}
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as Array<{
info?: { role?: string; time?: { created?: number } }
parts?: Array<{ type?: string; text?: string }>
}>
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (!lastMessage) {
return `❌ No assistant response found.\n\nSession ID: ${sessionID}`
}
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
if (toastManager) {
toastManager.removeTask(taskId)
}
subagentSessions.delete(sessionID)
return `Task completed in ${duration}.
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
Session ID: ${sessionID}
---
${textContent || "(No text output)"}`
} catch (error) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
if (syncSessionID) {
subagentSessions.delete(syncSessionID)
}
return formatDetailedError(error, {
operation: "Execute task",
args,
sessionID: syncSessionID,
agent: agentToUse,
category: args.category,
})
}
},
})
}