feat(athena): add session handoff with Question tool for Atlas/Prometheus routing

After Athena synthesizes council findings, presents user with Question tool
TUI to choose: Atlas (fix now), Prometheus (create plan), or no action.
On selection, session_handoff tool stores intent + calls updateSessionAgent(),
then agent-handoff hook fires on session.idle to switch the main session's
active agent via promptAsync with synthesis context.
This commit is contained in:
ismeth 2026-02-13 12:57:16 +01:00 committed by YeonGyu-Kim
parent fea732a6d2
commit 7a71d4fb4f
16 changed files with 282 additions and 12 deletions

View File

@ -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"])

View File

@ -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<typeof HookNameSchema>

View File

@ -0,0 +1,2 @@
export { setPendingHandoff, consumePendingHandoff, _resetForTesting } from "./state"
export type { PendingHandoff } from "./state"

View File

@ -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" })
})
})

View File

@ -0,0 +1,23 @@
export interface PendingHandoff {
agent: string
context: string
}
const pendingHandoffs = new Map<string, PendingHandoff>()
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()
}

View File

@ -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<string, unknown> } }): Promise<void> => {
if (input.event.type !== "session.idle") return
const props = input.event.properties as Record<string, unknown> | 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) })
}
},
}
}

View File

@ -0,0 +1 @@
export { createAgentHandoffHook } from "./hook"

View File

@ -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";

View File

@ -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<string, number>();

View File

@ -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<typeof createUnstableAgentBabysitter> | null
backgroundNotificationHook: ReturnType<typeof createBackgroundNotificationHook> | null
atlasHook: ReturnType<typeof createAtlasHook> | null
agentHandoffHook: ReturnType<typeof createAgentHandoffHook> | 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,
}
}

View File

@ -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,

View File

@ -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,

View File

@ -0,0 +1 @@
export { createSessionHandoffTool } from "./tools"

View File

@ -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")
})
})

View File

@ -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.`
},
})
}

View File

@ -0,0 +1,4 @@
export interface SessionHandoffArgs {
agent: string
context: string
}