4de2399f22
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)
759 lines
27 KiB
TypeScript
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,
|
|
})
|
|
}
|
|
},
|
|
})
|
|
}
|