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:
YeonGyu-Kim
2026-04-18 03:10:14 +09:00
committed by GitHub
60 changed files with 1590 additions and 1429 deletions
+4 -5
View File
@@ -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> {
+39 -4
View File
@@ -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,
)
},
});
}
+4 -3
View File
@@ -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)
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 }
}
+2 -3
View File
@@ -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,
}
}
+38 -137
View File
@@ -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"
}
+2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
-124
View File
@@ -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 -1
View File
@@ -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> {
+151
View File
@@ -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'.",
}
}
+18
View File
@@ -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.`
}
+107
View File
@@ -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
View File
@@ -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}`,
)
}
}
+2 -25
View File
@@ -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) {