Merge pull request #3492 from code-yeongyu/refactor/legacy-plugin-decoupling
refactor: modernize plugin entry to V1 format and decouple legacy/tightly-coupled code
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||
import { getAgentToolRestrictions, log } from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
@@ -19,8 +18,8 @@ type ExecuteSyncDeps = {
|
||||
createOrGetSession: typeof createOrGetSession
|
||||
waitForCompletion: typeof waitForCompletion
|
||||
processMessages: typeof processMessages
|
||||
setSessionFallbackChain: typeof setSessionFallbackChain
|
||||
clearSessionFallbackChain: typeof clearSessionFallbackChain
|
||||
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
|
||||
clearSessionFallbackChain: (sessionID: string) => void
|
||||
}
|
||||
|
||||
type SpawnReservation = {
|
||||
@@ -32,8 +31,8 @@ const defaultDeps: ExecuteSyncDeps = {
|
||||
createOrGetSession,
|
||||
waitForCompletion,
|
||||
processMessages,
|
||||
setSessionFallbackChain,
|
||||
clearSessionFallbackChain,
|
||||
setSessionFallbackChain: () => {},
|
||||
clearSessionFallbackChain: () => {},
|
||||
}
|
||||
|
||||
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
||||
import type { AllowedAgentType, CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
@@ -11,10 +12,27 @@ import { normalizeFallbackModels } from "../../shared/model-resolver"
|
||||
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
||||
import { log } from "../../shared"
|
||||
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
|
||||
import { parseModelString } from "../delegate-task/model-string-parser"
|
||||
import { parseModelString } from "../../shared"
|
||||
import { executeBackground } from "./background-executor"
|
||||
import { executeSync } from "./sync-executor"
|
||||
import { resolveCallableAgents } from "./agent-resolver"
|
||||
import { createOrGetSession } from "./session-creator"
|
||||
import { processMessages } from "./message-processor"
|
||||
import { waitForCompletion } from "./completion-poller"
|
||||
|
||||
function createSyncExecutorDeps(modelFallbackControllerAccessor?: ModelFallbackControllerAccessor) {
|
||||
return {
|
||||
createOrGetSession,
|
||||
waitForCompletion,
|
||||
processMessages,
|
||||
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => {
|
||||
modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||
},
|
||||
clearSessionFallbackChain: (sessionID: string) => {
|
||||
modelFallbackControllerAccessor?.clearSessionFallbackChain(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveModelAndFallbackChain(args: {
|
||||
subagentType: string
|
||||
@@ -82,6 +100,7 @@ export function createCallOmoAgent(
|
||||
disabledAgents: string[] = [],
|
||||
agentOverrides?: AgentOverrides,
|
||||
userCategories?: CategoriesConfig,
|
||||
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor,
|
||||
): ToolDefinition {
|
||||
const agentDescriptions = ALLOWED_AGENTS.map(
|
||||
(name) => `- ${name}: Specialized agent for ${name} tasks`,
|
||||
@@ -158,14 +177,30 @@ export function createCallOmoAgent(
|
||||
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
|
||||
try {
|
||||
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
|
||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation, resolvedModel)
|
||||
return await executeSync(
|
||||
args,
|
||||
toolCtx,
|
||||
ctx,
|
||||
createSyncExecutorDeps(modelFallbackControllerAccessor),
|
||||
fallbackChain,
|
||||
spawnReservation,
|
||||
resolvedModel,
|
||||
)
|
||||
} catch (error) {
|
||||
spawnReservation?.rollback()
|
||||
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, undefined, resolvedModel)
|
||||
return await executeSync(
|
||||
args,
|
||||
toolCtx,
|
||||
ctx,
|
||||
createSyncExecutorDeps(modelFallbackControllerAccessor),
|
||||
fallbackChain,
|
||||
undefined,
|
||||
resolvedModel,
|
||||
)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { formatDetailedError } from "./error-formatting"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
||||
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||
@@ -19,6 +18,7 @@ function continueSessionSetup(args: {
|
||||
timing: ReturnType<typeof getTimingConfig>
|
||||
fallbackChain?: FallbackEntry[]
|
||||
category?: string
|
||||
modelFallbackControllerAccessor?: ExecutorContext["modelFallbackControllerAccessor"]
|
||||
}): void {
|
||||
if (!args.fallbackChain && !args.category) {
|
||||
return
|
||||
@@ -41,7 +41,7 @@ function continueSessionSetup(args: {
|
||||
continue
|
||||
}
|
||||
|
||||
setSessionFallbackChain(sessionId, args.fallbackChain)
|
||||
args.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, args.fallbackChain)
|
||||
if (args.category) {
|
||||
SessionCategoryRegistry.register(sessionId, args.category)
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export async function executeBackgroundTask(
|
||||
timing,
|
||||
fallbackChain,
|
||||
category: args.category,
|
||||
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -113,7 +114,7 @@ export async function executeBackgroundTask(
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
setSessionFallbackChain(sessionId, fallbackChain)
|
||||
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, fallbackChain)
|
||||
}
|
||||
if (args.category && sessionId) {
|
||||
SessionCategoryRegistry.register(sessionId, args.category)
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||
import { resolveCategoryConfig } from "./categories"
|
||||
import { parseModelString } from "./model-string-parser"
|
||||
import { parseModelString } from "../../shared/model-string-parser"
|
||||
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
||||
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||
import type { OpencodeClient } from "./types"
|
||||
|
||||
export interface ExecutorContext {
|
||||
@@ -12,6 +13,7 @@ export interface ExecutorContext {
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
agentOverrides?: AgentOverrides
|
||||
sisyphusAgentConfig?: SisyphusAgentConfig
|
||||
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
||||
syncPollTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fuzzyMatchModel } from "../../shared/model-availability"
|
||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||
import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache"
|
||||
import { log } from "../../shared/logger"
|
||||
import { parseModelString, parseVariantFromModelID } from "./model-string-parser"
|
||||
import { parseModelString, parseVariantFromModelID } from "../../shared/model-string-parser"
|
||||
|
||||
function isExplicitHighModel(model: string): boolean {
|
||||
return /(?:^|\/)[^/]+-high$/.test(model)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
const KNOWN_VARIANTS = new Set([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"minimal",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
])
|
||||
|
||||
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
|
||||
const trimmedModelID = rawModelID.trim()
|
||||
if (!trimmedModelID) {
|
||||
return { modelID: "" }
|
||||
}
|
||||
|
||||
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
|
||||
if (parenthesizedVariant) {
|
||||
const modelID = parenthesizedVariant[1]?.trim() ?? ""
|
||||
const variant = parenthesizedVariant[2]?.trim()
|
||||
return variant ? { modelID, variant } : { modelID }
|
||||
}
|
||||
|
||||
const spaceVariant = trimmedModelID.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
|
||||
if (spaceVariant) {
|
||||
const modelID = spaceVariant[1]?.trim() ?? ""
|
||||
const variant = spaceVariant[2]?.trim().toLowerCase()
|
||||
if (variant && KNOWN_VARIANTS.has(variant)) {
|
||||
return { modelID, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { modelID: trimmedModelID }
|
||||
}
|
||||
|
||||
export function parseModelString(
|
||||
model: string,
|
||||
): { providerID: string; modelID: string; variant?: string } | undefined {
|
||||
const trimmedModel = model.trim()
|
||||
if (!trimmedModel) return undefined
|
||||
|
||||
const parts = trimmedModel.split("/")
|
||||
if (parts.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerID = parts[0]?.trim()
|
||||
const rawModelID = parts.slice(1).join("/").trim()
|
||||
if (!providerID || !rawModelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsedModel = parseVariantFromModelID(rawModelID)
|
||||
if (!parsedModel.modelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsedModel.variant
|
||||
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
|
||||
: { providerID, modelID: parsedModel.modelID }
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { formatDuration } from "./time-formatter"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
||||
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||
@@ -81,7 +80,7 @@ export async function executeSyncTask(
|
||||
subagentSessions.add(sessionID)
|
||||
syncSubagentSessions.add(sessionID)
|
||||
setSessionAgent(sessionID, agentToUse)
|
||||
setSessionFallbackChain(sessionID, fallbackChain)
|
||||
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||
|
||||
if (args.category) {
|
||||
SessionCategoryRegistry.register(sessionID, args.category)
|
||||
@@ -237,7 +236,7 @@ ${buildTaskMetadataBlock({
|
||||
if (syncSessionID) {
|
||||
subagentSessions.delete(syncSessionID)
|
||||
syncSubagentSessions.delete(syncSessionID)
|
||||
clearSessionFallbackChain(syncSessionID)
|
||||
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID)
|
||||
SessionCategoryRegistry.remove(syncSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export async function prepareDelegateTaskArgs(args: Record<string, unknown>, ctx: ToolContextWithMetadata): Promise<DelegateTaskArgs> {
|
||||
const category = typeof args.category === "string" ? args.category : undefined
|
||||
const prompt = typeof args.prompt === "string" ? args.prompt : ""
|
||||
const originalSubagentType = typeof args.subagent_type === "string" ? args.subagent_type : undefined
|
||||
let subagentType = originalSubagentType
|
||||
|
||||
if (category) {
|
||||
if (subagentType && subagentType !== SISYPHUS_JUNIOR_AGENT) {
|
||||
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
|
||||
category,
|
||||
subagent_type: subagentType,
|
||||
})
|
||||
}
|
||||
subagentType = SISYPHUS_JUNIOR_AGENT
|
||||
}
|
||||
|
||||
let description = typeof args.description === "string" ? args.description : undefined
|
||||
if (!description || description.trim() === "") {
|
||||
const words = prompt.trim().split(/\s+/)
|
||||
description = words.slice(0, 4).join(" ") || "Delegated task"
|
||||
}
|
||||
|
||||
await ctx.metadata?.({
|
||||
title: description,
|
||||
})
|
||||
|
||||
const runInBackground = args.run_in_background
|
||||
if (runInBackground === undefined) {
|
||||
throw new Error("Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.")
|
||||
}
|
||||
|
||||
let loadSkills = args.load_skills
|
||||
if (typeof loadSkills === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(loadSkills)
|
||||
loadSkills = Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
loadSkills = []
|
||||
}
|
||||
}
|
||||
|
||||
if (loadSkills === undefined) {
|
||||
throw new Error("Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed.")
|
||||
}
|
||||
|
||||
if (loadSkills === null) {
|
||||
throw new Error("Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed.")
|
||||
}
|
||||
|
||||
const normalizedLoadSkills = Array.isArray(loadSkills)
|
||||
? loadSkills.filter((value): value is string => typeof value === "string")
|
||||
: []
|
||||
|
||||
const taskID = typeof args.task_id === "string" ? args.task_id : undefined
|
||||
const command = typeof args.command === "string" ? args.command : undefined
|
||||
|
||||
args.category = category
|
||||
args.subagent_type = subagentType
|
||||
args.description = description
|
||||
args.prompt = prompt
|
||||
args.run_in_background = runInBackground
|
||||
args.task_id = taskID
|
||||
args.command = command
|
||||
args.load_skills = normalizedLoadSkills
|
||||
|
||||
return {
|
||||
category,
|
||||
subagent_type: subagentType,
|
||||
description,
|
||||
prompt,
|
||||
run_in_background: runInBackground === true,
|
||||
task_id: taskID,
|
||||
command,
|
||||
load_skills: normalizedLoadSkills,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { AvailableCategory, AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import { CATEGORY_DESCRIPTIONS } from "./constants"
|
||||
import type { DelegateTaskToolOptions } from "./types"
|
||||
|
||||
export interface DelegateTaskPresentation {
|
||||
availableCategories: AvailableCategory[]
|
||||
availableSkills: AvailableSkill[]
|
||||
categoryExamples: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function createDelegateTaskPresentation(options: DelegateTaskToolOptions): DelegateTaskPresentation {
|
||||
const { userCategories } = options
|
||||
const allCategories = mergeCategories(userCategories)
|
||||
const categoryEntries = Object.entries(allCategories).map(([name, categoryConfig]) => ({
|
||||
name,
|
||||
categoryConfig,
|
||||
description: userCategories?.[name]?.description || CATEGORY_DESCRIPTIONS[name],
|
||||
}))
|
||||
const categoryNames = categoryEntries.map(({ name }) => name)
|
||||
const categoryExamples = categoryNames.join(", ")
|
||||
|
||||
const availableCategories: AvailableCategory[] = options.availableCategories
|
||||
?? categoryEntries.map(({ name, categoryConfig, description }) => {
|
||||
return {
|
||||
name,
|
||||
description: description || "General tasks",
|
||||
model: categoryConfig.model,
|
||||
}
|
||||
})
|
||||
|
||||
const availableSkills: AvailableSkill[] = options.availableSkills ?? []
|
||||
|
||||
const categoryList = categoryEntries.map(({ name, description }) => {
|
||||
return description ? ` - ${name}: ${description}` : ` - ${name}`
|
||||
}).join("\n")
|
||||
|
||||
const description = `Spawn agent task with category-based or direct agent selection.
|
||||
|
||||
⚠️ CRITICAL: You MUST provide EITHER category OR subagent_type. Omitting BOTH will FAIL.
|
||||
|
||||
**COMMON MISTAKE (DO NOT DO THIS):**
|
||||
\`\`\`
|
||||
task(description="...", prompt="...", run_in_background=false) // ❌ FAILS - missing category AND subagent_type
|
||||
\`\`\`
|
||||
|
||||
**CORRECT - Using category:**
|
||||
\`\`\`
|
||||
task(category="quick", load_skills=[], description="Fix type error", prompt="...", run_in_background=false)
|
||||
\`\`\`
|
||||
|
||||
**CORRECT - Using subagent_type:**
|
||||
\`\`\`
|
||||
task(subagent_type="explore", load_skills=[], description="Find patterns", prompt="...", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
REQUIRED: Provide ONE of:
|
||||
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
|
||||
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
|
||||
|
||||
**DO NOT provide both.** If category is provided, subagent_type is ignored.
|
||||
|
||||
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
|
||||
- category: Use predefined category → Spawns Sisyphus-Junior with category config
|
||||
Available categories:
|
||||
${categoryList}
|
||||
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
|
||||
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
|
||||
- command: The command that triggered this task (optional, for slash command tracking).
|
||||
|
||||
**WHEN TO USE task_id:**
|
||||
- Task failed/incomplete → task_id with "fix: [specific issue]"
|
||||
- Need follow-up on previous result → task_id with additional question
|
||||
- Multi-turn conversation with same agent → always task_id instead of new task
|
||||
|
||||
Prompts MUST be in English.`
|
||||
|
||||
return {
|
||||
availableCategories,
|
||||
availableSkills,
|
||||
categoryExamples,
|
||||
description,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { DelegateTaskArgs, DelegatedModelConfig, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
|
||||
import { CATEGORY_DESCRIPTIONS } from "./constants"
|
||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import type { DelegatedModelConfig, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { buildSystemContent } from "./prompt-builder"
|
||||
import type {
|
||||
AvailableCategory,
|
||||
AvailableSkill,
|
||||
} from "../../agents/dynamic-agent-prompt-builder"
|
||||
import {
|
||||
resolveSkillContent,
|
||||
resolveParentContext,
|
||||
@@ -20,133 +13,37 @@ import {
|
||||
executeBackgroundTask,
|
||||
executeSyncTask,
|
||||
} from "./executor"
|
||||
import { prepareDelegateTaskArgs } from "./tool-argument-preparation"
|
||||
import { createDelegateTaskPresentation } from "./tool-description"
|
||||
|
||||
export { resolveCategoryConfig } from "./categories"
|
||||
export type { SyncSessionCreatedEvent, DelegateTaskToolOptions, BuildSystemContentInput } from "./types"
|
||||
export { buildSystemContent, buildTaskPrompt } from "./prompt-builder"
|
||||
|
||||
const delegateTaskArgsSchema = {
|
||||
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
||||
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
|
||||
category: tool.schema.string().optional().describe("REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type."),
|
||||
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
|
||||
task_id: tool.schema.string().optional().describe("Existing task to continue. Canonical resume identifier."),
|
||||
command: tool.schema.string().optional().describe("The command that triggered this task"),
|
||||
}
|
||||
|
||||
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
|
||||
const { userCategories } = options
|
||||
|
||||
const allCategories = mergeCategories(userCategories)
|
||||
const categoryNames = Object.keys(allCategories)
|
||||
const categoryExamples = categoryNames.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]
|
||||
const desc = userDesc || builtinDesc
|
||||
return desc ? ` - ${name}: ${desc}` : ` - ${name}`
|
||||
}).join("\n")
|
||||
|
||||
const description = `Spawn agent task with category-based or direct agent selection.
|
||||
|
||||
⚠️ CRITICAL: You MUST provide EITHER category OR subagent_type. Omitting BOTH will FAIL.
|
||||
|
||||
**COMMON MISTAKE (DO NOT DO THIS):**
|
||||
\`\`\`
|
||||
task(description="...", prompt="...", run_in_background=false) // ❌ FAILS - missing category AND subagent_type
|
||||
\`\`\`
|
||||
|
||||
**CORRECT - Using category:**
|
||||
\`\`\`
|
||||
task(category="quick", load_skills=[], description="Fix type error", prompt="...", run_in_background=false)
|
||||
\`\`\`
|
||||
|
||||
**CORRECT - Using subagent_type:**
|
||||
\`\`\`
|
||||
task(subagent_type="explore", load_skills=[], description="Find patterns", prompt="...", run_in_background=true)
|
||||
\`\`\`
|
||||
|
||||
REQUIRED: Provide ONE of:
|
||||
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
|
||||
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
|
||||
|
||||
**DO NOT provide both.** If category is provided, subagent_type is ignored.
|
||||
|
||||
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
|
||||
- category: Use predefined category → Spawns Sisyphus-Junior with category config
|
||||
Available categories:
|
||||
${categoryList}
|
||||
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
|
||||
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
|
||||
- command: The command that triggered this task (optional, for slash command tracking).
|
||||
|
||||
**WHEN TO USE task_id:**
|
||||
- Task failed/incomplete → task_id with "fix: [specific issue]"
|
||||
- Need follow-up on previous result → task_id with additional question
|
||||
- Multi-turn conversation with same agent → always task_id instead of new task
|
||||
|
||||
Prompts MUST be in English.`
|
||||
const { availableCategories, availableSkills, categoryExamples, description } = createDelegateTaskPresentation(options)
|
||||
|
||||
return tool({
|
||||
description,
|
||||
args: {
|
||||
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
||||
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
|
||||
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
|
||||
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
|
||||
task_id: tool.schema.string().optional().describe("Existing task to continue. Canonical resume identifier."),
|
||||
command: tool.schema.string().optional().describe("The command that triggered this task"),
|
||||
},
|
||||
async execute(args: DelegateTaskArgs, toolContext) {
|
||||
args: delegateTaskArgsSchema,
|
||||
async execute(args, toolContext) {
|
||||
const ctx = toolContext as ToolContextWithMetadata
|
||||
const delegateTaskArgs = await prepareDelegateTaskArgs(args, ctx)
|
||||
|
||||
if (args.category) {
|
||||
if (args.subagent_type && args.subagent_type !== SISYPHUS_JUNIOR_AGENT) {
|
||||
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
|
||||
category: args.category,
|
||||
subagent_type: args.subagent_type,
|
||||
})
|
||||
}
|
||||
args.subagent_type = SISYPHUS_JUNIOR_AGENT
|
||||
}
|
||||
// Auto-generate description from prompt when missing or empty
|
||||
if (!args.description || typeof args.description !== "string" || args.description.trim() === "") {
|
||||
const words = (args.prompt || "").trim().split(/\s+/)
|
||||
args.description = words.slice(0, 4).join(" ") || "Delegated task"
|
||||
}
|
||||
await ctx.metadata?.({
|
||||
title: args.description,
|
||||
})
|
||||
if (args.run_in_background === undefined) {
|
||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
|
||||
}
|
||||
if (typeof args.load_skills === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(args.load_skills)
|
||||
args.load_skills = Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
args.load_skills = []
|
||||
}
|
||||
}
|
||||
if (args.load_skills === undefined) {
|
||||
throw new Error(`Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed.`)
|
||||
}
|
||||
if (args.load_skills === null) {
|
||||
throw new Error(`Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed.`)
|
||||
}
|
||||
const runInBackground = delegateTaskArgs.run_in_background === true
|
||||
|
||||
const runInBackground = args.run_in_background === true
|
||||
|
||||
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(args.load_skills, {
|
||||
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(delegateTaskArgs.load_skills, {
|
||||
gitMasterConfig: options.gitMasterConfig,
|
||||
browserProvider: options.browserProvider,
|
||||
disabledSkills: options.disabledSkills,
|
||||
@@ -158,14 +55,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
|
||||
const parentContext = await resolveParentContext(ctx, options.client)
|
||||
|
||||
if (args.task_id) {
|
||||
if (delegateTaskArgs.task_id) {
|
||||
if (runInBackground) {
|
||||
return executeBackgroundContinuation(args, ctx, options, parentContext)
|
||||
return executeBackgroundContinuation(delegateTaskArgs, ctx, options, parentContext)
|
||||
}
|
||||
return executeSyncContinuation(args, ctx, options, parentContext)
|
||||
return executeSyncContinuation(delegateTaskArgs, ctx, options, parentContext)
|
||||
}
|
||||
|
||||
if (!args.category && !args.subagent_type) {
|
||||
if (!delegateTaskArgs.category && !delegateTaskArgs.subagent_type) {
|
||||
return `Invalid arguments: Must provide either category or subagent_type.`
|
||||
}
|
||||
|
||||
@@ -190,8 +87,8 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
let fallbackChain: import("../../shared/model-requirements").FallbackEntry[] | undefined
|
||||
let maxPromptTokens: number | undefined
|
||||
|
||||
if (args.category) {
|
||||
const resolution = await resolveCategoryExecution(args, options, inheritedModel, systemDefaultModel)
|
||||
if (delegateTaskArgs.category) {
|
||||
const resolution = await resolveCategoryExecution(delegateTaskArgs, options, inheritedModel, systemDefaultModel)
|
||||
if (resolution.error) {
|
||||
return resolution.error
|
||||
}
|
||||
@@ -204,14 +101,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
fallbackChain = resolution.fallbackChain
|
||||
maxPromptTokens = resolution.maxPromptTokens
|
||||
|
||||
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
|
||||
const isRunInBackgroundExplicitlyFalse = isExplicitSyncRun(delegateTaskArgs.run_in_background)
|
||||
|
||||
log("[task] unstable agent detection", {
|
||||
category: args.category,
|
||||
category: delegateTaskArgs.category,
|
||||
actualModel,
|
||||
isUnstableAgent,
|
||||
run_in_background_value: args.run_in_background,
|
||||
run_in_background_type: typeof args.run_in_background,
|
||||
run_in_background_value: delegateTaskArgs.run_in_background,
|
||||
run_in_background_type: typeof delegateTaskArgs.run_in_background,
|
||||
isRunInBackgroundExplicitlyFalse,
|
||||
willForceBackground: isUnstableAgent && isRunInBackgroundExplicitlyFalse,
|
||||
})
|
||||
@@ -227,10 +124,10 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
availableCategories,
|
||||
availableSkills,
|
||||
})
|
||||
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
||||
return executeUnstableAgentTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
||||
}
|
||||
} else {
|
||||
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
|
||||
const resolution = await resolveSubagentExecution(delegateTaskArgs, options, parentContext.agent, categoryExamples)
|
||||
if (resolution.error) {
|
||||
return resolution.error
|
||||
}
|
||||
@@ -251,10 +148,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
})
|
||||
|
||||
if (runInBackground) {
|
||||
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
|
||||
return executeBackgroundTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
|
||||
}
|
||||
|
||||
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
|
||||
return executeSyncTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function isExplicitSyncRun(runInBackground: unknown): boolean {
|
||||
return runInBackground === false || runInBackground === "false"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
||||
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||
import type {
|
||||
AvailableCategory,
|
||||
AvailableSkill,
|
||||
@@ -68,6 +69,7 @@ export interface DelegateTaskToolOptions {
|
||||
availableSkills?: AvailableSkill[]
|
||||
agentOverrides?: AgentOverrides
|
||||
sisyphusAgentConfig?: SisyphusAgentConfig
|
||||
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
|
||||
syncPollTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../grep/constants"
|
||||
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../../shared/ripgrep-cli"
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||
export const DEFAULT_LIMIT = 100
|
||||
|
||||
@@ -3,13 +3,15 @@ import {
|
||||
resolveGrepCli,
|
||||
type ResolvedCli,
|
||||
type GrepBackend,
|
||||
DEFAULT_RG_THREADS,
|
||||
} from "../../shared/ripgrep-cli"
|
||||
import {
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_FILESIZE,
|
||||
DEFAULT_MAX_COUNT,
|
||||
DEFAULT_MAX_COLUMNS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_RG_THREADS,
|
||||
RG_SAFETY_FLAGS,
|
||||
GREP_SAFETY_FLAGS,
|
||||
} from "./constants"
|
||||
|
||||
@@ -1,126 +1,3 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { join, dirname } from "node:path"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
|
||||
import { getDataDir } from "../../shared/data-path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
|
||||
|
||||
export type GrepBackend = "rg" | "grep"
|
||||
|
||||
export interface ResolvedCli {
|
||||
path: string
|
||||
backend: GrepBackend
|
||||
}
|
||||
|
||||
let cachedCli: ResolvedCli | null = null
|
||||
let autoInstallAttempted = false
|
||||
|
||||
function findExecutable(name: string): string | null {
|
||||
const isWindows = process.platform === "win32"
|
||||
const cmd = isWindows ? "where" : "which"
|
||||
|
||||
try {
|
||||
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim().split("\n")[0]
|
||||
}
|
||||
} catch {
|
||||
// Command execution failed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getOpenCodeBundledRg(): string | null {
|
||||
const execPath = process.execPath
|
||||
const execDir = dirname(execPath)
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const rgName = isWindows ? "rg.exe" : "rg"
|
||||
|
||||
const candidates = [
|
||||
// OpenCode XDG data path (highest priority - where OpenCode installs rg)
|
||||
join(getDataDir(), "opencode", "bin", rgName),
|
||||
// Legacy paths relative to execPath
|
||||
join(execDir, rgName),
|
||||
join(execDir, "bin", rgName),
|
||||
join(execDir, "..", "bin", rgName),
|
||||
join(execDir, "..", "libexec", rgName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveGrepCli(): ResolvedCli {
|
||||
if (cachedCli) return cachedCli
|
||||
|
||||
const bundledRg = getOpenCodeBundledRg()
|
||||
if (bundledRg) {
|
||||
cachedCli = { path: bundledRg, backend: "rg" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
const systemRg = findExecutable("rg")
|
||||
if (systemRg) {
|
||||
cachedCli = { path: systemRg, backend: "rg" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
const installedRg = getInstalledRipgrepPath()
|
||||
if (installedRg) {
|
||||
cachedCli = { path: installedRg, backend: "rg" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
const grep = findExecutable("grep")
|
||||
if (grep) {
|
||||
cachedCli = { path: grep, backend: "grep" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
cachedCli = { path: "rg", backend: "rg" }
|
||||
return cachedCli
|
||||
}
|
||||
|
||||
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
|
||||
const current = resolveGrepCli()
|
||||
|
||||
if (current.backend === "rg" && current.path !== "rg") {
|
||||
return current
|
||||
}
|
||||
|
||||
if (autoInstallAttempted) {
|
||||
return current
|
||||
}
|
||||
|
||||
autoInstallAttempted = true
|
||||
|
||||
try {
|
||||
const rgPath = await downloadAndInstallRipgrep()
|
||||
cachedCli = { path: rgPath, backend: "rg" }
|
||||
return cachedCli
|
||||
} catch (error) {
|
||||
if (current.backend === "grep") {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
grep_path: current.path,
|
||||
})
|
||||
} else {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_DEPTH = 20
|
||||
export const DEFAULT_MAX_FILESIZE = "10M"
|
||||
export const DEFAULT_MAX_COUNT = 500
|
||||
@@ -128,7 +5,6 @@ export const DEFAULT_MAX_COLUMNS = 1000
|
||||
export const DEFAULT_CONTEXT = 2
|
||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024
|
||||
export const DEFAULT_RG_THREADS = 4
|
||||
|
||||
export const RG_SAFETY_FLAGS = [
|
||||
"--no-follow",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { resolve } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { resolveGrepCliWithAutoInstall } from "../../shared/ripgrep-cli"
|
||||
import { runRg, runRgCount } from "./cli"
|
||||
import { resolveGrepCliWithAutoInstall } from "./constants"
|
||||
import { formatGrepResult, formatCountResult } from "./result-formatter"
|
||||
|
||||
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { basename } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import {
|
||||
extractBase64Data,
|
||||
inferMimeTypeFromBase64,
|
||||
inferMimeTypeFromFilePath,
|
||||
} from "./mime-type-inference"
|
||||
import {
|
||||
needsConversion,
|
||||
convertImageToJpeg,
|
||||
convertBase64ImageToJpeg,
|
||||
cleanupConvertedImage,
|
||||
} from "./image-converter"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export interface LookAtFilePart {
|
||||
type: "file"
|
||||
mime: string
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface PreparedLookAtInput {
|
||||
readonly filePart: LookAtFilePart
|
||||
readonly isBase64Input: boolean
|
||||
readonly sourceDescription: string
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
type PrepareLookAtInputResult =
|
||||
| { ok: true; value: PreparedLookAtInput }
|
||||
| { ok: false; error: string }
|
||||
|
||||
function getTemporaryConversionPath(error: unknown): string | null {
|
||||
if (!(error instanceof Error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const temporaryOutputPath = Reflect.get(error, "temporaryOutputPath")
|
||||
if (typeof temporaryOutputPath === "string" && temporaryOutputPath.length > 0) {
|
||||
return temporaryOutputPath
|
||||
}
|
||||
|
||||
const temporaryDirectory = Reflect.get(error, "temporaryDirectory")
|
||||
if (typeof temporaryDirectory === "string" && temporaryDirectory.length > 0) {
|
||||
return temporaryDirectory
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function prepareLookAtInput(args: LookAtArgs): PrepareLookAtInputResult {
|
||||
const imageData = args.image_data
|
||||
const filePath = args.file_path
|
||||
|
||||
if (imageData) {
|
||||
const mimeType = inferMimeTypeFromBase64(imageData)
|
||||
|
||||
let finalBase64Data = extractBase64Data(imageData)
|
||||
let finalMimeType = mimeType
|
||||
let tempFilesToCleanup: string[] = []
|
||||
|
||||
if (needsConversion(mimeType)) {
|
||||
log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`)
|
||||
try {
|
||||
const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType)
|
||||
finalBase64Data = base64
|
||||
finalMimeType = "image/jpeg"
|
||||
tempFilesToCleanup = tempFiles
|
||||
log("[look_at] Base64 conversion successful")
|
||||
} catch (conversionError) {
|
||||
log(`[look_at] Base64 conversion failed: ${conversionError}`)
|
||||
return {
|
||||
ok: false,
|
||||
error: `Error: Failed to convert Base64 image format. ${conversionError}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
isBase64Input: true,
|
||||
sourceDescription: "clipboard/pasted image",
|
||||
filePart: {
|
||||
type: "file",
|
||||
mime: finalMimeType,
|
||||
url: `data:${finalMimeType};base64,${finalBase64Data}`,
|
||||
filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`,
|
||||
},
|
||||
cleanup() {
|
||||
for (const temporaryFile of tempFilesToCleanup) {
|
||||
cleanupConvertedImage(temporaryFile)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
let mimeType = inferMimeTypeFromFilePath(filePath)
|
||||
let actualFilePath = filePath
|
||||
let tempConversionPath: string | null = null
|
||||
|
||||
if (needsConversion(mimeType)) {
|
||||
log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`)
|
||||
try {
|
||||
const convertedFilePath = convertImageToJpeg(filePath, mimeType)
|
||||
tempConversionPath = convertedFilePath
|
||||
actualFilePath = convertedFilePath
|
||||
mimeType = "image/jpeg"
|
||||
log(`[look_at] Conversion successful: ${convertedFilePath}`)
|
||||
} catch (conversionError) {
|
||||
const failedConversionPath = getTemporaryConversionPath(conversionError)
|
||||
if (failedConversionPath) {
|
||||
tempConversionPath = failedConversionPath
|
||||
}
|
||||
log(`[look_at] Conversion failed: ${conversionError}`)
|
||||
return {
|
||||
ok: false,
|
||||
error: `Error: Failed to convert image format. ${conversionError}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
isBase64Input: false,
|
||||
sourceDescription: filePath,
|
||||
filePart: {
|
||||
type: "file",
|
||||
mime: mimeType,
|
||||
url: pathToFileURL(actualFilePath).href,
|
||||
filename: basename(actualFilePath),
|
||||
},
|
||||
cleanup() {
|
||||
if (tempConversionPath) {
|
||||
cleanupConvertedImage(tempConversionPath)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: "Error: Must provide either 'file_path' or 'image_data'.",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const READ_ENABLED = false
|
||||
|
||||
export function buildLookAtPrompt(goal: string, isBase64Input: boolean): string {
|
||||
const subjectNoun = isBase64Input ? "image" : "file"
|
||||
const sourceClause = READ_ENABLED
|
||||
? "Use the Read tool on the provided file path to load its contents, then analyze it."
|
||||
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
|
||||
|
||||
return `Analyze the attached ${subjectNoun} and extract the requested information.
|
||||
|
||||
${sourceClause}
|
||||
|
||||
Goal: ${goal}
|
||||
|
||||
Provide ONLY the extracted information that matches the goal.
|
||||
Be thorough on what was requested, concise on everything else.
|
||||
If the requested information is not found, clearly state what is missing.`
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||
import { extractLatestAssistantText } from "./assistant-message-extractor"
|
||||
import { MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import { READ_ENABLED, buildLookAtPrompt } from "./look-at-prompt"
|
||||
import type { LookAtFilePart } from "./look-at-input-preparer"
|
||||
import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata"
|
||||
|
||||
interface RunLookAtSessionInput {
|
||||
ctx: PluginInput
|
||||
toolContext: ToolContext
|
||||
goal: string
|
||||
filePart: LookAtFilePart
|
||||
isBase64Input: boolean
|
||||
}
|
||||
|
||||
export async function runLookAtSession({
|
||||
ctx,
|
||||
toolContext,
|
||||
goal,
|
||||
filePart,
|
||||
isBase64Input,
|
||||
}: RunLookAtSessionInput): Promise<string> {
|
||||
const prompt = buildLookAtPrompt(goal, isBase64Input)
|
||||
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
|
||||
|
||||
log(`[look_at] Creating session with parent: ${toolContext.sessionID}`)
|
||||
const parentSession = await ctx.client.session.get({
|
||||
path: { id: toolContext.sessionID },
|
||||
}).catch(() => null)
|
||||
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
|
||||
|
||||
const createResult = await ctx.client.session.create({
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `look_at: ${goal.substring(0, 50)}`,
|
||||
},
|
||||
query: { directory: parentDirectory },
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
log("[look_at] Session create error:", createResult.error)
|
||||
const errorString = String(createResult.error)
|
||||
if (errorString.toLowerCase().includes("unauthorized")) {
|
||||
return `Error: Failed to create session (Unauthorized). This may be due to:
|
||||
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
|
||||
2. Provider authentication issues
|
||||
3. Session permission inheritance problems
|
||||
|
||||
Try using a different provider or API key authentication.
|
||||
|
||||
Original error: ${createResult.error}`
|
||||
}
|
||||
|
||||
return `Error: Failed to create session: ${createResult.error}`
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
log(`[look_at] Created session: ${sessionID}`)
|
||||
|
||||
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||
try {
|
||||
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: MULTIMODAL_LOOKER_AGENT,
|
||||
tools: {
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
look_at: false,
|
||||
read: READ_ENABLED,
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: prompt },
|
||||
filePart,
|
||||
],
|
||||
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
|
||||
...(agentVariant ? { variant: agentVariant } : {}),
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
log("[look_at] Prompt error (ignored, will still fetch messages):", promptError)
|
||||
}
|
||||
|
||||
log(`[look_at] Fetching messages from session ${sessionID}...`)
|
||||
const messagesResult = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
if (messagesResult.error) {
|
||||
log("[look_at] Messages error:", messagesResult.error)
|
||||
return `Error: Failed to get messages: ${messagesResult.error}`
|
||||
}
|
||||
|
||||
const messages = messagesResult.data
|
||||
log(`[look_at] Got ${messages.length} messages`)
|
||||
|
||||
const responseText = extractLatestAssistantText(messages)
|
||||
if (!responseText) {
|
||||
log("[look_at] No assistant message found")
|
||||
return "Error: No response from multimodal-looker agent"
|
||||
}
|
||||
|
||||
log(`[look_at] Got response, length: ${responseText.length}`)
|
||||
return responseText
|
||||
}
|
||||
+18
-209
@@ -1,43 +1,11 @@
|
||||
import { basename } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import { LOOK_AT_DESCRIPTION } from "./constants"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||
import { extractLatestAssistantText } from "./assistant-message-extractor"
|
||||
import { log } from "../../shared"
|
||||
import type { LookAtArgsWithAlias } from "./look-at-arguments"
|
||||
import { normalizeArgs, validateArgs } from "./look-at-arguments"
|
||||
import {
|
||||
extractBase64Data,
|
||||
inferMimeTypeFromBase64,
|
||||
inferMimeTypeFromFilePath,
|
||||
} from "./mime-type-inference"
|
||||
import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata"
|
||||
import {
|
||||
needsConversion,
|
||||
convertImageToJpeg,
|
||||
convertBase64ImageToJpeg,
|
||||
cleanupConvertedImage,
|
||||
} from "./image-converter"
|
||||
|
||||
function getTemporaryConversionPath(error: unknown): string | null {
|
||||
if (!(error instanceof Error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const temporaryOutputPath = Reflect.get(error, "temporaryOutputPath")
|
||||
if (typeof temporaryOutputPath === "string" && temporaryOutputPath.length > 0) {
|
||||
return temporaryOutputPath
|
||||
}
|
||||
|
||||
const temporaryDirectory = Reflect.get(error, "temporaryDirectory")
|
||||
if (typeof temporaryDirectory === "string" && temporaryDirectory.length > 0) {
|
||||
return temporaryDirectory
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
import { prepareLookAtInput } from "./look-at-input-preparer"
|
||||
import { runLookAtSession } from "./look-at-session-runner"
|
||||
|
||||
export { normalizeArgs, validateArgs } from "./look-at-arguments"
|
||||
|
||||
@@ -57,188 +25,29 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
|
||||
return validationError
|
||||
}
|
||||
|
||||
const isBase64Input = Boolean(args.image_data)
|
||||
const sourceDescription = isBase64Input ? "clipboard/pasted image" : args.file_path
|
||||
const preparedInputResult = prepareLookAtInput(args)
|
||||
if (!preparedInputResult.ok) {
|
||||
return preparedInputResult.error
|
||||
}
|
||||
|
||||
const preparedInput = preparedInputResult.value
|
||||
const { isBase64Input, sourceDescription } = preparedInput
|
||||
log(`[look_at] Analyzing ${sourceDescription}, goal: ${args.goal}`)
|
||||
|
||||
const imageData = args.image_data
|
||||
const filePath = args.file_path
|
||||
|
||||
let mimeType: string
|
||||
let filePart: { type: "file"; mime: string; url: string; filename: string }
|
||||
let tempFilePath: string | null = null
|
||||
let tempConversionPath: string | null = null
|
||||
let tempFilesToCleanup: string[] = []
|
||||
|
||||
try {
|
||||
if (imageData) {
|
||||
mimeType = inferMimeTypeFromBase64(imageData)
|
||||
|
||||
let finalBase64Data = extractBase64Data(imageData)
|
||||
let finalMimeType = mimeType
|
||||
|
||||
if (needsConversion(mimeType)) {
|
||||
log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`)
|
||||
try {
|
||||
const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType)
|
||||
finalBase64Data = base64
|
||||
finalMimeType = "image/jpeg"
|
||||
tempFilesToCleanup = tempFiles
|
||||
log(`[look_at] Base64 conversion successful`)
|
||||
} catch (conversionError) {
|
||||
log(`[look_at] Base64 conversion failed: ${conversionError}`)
|
||||
return `Error: Failed to convert Base64 image format. ${conversionError}`
|
||||
}
|
||||
}
|
||||
|
||||
filePart = {
|
||||
type: "file",
|
||||
mime: finalMimeType,
|
||||
url: `data:${finalMimeType};base64,${finalBase64Data}`,
|
||||
filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`,
|
||||
}
|
||||
} else if (filePath) {
|
||||
mimeType = inferMimeTypeFromFilePath(filePath)
|
||||
|
||||
let actualFilePath = filePath
|
||||
if (needsConversion(mimeType)) {
|
||||
log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`)
|
||||
try {
|
||||
tempFilePath = convertImageToJpeg(filePath, mimeType)
|
||||
tempConversionPath = tempFilePath
|
||||
actualFilePath = tempFilePath
|
||||
mimeType = "image/jpeg"
|
||||
log(`[look_at] Conversion successful: ${tempFilePath}`)
|
||||
} catch (conversionError) {
|
||||
const failedConversionPath = getTemporaryConversionPath(conversionError)
|
||||
if (failedConversionPath) {
|
||||
tempConversionPath = failedConversionPath
|
||||
}
|
||||
log(`[look_at] Conversion failed: ${conversionError}`)
|
||||
return `Error: Failed to convert image format. ${conversionError}`
|
||||
}
|
||||
}
|
||||
|
||||
filePart = {
|
||||
type: "file",
|
||||
mime: mimeType,
|
||||
url: pathToFileURL(actualFilePath).href,
|
||||
filename: basename(actualFilePath),
|
||||
}
|
||||
} else {
|
||||
return "Error: Must provide either 'file_path' or 'image_data'."
|
||||
}
|
||||
|
||||
const readEnabled = false
|
||||
const subjectNoun = isBase64Input ? "image" : "file"
|
||||
const sourceClause = readEnabled
|
||||
? `Use the Read tool on the provided file path to load its contents, then analyze it.`
|
||||
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
|
||||
|
||||
const prompt = `Analyze the attached ${subjectNoun} and extract the requested information.
|
||||
|
||||
${sourceClause}
|
||||
|
||||
Goal: ${args.goal}
|
||||
|
||||
Provide ONLY the extracted information that matches the goal.
|
||||
Be thorough on what was requested, concise on everything else.
|
||||
If the requested information is not found, clearly state what is missing.`
|
||||
|
||||
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
|
||||
|
||||
log(`[look_at] Creating session with parent: ${toolContext.sessionID}`)
|
||||
const parentSession = await ctx.client.session.get({
|
||||
path: { id: toolContext.sessionID },
|
||||
}).catch(() => null)
|
||||
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
|
||||
|
||||
const createResult = await ctx.client.session.create({
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `look_at: ${args.goal.substring(0, 50)}`,
|
||||
},
|
||||
query: { directory: parentDirectory },
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
log(`[look_at] Session create error:`, createResult.error)
|
||||
const errorStr = String(createResult.error)
|
||||
if (errorStr.toLowerCase().includes("unauthorized")) {
|
||||
return `Error: Failed to create session (Unauthorized). This may be due to:
|
||||
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
|
||||
2. Provider authentication issues
|
||||
3. Session permission inheritance problems
|
||||
|
||||
Try using a different provider or API key authentication.
|
||||
|
||||
Original error: ${createResult.error}`
|
||||
}
|
||||
return `Error: Failed to create session: ${createResult.error}`
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
log(`[look_at] Created session: ${sessionID}`)
|
||||
|
||||
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||
try {
|
||||
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: MULTIMODAL_LOOKER_AGENT,
|
||||
tools: {
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
look_at: false,
|
||||
read: readEnabled,
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: prompt },
|
||||
filePart,
|
||||
],
|
||||
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
|
||||
...(agentVariant ? { variant: agentVariant } : {}),
|
||||
},
|
||||
return await runLookAtSession({
|
||||
ctx,
|
||||
toolContext,
|
||||
goal: args.goal,
|
||||
filePart: preparedInput.filePart,
|
||||
isBase64Input,
|
||||
})
|
||||
} catch (promptError) {
|
||||
log(`[look_at] Prompt error (ignored, will still fetch messages):`, promptError)
|
||||
}
|
||||
|
||||
log(`[look_at] Fetching messages from session ${sessionID}...`)
|
||||
|
||||
const messagesResult = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
if (messagesResult.error) {
|
||||
log(`[look_at] Messages error:`, messagesResult.error)
|
||||
return `Error: Failed to get messages: ${messagesResult.error}`
|
||||
}
|
||||
|
||||
const messages = messagesResult.data
|
||||
log(`[look_at] Got ${messages.length} messages`)
|
||||
|
||||
const responseText = extractLatestAssistantText(messages)
|
||||
if (!responseText) {
|
||||
log("[look_at] No assistant message found")
|
||||
return "Error: No response from multimodal-looker agent"
|
||||
}
|
||||
|
||||
log(`[look_at] Got response, length: ${responseText.length}`)
|
||||
return responseText
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log(`[look_at] Unexpected error analyzing ${sourceDescription}:`, error)
|
||||
return `Error: Failed to analyze ${sourceDescription}: ${errorMessage}`
|
||||
} finally {
|
||||
if (tempConversionPath) {
|
||||
cleanupConvertedImage(tempConversionPath)
|
||||
} else if (tempFilePath) {
|
||||
cleanupConvertedImage(tempFilePath)
|
||||
}
|
||||
tempFilesToCleanup.forEach(file => {
|
||||
cleanupConvertedImage(file)
|
||||
})
|
||||
preparedInput.cleanup()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export function parseSkillMcpArguments(
|
||||
argsJson: string | Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!argsJson) return {}
|
||||
if (typeof argsJson === "object" && argsJson !== null) {
|
||||
return argsJson
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonString = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson
|
||||
const parsed = JSON.parse(jsonString)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("Arguments must be a JSON object")
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Invalid arguments JSON: ${errorMessage}\n\n` +
|
||||
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
|
||||
`Received: ${argsJson}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants"
|
||||
import { parseSkillMcpArguments } from "./parse-skill-mcp-arguments"
|
||||
import type { SkillMcpArgs } from "./types"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
@@ -82,30 +83,6 @@ function formatBuiltinMcpHint(mcpName: string): string | null {
|
||||
)
|
||||
}
|
||||
|
||||
function parseArguments(argsJson: string | Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
if (!argsJson) return {}
|
||||
if (typeof argsJson === "object" && argsJson !== null) {
|
||||
return argsJson
|
||||
}
|
||||
try {
|
||||
// Strip outer single quotes if present (common in LLM output)
|
||||
const jsonStr = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson
|
||||
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("Arguments must be a JSON object")
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Invalid arguments JSON: ${errorMessage}\n\n` +
|
||||
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
|
||||
`Received: ${argsJson}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGrepFilter(output: string, pattern: string | undefined): string {
|
||||
if (!pattern) return output
|
||||
try {
|
||||
@@ -174,7 +151,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
|
||||
skillName: found.skill.name,
|
||||
}
|
||||
|
||||
const parsedArgs = parseArguments(args.arguments)
|
||||
const parsedArgs = parseSkillMcpArguments(args.arguments)
|
||||
|
||||
let output: string
|
||||
switch (operation.type) {
|
||||
|
||||
Reference in New Issue
Block a user