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:
@@ -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"])
|
||||
|
||||
@@ -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>
|
||||
|
||||
2
src/features/agent-handoff/index.ts
Normal file
2
src/features/agent-handoff/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { setPendingHandoff, consumePendingHandoff, _resetForTesting } from "./state"
|
||||
export type { PendingHandoff } from "./state"
|
||||
50
src/features/agent-handoff/state.test.ts
Normal file
50
src/features/agent-handoff/state.test.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
23
src/features/agent-handoff/state.ts
Normal file
23
src/features/agent-handoff/state.ts
Normal 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()
|
||||
}
|
||||
37
src/hooks/agent-handoff/hook.ts
Normal file
37
src/hooks/agent-handoff/hook.ts
Normal 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) })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
1
src/hooks/agent-handoff/index.ts
Normal file
1
src/hooks/agent-handoff/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createAgentHandoffHook } from "./hook"
|
||||
@@ -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";
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
1
src/tools/session-handoff/index.ts
Normal file
1
src/tools/session-handoff/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createSessionHandoffTool } from "./tools"
|
||||
91
src/tools/session-handoff/tools.test.ts
Normal file
91
src/tools/session-handoff/tools.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
37
src/tools/session-handoff/tools.ts
Normal file
37
src/tools/session-handoff/tools.ts
Normal 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.`
|
||||
},
|
||||
})
|
||||
}
|
||||
4
src/tools/session-handoff/types.ts
Normal file
4
src/tools/session-handoff/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SessionHandoffArgs {
|
||||
agent: string
|
||||
context: string
|
||||
}
|
||||
Reference in New Issue
Block a user