diff --git a/src/agents/atlas/agent.ts b/src/agents/atlas/agent.ts index c583ada5a..ccf987754 100644 --- a/src/agents/atlas/agent.ts +++ b/src/agents/atlas/agent.ts @@ -29,7 +29,7 @@ import { buildDecisionMatrix, } from "./prompt-section-builder" -const MODE: AgentMode = "primary" +const MODE: AgentMode = "all" export type AtlasPromptSource = "default" | "gpt" | "gemini" diff --git a/src/agents/hephaestus.ts b/src/agents/hephaestus.ts index 10f700662..e182c96f4 100644 --- a/src/agents/hephaestus.ts +++ b/src/agents/hephaestus.ts @@ -19,7 +19,7 @@ import { categorizeTools, } from "./dynamic-agent-prompt-builder"; -const MODE: AgentMode = "primary"; +const MODE: AgentMode = "all"; function buildTodoDisciplineSection(useTaskSystem: boolean): string { if (useTaskSystem) { diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 72173bd48..06debf111 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -8,7 +8,7 @@ import { buildGeminiIntentGateEnforcement, } from "./sisyphus-gemini-overlays"; -const MODE: AgentMode = "primary"; +const MODE: AgentMode = "all"; export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = { category: "utility", cost: "EXPENSIVE", diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index b36e8688f..52e7d461e 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -27,7 +27,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ /** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */ default_run_agent: z.string().optional(), disabled_mcps: z.array(AnyMcpNameSchema).optional(), - disabled_agents: z.array(BuiltinAgentNameSchema).optional(), + disabled_agents: z.array(z.string()).optional(), disabled_skills: z.array(BuiltinSkillNameSchema).optional(), disabled_hooks: z.array(z.string()).optional(), disabled_commands: z.array(BuiltinCommandNameSchema).optional(), diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 10708d1a3..1f944db59 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -15,6 +15,7 @@ import { MAX_CONSECUTIVE_FAILURES, } from "./constants" import { isLastAssistantMessageAborted } from "./abort-detection" +import { hasUnansweredQuestion } from "./pending-question-detection" import { getIncompleteCount } from "./todo" import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" import type { SessionStateStore } from "./session-state" @@ -74,6 +75,10 @@ export async function handleSessionIdle(args: { log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) return } + if (hasUnansweredQuestion(messages)) { + log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID }) + return + } } catch (error) { log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) }) } diff --git a/src/hooks/todo-continuation-enforcer/pending-question-detection.test.ts b/src/hooks/todo-continuation-enforcer/pending-question-detection.test.ts new file mode 100644 index 000000000..5ea4b214c --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/pending-question-detection.test.ts @@ -0,0 +1,100 @@ +/// +import { describe, expect, test } from "bun:test" + +import { hasUnansweredQuestion } from "./pending-question-detection" + +describe("hasUnansweredQuestion", () => { + test("given empty messages, returns false", () => { + expect(hasUnansweredQuestion([])).toBe(false) + }) + + test("given null-ish input, returns false", () => { + expect(hasUnansweredQuestion(undefined as never)).toBe(false) + }) + + test("given last assistant message with question tool_use, returns true", () => { + const messages = [ + { info: { role: "user" } }, + { + info: { role: "assistant" }, + parts: [ + { type: "tool_use", name: "question" }, + ], + }, + ] + expect(hasUnansweredQuestion(messages)).toBe(true) + }) + + test("given last assistant message with question tool-invocation, returns true", () => { + const messages = [ + { info: { role: "user" } }, + { + info: { role: "assistant" }, + parts: [ + { type: "tool-invocation", toolName: "question" }, + ], + }, + ] + expect(hasUnansweredQuestion(messages)).toBe(true) + }) + + test("given user message after question (answered), returns false", () => { + const messages = [ + { + info: { role: "assistant" }, + parts: [ + { type: "tool_use", name: "question" }, + ], + }, + { info: { role: "user" } }, + ] + expect(hasUnansweredQuestion(messages)).toBe(false) + }) + + test("given assistant message with non-question tool, returns false", () => { + const messages = [ + { info: { role: "user" } }, + { + info: { role: "assistant" }, + parts: [ + { type: "tool_use", name: "bash" }, + ], + }, + ] + expect(hasUnansweredQuestion(messages)).toBe(false) + }) + + test("given assistant message with no parts, returns false", () => { + const messages = [ + { info: { role: "user" } }, + { info: { role: "assistant" } }, + ] + expect(hasUnansweredQuestion(messages)).toBe(false) + }) + + test("given role on message directly (not in info), returns true for question", () => { + const messages = [ + { role: "user" }, + { + role: "assistant", + parts: [ + { type: "tool_use", name: "question" }, + ], + }, + ] + expect(hasUnansweredQuestion(messages)).toBe(true) + }) + + test("given mixed tools including question, returns true", () => { + const messages = [ + { + info: { role: "assistant" }, + parts: [ + { type: "tool_use", name: "bash" }, + { type: "tool_use", name: "question" }, + ], + }, + ] + expect(hasUnansweredQuestion(messages)).toBe(true) + }) +}) diff --git a/src/hooks/todo-continuation-enforcer/pending-question-detection.ts b/src/hooks/todo-continuation-enforcer/pending-question-detection.ts new file mode 100644 index 000000000..fd97b6c35 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/pending-question-detection.ts @@ -0,0 +1,40 @@ +import { log } from "../../shared/logger" +import { HOOK_NAME } from "./constants" + +interface MessagePart { + type: string + name?: string + toolName?: string +} + +interface Message { + info?: { role?: string } + role?: string + parts?: MessagePart[] +} + +export function hasUnansweredQuestion(messages: Message[]): boolean { + if (!messages || messages.length === 0) return false + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + const role = msg.info?.role ?? msg.role + + if (role === "user") return false + + if (role === "assistant" && msg.parts) { + const hasQuestion = msg.parts.some( + (part) => + (part.type === "tool_use" || part.type === "tool-invocation") && + (part.name === "question" || part.toolName === "question"), + ) + if (hasQuestion) { + log(`[${HOOK_NAME}] Detected pending question tool in last assistant message`) + return true + } + return false + } + } + + return false +} diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index c5d59e149..08230f55a 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -106,6 +106,15 @@ export async function applyAgentConfig(params: { ]), ); + const disabledAgentNames = new Set( + (migratedDisabledAgents ?? []).map(a => a.toLowerCase()) + ); + + const filterDisabledAgents = (agents: Record) => + Object.fromEntries( + Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase())) + ); + const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -194,9 +203,9 @@ export async function applyAgentConfig(params: { ...Object.fromEntries( Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), ), - ...userAgents, - ...projectAgents, - ...pluginAgents, + ...filterDisabledAgents(userAgents), + ...filterDisabledAgents(projectAgents), + ...filterDisabledAgents(pluginAgents), ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -204,9 +213,9 @@ export async function applyAgentConfig(params: { } else { params.config.agent = { ...builtinAgents, - ...userAgents, - ...projectAgents, - ...pluginAgents, + ...filterDisabledAgents(userAgents), + ...filterDisabledAgents(projectAgents), + ...filterDisabledAgents(pluginAgents), ...configAgent, }; }