diff --git a/src/agents/athena/agent.ts b/src/agents/athena/agent.ts index 665dfa899..688b9ac5c 100644 --- a/src/agents/athena/agent.ts +++ b/src/agents/athena/agent.ts @@ -80,26 +80,36 @@ Step 4: After collecting ALL council member responses via background_output, syn - Solo findings are potential false positives — flag the risk explicitly - Add your own assessment and rationale to each finding -Step 5: Present synthesized findings to the user grouped by agreement level (unanimous first, then majority, minority, solo). End with action options: "fix now" (Atlas) or "create plan" (Prometheus). +Step 5: Present synthesized findings to the user grouped by agreement level (unanimous first, then majority, minority, solo). Then use the Question tool to ask which action to take: -Step 6: Wait for explicit user confirmation before delegating. NEVER delegate without confirmation. -- Direct fixes → delegate to Atlas using the task tool (background is fine — Atlas executes autonomously) -- Planning → do NOT spawn Prometheus as a background task. Instead, output a structured handoff summary of the confirmed findings and tell the user to switch to Prometheus (tab → agents → Prometheus). Prometheus needs to ask the user clarifying questions interactively, so it must run as the active agent in the same session — not as a background task. +Question({ + questions: [{ + question: "How should we proceed with these findings?", + header: "Action", + options: [ + { label: "Fix now (Atlas)", description: "Hand off to Atlas for direct implementation" }, + { label: "Create plan (Prometheus)", description: "Hand off to Prometheus for planning and phased execution" }, + { label: "No action", description: "Review only — no delegation" } + ], + multiple: false + }] +}) -## Prometheus Handoff Format -When the user confirms planning, output: -1. A clear summary of confirmed findings for Prometheus to work with -2. The original question for context -3. Tell the user: "Switch to Prometheus to start planning. It will see this conversation and can ask you questions." +Step 6: After the user selects an action: +- **"Fix now (Atlas)"** → Call session_handoff with agent="atlas" and context containing the confirmed findings summary, the original question, and instruction to implement the fixes. +- **"Create plan (Prometheus)"** → Call session_handoff with agent="prometheus" and context containing the confirmed findings summary, the original question, and instruction to create a phased plan. +- **"No action"** → Acknowledge and end. Do not delegate. + +The session_handoff tool switches the active agent. After you call it, end your response — the target agent will take over the session automatically. ## Constraints - Use the Question tool for member selection BEFORE calling athena_council (unless user pre-specified). +- Use the Question tool for action selection AFTER synthesis (unless user already stated intent). - After athena_council, use background_output for each returned task ID before synthesizing. - Do NOT write or edit files directly. -- Do NOT delegate without explicit user confirmation. +- Do NOT delegate without explicit user confirmation via Question tool. - Do NOT ignore solo finding false-positive warnings. -- Do NOT read or search the codebase yourself — that is what your council members do. -- Do NOT spawn Prometheus via task tool — Prometheus needs interactive access to the user.` +- Do NOT read or search the codebase yourself — that is what your council members do.` export function createAthenaAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions(["write", "edit"]) diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 8a7ecfdfb..67965035b 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -49,6 +49,7 @@ export const HookNameSchema = z.enum([ "write-existing-file-guard", "anthropic-effort", "hashline-read-enhancer", + "agent-handoff", ]) export type HookName = z.infer diff --git a/src/features/agent-handoff/index.ts b/src/features/agent-handoff/index.ts new file mode 100644 index 000000000..0855ee794 --- /dev/null +++ b/src/features/agent-handoff/index.ts @@ -0,0 +1,2 @@ +export { setPendingHandoff, consumePendingHandoff, _resetForTesting } from "./state" +export type { PendingHandoff } from "./state" diff --git a/src/features/agent-handoff/state.test.ts b/src/features/agent-handoff/state.test.ts new file mode 100644 index 000000000..3a7adc36e --- /dev/null +++ b/src/features/agent-handoff/state.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect, beforeEach } from "bun:test" +import { setPendingHandoff, consumePendingHandoff, _resetForTesting } from "./state" + +describe("agent-handoff state", () => { + beforeEach(() => { + _resetForTesting() + }) + + //#given a pending handoff is set + //#when consumePendingHandoff is called + //#then it returns the handoff and removes it + test("should store and consume a pending handoff", () => { + setPendingHandoff("session-1", "atlas", "Fix these findings") + + const handoff = consumePendingHandoff("session-1") + + expect(handoff).toEqual({ agent: "atlas", context: "Fix these findings" }) + expect(consumePendingHandoff("session-1")).toBeUndefined() + }) + + //#given no pending handoff exists + //#when consumePendingHandoff is called + //#then it returns undefined + test("should return undefined when no handoff is pending", () => { + expect(consumePendingHandoff("session-1")).toBeUndefined() + }) + + //#given a pending handoff is set + //#when a new handoff is set for the same session + //#then the latest handoff wins + test("should overwrite previous handoff for same session", () => { + setPendingHandoff("session-1", "atlas", "Fix A") + setPendingHandoff("session-1", "prometheus", "Plan B") + + const handoff = consumePendingHandoff("session-1") + + expect(handoff).toEqual({ agent: "prometheus", context: "Plan B" }) + }) + + //#given handoffs for different sessions + //#when consumed separately + //#then each session gets its own handoff + test("should isolate handoffs by session", () => { + setPendingHandoff("session-1", "atlas", "Fix A") + setPendingHandoff("session-2", "prometheus", "Plan B") + + expect(consumePendingHandoff("session-1")).toEqual({ agent: "atlas", context: "Fix A" }) + expect(consumePendingHandoff("session-2")).toEqual({ agent: "prometheus", context: "Plan B" }) + }) +}) diff --git a/src/features/agent-handoff/state.ts b/src/features/agent-handoff/state.ts new file mode 100644 index 000000000..89b3c995e --- /dev/null +++ b/src/features/agent-handoff/state.ts @@ -0,0 +1,23 @@ +export interface PendingHandoff { + agent: string + context: string +} + +const pendingHandoffs = new Map() + +export function setPendingHandoff(sessionID: string, agent: string, context: string): void { + pendingHandoffs.set(sessionID, { agent, context }) +} + +export function consumePendingHandoff(sessionID: string): PendingHandoff | undefined { + const handoff = pendingHandoffs.get(sessionID) + if (handoff) { + pendingHandoffs.delete(sessionID) + } + return handoff +} + +/** @internal For testing only */ +export function _resetForTesting(): void { + pendingHandoffs.clear() +} diff --git a/src/hooks/agent-handoff/hook.ts b/src/hooks/agent-handoff/hook.ts new file mode 100644 index 000000000..8a19b3061 --- /dev/null +++ b/src/hooks/agent-handoff/hook.ts @@ -0,0 +1,37 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { consumePendingHandoff } from "../../features/agent-handoff" +import { log } from "../../shared/logger" + +const HOOK_NAME = "agent-handoff" as const + +export function createAgentHandoffHook(ctx: PluginInput) { + return { + event: async (input: { event: { type: string; properties?: Record } }): Promise => { + if (input.event.type !== "session.idle") return + + const props = input.event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const handoff = consumePendingHandoff(sessionID) + if (!handoff) return + + log(`[${HOOK_NAME}] Executing handoff to ${handoff.agent}`, { sessionID }) + + try { + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: handoff.agent, + parts: [{ type: "text", text: handoff.context }], + }, + query: { directory: ctx.directory }, + }) + + log(`[${HOOK_NAME}] Handoff to ${handoff.agent} complete`, { sessionID }) + } catch (err) { + log(`[${HOOK_NAME}] Handoff failed`, { sessionID, error: String(err) }) + } + }, + } +} diff --git a/src/hooks/agent-handoff/index.ts b/src/hooks/agent-handoff/index.ts new file mode 100644 index 000000000..e1a45bf53 --- /dev/null +++ b/src/hooks/agent-handoff/index.ts @@ -0,0 +1 @@ +export { createAgentHandoffHook } from "./hook" diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f992b0d7d..e3e1baead 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -50,3 +50,5 @@ export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallba export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery"; +export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer"; +export { createAgentHandoffHook } from "./agent-handoff"; diff --git a/src/plugin/event.ts b/src/plugin/event.ts index b8f7910a8..1dc114a79 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -156,6 +156,7 @@ export function createEventHandler(args: { await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input)); await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input)); await Promise.resolve(hooks.atlasHook?.handler?.(input)); + await Promise.resolve(hooks.agentHandoffHook?.event?.(input)); }; const recentSyntheticIdles = new Map(); diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index 96bf5de0c..d8345bd6b 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -9,6 +9,7 @@ import { createCompactionContextInjector, createCompactionTodoPreserverHook, createAtlasHook, + createAgentHandoffHook, } from "../../hooks" import { safeCreateHook } from "../../shared/safe-create-hook" import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter" @@ -21,6 +22,7 @@ export type ContinuationHooks = { unstableAgentBabysitter: ReturnType | null backgroundNotificationHook: ReturnType | null atlasHook: ReturnType | null + agentHandoffHook: ReturnType | null } type SessionRecovery = { @@ -111,6 +113,10 @@ export function createContinuationHooks(args: { })) : null + const agentHandoffHook = isHookEnabled("agent-handoff") + ? safeHook("agent-handoff", () => createAgentHandoffHook(ctx)) + : null + return { stopContinuationGuard, compactionContextInjector, @@ -119,5 +125,6 @@ export function createContinuationHooks(args: { unstableAgentBabysitter, backgroundNotificationHook, atlasHook, + agentHandoffHook, } } diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 2353f795c..033e69785 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -11,6 +11,7 @@ import { createBackgroundTools, createCallOmoAgent, createAthenaCouncilTool, + createSessionHandoffTool, createLookAt, createSkillMcpTool, createSkillTool, @@ -133,6 +134,7 @@ export function createToolRegistry(args: { ...backgroundTools, call_omo_agent: callOmoAgent, athena_council: athenaCouncilTool, + session_handoff: createSessionHandoffTool(), ...(lookAt ? { look_at: lookAt } : {}), task: delegateTask, skill_mcp: skillMcpTool, diff --git a/src/tools/index.ts b/src/tools/index.ts index 5d0bec378..8755cab11 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -38,6 +38,7 @@ export { createCallOmoAgent } from "./call-omo-agent" export { createAthenaCouncilTool } from "./athena-council" export { createLookAt } from "./look-at" export { createDelegateTask } from "./delegate-task" +export { createSessionHandoffTool } from "./session-handoff" export { createTaskCreateTool, createTaskGetTool, diff --git a/src/tools/session-handoff/index.ts b/src/tools/session-handoff/index.ts new file mode 100644 index 000000000..c77180a37 --- /dev/null +++ b/src/tools/session-handoff/index.ts @@ -0,0 +1 @@ +export { createSessionHandoffTool } from "./tools" diff --git a/src/tools/session-handoff/tools.test.ts b/src/tools/session-handoff/tools.test.ts new file mode 100644 index 000000000..c3230b44e --- /dev/null +++ b/src/tools/session-handoff/tools.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, beforeEach } from "bun:test" +import { createSessionHandoffTool } from "./tools" +import { consumePendingHandoff, _resetForTesting as resetHandoff } from "../../features/agent-handoff" +import { getSessionAgent, _resetForTesting as resetSession } from "../../features/claude-code-session-state" + +describe("session_handoff tool", () => { + const sessionID = "test-session-123" + const messageID = "msg-456" + const agent = "athena" + + const toolContext = { + sessionID, + messageID, + agent, + abort: new AbortController().signal, + } + + beforeEach(() => { + resetHandoff() + resetSession() + }) + + //#given valid atlas handoff args + //#when execute is called + //#then it stores pending handoff and updates session agent + test("should queue handoff to atlas", async () => { + const tool = createSessionHandoffTool() + const result = await tool.execute( + { agent: "atlas", context: "Fix the auth bug based on council findings" }, + toolContext + ) + + expect(result).toContain("atlas") + expect(result).toContain("Handoff queued") + + const handoff = consumePendingHandoff(sessionID) + expect(handoff).toEqual({ + agent: "atlas", + context: "Fix the auth bug based on council findings", + }) + + expect(getSessionAgent(sessionID)).toBe("atlas") + }) + + //#given valid prometheus handoff args + //#when execute is called + //#then it stores pending handoff for prometheus + test("should queue handoff to prometheus", async () => { + const tool = createSessionHandoffTool() + const result = await tool.execute( + { agent: "Prometheus", context: "Create a plan for the refactoring" }, + toolContext + ) + + expect(result).toContain("prometheus") + expect(result).toContain("Handoff queued") + + const handoff = consumePendingHandoff(sessionID) + expect(handoff?.agent).toBe("prometheus") + }) + + //#given an invalid agent name + //#when execute is called + //#then it returns an error + test("should reject invalid agent names", async () => { + const tool = createSessionHandoffTool() + const result = await tool.execute( + { agent: "librarian", context: "Some context" }, + toolContext + ) + + expect(result).toContain("Invalid handoff target") + expect(result).toContain("librarian") + expect(consumePendingHandoff(sessionID)).toBeUndefined() + }) + + //#given agent name with different casing + //#when execute is called + //#then it normalizes to lowercase + test("should handle case-insensitive agent names", async () => { + const tool = createSessionHandoffTool() + await tool.execute( + { agent: "ATLAS", context: "Fix things" }, + toolContext + ) + + const handoff = consumePendingHandoff(sessionID) + expect(handoff?.agent).toBe("atlas") + expect(getSessionAgent(sessionID)).toBe("atlas") + }) +}) diff --git a/src/tools/session-handoff/tools.ts b/src/tools/session-handoff/tools.ts new file mode 100644 index 000000000..6b29c55cb --- /dev/null +++ b/src/tools/session-handoff/tools.ts @@ -0,0 +1,37 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import { setPendingHandoff } from "../../features/agent-handoff" +import { updateSessionAgent } from "../../features/claude-code-session-state" +import type { SessionHandoffArgs } from "./types" + +const DESCRIPTION = + "Switch the active session agent. After calling this tool, the session will transition to the specified agent " + + "with the provided context as its starting prompt. Use this to hand off work to another agent " + + "(e.g., Atlas for fixes, Prometheus for planning). The handoff executes when the current agent's turn completes." + +const ALLOWED_AGENTS = new Set(["atlas", "prometheus", "sisyphus", "hephaestus"]) + +export function createSessionHandoffTool(): ToolDefinition { + return tool({ + description: DESCRIPTION, + args: { + agent: tool.schema + .string() + .describe("Target agent name to hand off to (e.g., 'atlas', 'prometheus')"), + context: tool.schema + .string() + .describe("Context message for the target agent — include confirmed findings, the original question, and what action to take"), + }, + async execute(args: SessionHandoffArgs, toolContext) { + const agentName = args.agent.toLowerCase() + + if (!ALLOWED_AGENTS.has(agentName)) { + return `Invalid handoff target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}` + } + + updateSessionAgent(toolContext.sessionID, agentName) + setPendingHandoff(toolContext.sessionID, agentName, args.context) + + return `Handoff queued. Session will switch to ${agentName} when your turn completes.` + }, + }) +} diff --git a/src/tools/session-handoff/types.ts b/src/tools/session-handoff/types.ts new file mode 100644 index 000000000..2a977adf3 --- /dev/null +++ b/src/tools/session-handoff/types.ts @@ -0,0 +1,4 @@ +export interface SessionHandoffArgs { + agent: string + context: string +}