refactor(athena): rename session_handoff to switch_agent to avoid confusion with /handoff command
Rename across all layers to eliminate naming ambiguity: - Tool: session_handoff → switch_agent - Hook: agent-handoff → agent-switch - Feature: agent-handoff/ → agent-switch/ - Types: SessionHandoffArgs → SwitchAgentArgs, PendingHandoff → PendingSwitch - Functions: setPendingHandoff → setPendingSwitch, consumePendingHandoff → consumePendingSwitch /handoff = inter-session context summary (existing command) switch_agent = intra-session active agent change (our new tool)
This commit is contained in:
parent
7a71d4fb4f
commit
5a72f21fc8
@ -96,11 +96,11 @@ Question({
|
|||||||
})
|
})
|
||||||
|
|
||||||
Step 6: After the user selects an action:
|
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.
|
- **"Fix now (Atlas)"** → Call switch_agent 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.
|
- **"Create plan (Prometheus)"** → Call switch_agent 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.
|
- **"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.
|
The switch_agent tool switches the active agent. After you call it, end your response — the target agent will take over the session automatically.
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
- Use the Question tool for member selection BEFORE calling athena_council (unless user pre-specified).
|
- Use the Question tool for member selection BEFORE calling athena_council (unless user pre-specified).
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export const HookNameSchema = z.enum([
|
|||||||
"write-existing-file-guard",
|
"write-existing-file-guard",
|
||||||
"anthropic-effort",
|
"anthropic-effort",
|
||||||
"hashline-read-enhancer",
|
"hashline-read-enhancer",
|
||||||
"agent-handoff",
|
"agent-switch",
|
||||||
])
|
])
|
||||||
|
|
||||||
export type HookName = z.infer<typeof HookNameSchema>
|
export type HookName = z.infer<typeof HookNameSchema>
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export { setPendingHandoff, consumePendingHandoff, _resetForTesting } from "./state"
|
|
||||||
export type { PendingHandoff } from "./state"
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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" })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
2
src/features/agent-switch/index.ts
Normal file
2
src/features/agent-switch/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { setPendingSwitch, consumePendingSwitch, _resetForTesting } from "./state"
|
||||||
|
export type { PendingSwitch } from "./state"
|
||||||
50
src/features/agent-switch/state.test.ts
Normal file
50
src/features/agent-switch/state.test.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from "bun:test"
|
||||||
|
import { setPendingSwitch, consumePendingSwitch, _resetForTesting } from "./state"
|
||||||
|
|
||||||
|
describe("agent-switch state", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
//#given a pending switch is set
|
||||||
|
//#when consumePendingSwitch is called
|
||||||
|
//#then it returns the switch and removes it
|
||||||
|
test("should store and consume a pending switch", () => {
|
||||||
|
setPendingSwitch("session-1", "atlas", "Fix these findings")
|
||||||
|
|
||||||
|
const entry = consumePendingSwitch("session-1")
|
||||||
|
|
||||||
|
expect(entry).toEqual({ agent: "atlas", context: "Fix these findings" })
|
||||||
|
expect(consumePendingSwitch("session-1")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
//#given no pending switch exists
|
||||||
|
//#when consumePendingSwitch is called
|
||||||
|
//#then it returns undefined
|
||||||
|
test("should return undefined when no switch is pending", () => {
|
||||||
|
expect(consumePendingSwitch("session-1")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
//#given a pending switch is set
|
||||||
|
//#when a new switch is set for the same session
|
||||||
|
//#then the latest switch wins
|
||||||
|
test("should overwrite previous switch for same session", () => {
|
||||||
|
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||||
|
setPendingSwitch("session-1", "prometheus", "Plan B")
|
||||||
|
|
||||||
|
const entry = consumePendingSwitch("session-1")
|
||||||
|
|
||||||
|
expect(entry).toEqual({ agent: "prometheus", context: "Plan B" })
|
||||||
|
})
|
||||||
|
|
||||||
|
//#given switches for different sessions
|
||||||
|
//#when consumed separately
|
||||||
|
//#then each session gets its own switch
|
||||||
|
test("should isolate switches by session", () => {
|
||||||
|
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||||
|
setPendingSwitch("session-2", "prometheus", "Plan B")
|
||||||
|
|
||||||
|
expect(consumePendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
|
||||||
|
expect(consumePendingSwitch("session-2")).toEqual({ agent: "prometheus", context: "Plan B" })
|
||||||
|
})
|
||||||
|
})
|
||||||
23
src/features/agent-switch/state.ts
Normal file
23
src/features/agent-switch/state.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface PendingSwitch {
|
||||||
|
agent: string
|
||||||
|
context: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSwitches = new Map<string, PendingSwitch>()
|
||||||
|
|
||||||
|
export function setPendingSwitch(sessionID: string, agent: string, context: string): void {
|
||||||
|
pendingSwitches.set(sessionID, { agent, context })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePendingSwitch(sessionID: string): PendingSwitch | undefined {
|
||||||
|
const entry = pendingSwitches.get(sessionID)
|
||||||
|
if (entry) {
|
||||||
|
pendingSwitches.delete(sessionID)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal For testing only */
|
||||||
|
export function _resetForTesting(): void {
|
||||||
|
pendingSwitches.clear()
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
export { createAgentHandoffHook } from "./hook"
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { consumePendingHandoff } from "../../features/agent-handoff"
|
import { consumePendingSwitch } from "../../features/agent-switch"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
const HOOK_NAME = "agent-handoff" as const
|
const HOOK_NAME = "agent-switch" as const
|
||||||
|
|
||||||
export function createAgentHandoffHook(ctx: PluginInput) {
|
export function createAgentSwitchHook(ctx: PluginInput) {
|
||||||
return {
|
return {
|
||||||
event: async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
|
event: async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
|
||||||
if (input.event.type !== "session.idle") return
|
if (input.event.type !== "session.idle") return
|
||||||
@ -13,24 +13,24 @@ export function createAgentHandoffHook(ctx: PluginInput) {
|
|||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const handoff = consumePendingHandoff(sessionID)
|
const pending = consumePendingSwitch(sessionID)
|
||||||
if (!handoff) return
|
if (!pending) return
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Executing handoff to ${handoff.agent}`, { sessionID })
|
log(`[${HOOK_NAME}] Switching to ${pending.agent}`, { sessionID })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ctx.client.session.promptAsync({
|
await ctx.client.session.promptAsync({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
agent: handoff.agent,
|
agent: pending.agent,
|
||||||
parts: [{ type: "text", text: handoff.context }],
|
parts: [{ type: "text", text: pending.context }],
|
||||||
},
|
},
|
||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Handoff to ${handoff.agent} complete`, { sessionID })
|
log(`[${HOOK_NAME}] Switch to ${pending.agent} complete`, { sessionID })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`[${HOOK_NAME}] Handoff failed`, { sessionID, error: String(err) })
|
log(`[${HOOK_NAME}] Switch failed`, { sessionID, error: String(err) })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
1
src/hooks/agent-switch/index.ts
Normal file
1
src/hooks/agent-switch/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { createAgentSwitchHook } from "./hook"
|
||||||
@ -51,4 +51,4 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
|||||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
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 { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
|
||||||
export { createAgentHandoffHook } from "./agent-handoff";
|
export { createAgentSwitchHook } from "./agent-switch";
|
||||||
|
|||||||
@ -156,7 +156,7 @@ export function createEventHandler(args: {
|
|||||||
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
|
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
|
||||||
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
|
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
|
||||||
await Promise.resolve(hooks.atlasHook?.handler?.(input));
|
await Promise.resolve(hooks.atlasHook?.handler?.(input));
|
||||||
await Promise.resolve(hooks.agentHandoffHook?.event?.(input));
|
await Promise.resolve(hooks.agentSwitchHook?.event?.(input));
|
||||||
};
|
};
|
||||||
|
|
||||||
const recentSyntheticIdles = new Map<string, number>();
|
const recentSyntheticIdles = new Map<string, number>();
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
createCompactionContextInjector,
|
createCompactionContextInjector,
|
||||||
createCompactionTodoPreserverHook,
|
createCompactionTodoPreserverHook,
|
||||||
createAtlasHook,
|
createAtlasHook,
|
||||||
createAgentHandoffHook,
|
createAgentSwitchHook,
|
||||||
} from "../../hooks"
|
} from "../../hooks"
|
||||||
import { safeCreateHook } from "../../shared/safe-create-hook"
|
import { safeCreateHook } from "../../shared/safe-create-hook"
|
||||||
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
||||||
@ -22,7 +22,7 @@ export type ContinuationHooks = {
|
|||||||
unstableAgentBabysitter: ReturnType<typeof createUnstableAgentBabysitter> | null
|
unstableAgentBabysitter: ReturnType<typeof createUnstableAgentBabysitter> | null
|
||||||
backgroundNotificationHook: ReturnType<typeof createBackgroundNotificationHook> | null
|
backgroundNotificationHook: ReturnType<typeof createBackgroundNotificationHook> | null
|
||||||
atlasHook: ReturnType<typeof createAtlasHook> | null
|
atlasHook: ReturnType<typeof createAtlasHook> | null
|
||||||
agentHandoffHook: ReturnType<typeof createAgentHandoffHook> | null
|
agentSwitchHook: ReturnType<typeof createAgentSwitchHook> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionRecovery = {
|
type SessionRecovery = {
|
||||||
@ -113,8 +113,8 @@ export function createContinuationHooks(args: {
|
|||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const agentHandoffHook = isHookEnabled("agent-handoff")
|
const agentSwitchHook = isHookEnabled("agent-switch")
|
||||||
? safeHook("agent-handoff", () => createAgentHandoffHook(ctx))
|
? safeHook("agent-switch", () => createAgentSwitchHook(ctx))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -125,6 +125,6 @@ export function createContinuationHooks(args: {
|
|||||||
unstableAgentBabysitter,
|
unstableAgentBabysitter,
|
||||||
backgroundNotificationHook,
|
backgroundNotificationHook,
|
||||||
atlasHook,
|
atlasHook,
|
||||||
agentHandoffHook,
|
agentSwitchHook,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
createBackgroundTools,
|
createBackgroundTools,
|
||||||
createCallOmoAgent,
|
createCallOmoAgent,
|
||||||
createAthenaCouncilTool,
|
createAthenaCouncilTool,
|
||||||
createSessionHandoffTool,
|
createSwitchAgentTool,
|
||||||
createLookAt,
|
createLookAt,
|
||||||
createSkillMcpTool,
|
createSkillMcpTool,
|
||||||
createSkillTool,
|
createSkillTool,
|
||||||
@ -134,7 +134,7 @@ export function createToolRegistry(args: {
|
|||||||
...backgroundTools,
|
...backgroundTools,
|
||||||
call_omo_agent: callOmoAgent,
|
call_omo_agent: callOmoAgent,
|
||||||
athena_council: athenaCouncilTool,
|
athena_council: athenaCouncilTool,
|
||||||
session_handoff: createSessionHandoffTool(),
|
switch_agent: createSwitchAgentTool(),
|
||||||
...(lookAt ? { look_at: lookAt } : {}),
|
...(lookAt ? { look_at: lookAt } : {}),
|
||||||
task: delegateTask,
|
task: delegateTask,
|
||||||
skill_mcp: skillMcpTool,
|
skill_mcp: skillMcpTool,
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export { createCallOmoAgent } from "./call-omo-agent"
|
|||||||
export { createAthenaCouncilTool } from "./athena-council"
|
export { createAthenaCouncilTool } from "./athena-council"
|
||||||
export { createLookAt } from "./look-at"
|
export { createLookAt } from "./look-at"
|
||||||
export { createDelegateTask } from "./delegate-task"
|
export { createDelegateTask } from "./delegate-task"
|
||||||
export { createSessionHandoffTool } from "./session-handoff"
|
export { createSwitchAgentTool } from "./switch-agent"
|
||||||
export {
|
export {
|
||||||
createTaskCreateTool,
|
createTaskCreateTool,
|
||||||
createTaskGetTool,
|
createTaskGetTool,
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export { createSessionHandoffTool } from "./tools"
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export interface SessionHandoffArgs {
|
|
||||||
agent: string
|
|
||||||
context: string
|
|
||||||
}
|
|
||||||
1
src/tools/switch-agent/index.ts
Normal file
1
src/tools/switch-agent/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { createSwitchAgentTool } from "./tools"
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { describe, test, expect, beforeEach } from "bun:test"
|
import { describe, test, expect, beforeEach } from "bun:test"
|
||||||
import { createSessionHandoffTool } from "./tools"
|
import { createSwitchAgentTool } from "./tools"
|
||||||
import { consumePendingHandoff, _resetForTesting as resetHandoff } from "../../features/agent-handoff"
|
import { consumePendingSwitch, _resetForTesting as resetSwitch } from "../../features/agent-switch"
|
||||||
import { getSessionAgent, _resetForTesting as resetSession } from "../../features/claude-code-session-state"
|
import { getSessionAgent, _resetForTesting as resetSession } from "../../features/claude-code-session-state"
|
||||||
|
|
||||||
describe("session_handoff tool", () => {
|
describe("switch_agent tool", () => {
|
||||||
const sessionID = "test-session-123"
|
const sessionID = "test-session-123"
|
||||||
const messageID = "msg-456"
|
const messageID = "msg-456"
|
||||||
const agent = "athena"
|
const agent = "athena"
|
||||||
@ -16,25 +16,25 @@ describe("session_handoff tool", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHandoff()
|
resetSwitch()
|
||||||
resetSession()
|
resetSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
//#given valid atlas handoff args
|
//#given valid atlas switch args
|
||||||
//#when execute is called
|
//#when execute is called
|
||||||
//#then it stores pending handoff and updates session agent
|
//#then it stores pending switch and updates session agent
|
||||||
test("should queue handoff to atlas", async () => {
|
test("should queue switch to atlas", async () => {
|
||||||
const tool = createSessionHandoffTool()
|
const tool = createSwitchAgentTool()
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
{ agent: "atlas", context: "Fix the auth bug based on council findings" },
|
{ agent: "atlas", context: "Fix the auth bug based on council findings" },
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("atlas")
|
expect(result).toContain("atlas")
|
||||||
expect(result).toContain("Handoff queued")
|
expect(result).toContain("switch")
|
||||||
|
|
||||||
const handoff = consumePendingHandoff(sessionID)
|
const entry = consumePendingSwitch(sessionID)
|
||||||
expect(handoff).toEqual({
|
expect(entry).toEqual({
|
||||||
agent: "atlas",
|
agent: "atlas",
|
||||||
context: "Fix the auth bug based on council findings",
|
context: "Fix the auth bug based on council findings",
|
||||||
})
|
})
|
||||||
@ -42,50 +42,50 @@ describe("session_handoff tool", () => {
|
|||||||
expect(getSessionAgent(sessionID)).toBe("atlas")
|
expect(getSessionAgent(sessionID)).toBe("atlas")
|
||||||
})
|
})
|
||||||
|
|
||||||
//#given valid prometheus handoff args
|
//#given valid prometheus switch args
|
||||||
//#when execute is called
|
//#when execute is called
|
||||||
//#then it stores pending handoff for prometheus
|
//#then it stores pending switch for prometheus
|
||||||
test("should queue handoff to prometheus", async () => {
|
test("should queue switch to prometheus", async () => {
|
||||||
const tool = createSessionHandoffTool()
|
const tool = createSwitchAgentTool()
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
{ agent: "Prometheus", context: "Create a plan for the refactoring" },
|
{ agent: "Prometheus", context: "Create a plan for the refactoring" },
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("prometheus")
|
expect(result).toContain("prometheus")
|
||||||
expect(result).toContain("Handoff queued")
|
expect(result).toContain("switch")
|
||||||
|
|
||||||
const handoff = consumePendingHandoff(sessionID)
|
const entry = consumePendingSwitch(sessionID)
|
||||||
expect(handoff?.agent).toBe("prometheus")
|
expect(entry?.agent).toBe("prometheus")
|
||||||
})
|
})
|
||||||
|
|
||||||
//#given an invalid agent name
|
//#given an invalid agent name
|
||||||
//#when execute is called
|
//#when execute is called
|
||||||
//#then it returns an error
|
//#then it returns an error
|
||||||
test("should reject invalid agent names", async () => {
|
test("should reject invalid agent names", async () => {
|
||||||
const tool = createSessionHandoffTool()
|
const tool = createSwitchAgentTool()
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
{ agent: "librarian", context: "Some context" },
|
{ agent: "librarian", context: "Some context" },
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toContain("Invalid handoff target")
|
expect(result).toContain("Invalid switch target")
|
||||||
expect(result).toContain("librarian")
|
expect(result).toContain("librarian")
|
||||||
expect(consumePendingHandoff(sessionID)).toBeUndefined()
|
expect(consumePendingSwitch(sessionID)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
//#given agent name with different casing
|
//#given agent name with different casing
|
||||||
//#when execute is called
|
//#when execute is called
|
||||||
//#then it normalizes to lowercase
|
//#then it normalizes to lowercase
|
||||||
test("should handle case-insensitive agent names", async () => {
|
test("should handle case-insensitive agent names", async () => {
|
||||||
const tool = createSessionHandoffTool()
|
const tool = createSwitchAgentTool()
|
||||||
await tool.execute(
|
await tool.execute(
|
||||||
{ agent: "ATLAS", context: "Fix things" },
|
{ agent: "ATLAS", context: "Fix things" },
|
||||||
toolContext
|
toolContext
|
||||||
)
|
)
|
||||||
|
|
||||||
const handoff = consumePendingHandoff(sessionID)
|
const entry = consumePendingSwitch(sessionID)
|
||||||
expect(handoff?.agent).toBe("atlas")
|
expect(entry?.agent).toBe("atlas")
|
||||||
expect(getSessionAgent(sessionID)).toBe("atlas")
|
expect(getSessionAgent(sessionID)).toBe("atlas")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1,37 +1,37 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import { setPendingHandoff } from "../../features/agent-handoff"
|
import { setPendingSwitch } from "../../features/agent-switch"
|
||||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import type { SessionHandoffArgs } from "./types"
|
import type { SwitchAgentArgs } from "./types"
|
||||||
|
|
||||||
const DESCRIPTION =
|
const DESCRIPTION =
|
||||||
"Switch the active session agent. After calling this tool, the session will transition to the specified agent " +
|
"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 " +
|
"with the provided context as its starting prompt. Use this to route work to another agent " +
|
||||||
"(e.g., Atlas for fixes, Prometheus for planning). The handoff executes when the current agent's turn completes."
|
"(e.g., Atlas for fixes, Prometheus for planning). The switch executes when the current agent's turn completes."
|
||||||
|
|
||||||
const ALLOWED_AGENTS = new Set(["atlas", "prometheus", "sisyphus", "hephaestus"])
|
const ALLOWED_AGENTS = new Set(["atlas", "prometheus", "sisyphus", "hephaestus"])
|
||||||
|
|
||||||
export function createSessionHandoffTool(): ToolDefinition {
|
export function createSwitchAgentTool(): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
args: {
|
args: {
|
||||||
agent: tool.schema
|
agent: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe("Target agent name to hand off to (e.g., 'atlas', 'prometheus')"),
|
.describe("Target agent name to switch to (e.g., 'atlas', 'prometheus')"),
|
||||||
context: tool.schema
|
context: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe("Context message for the target agent — include confirmed findings, the original question, and what action to take"),
|
.describe("Context message for the target agent — include confirmed findings, the original question, and what action to take"),
|
||||||
},
|
},
|
||||||
async execute(args: SessionHandoffArgs, toolContext) {
|
async execute(args: SwitchAgentArgs, toolContext) {
|
||||||
const agentName = args.agent.toLowerCase()
|
const agentName = args.agent.toLowerCase()
|
||||||
|
|
||||||
if (!ALLOWED_AGENTS.has(agentName)) {
|
if (!ALLOWED_AGENTS.has(agentName)) {
|
||||||
return `Invalid handoff target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}`
|
return `Invalid switch target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSessionAgent(toolContext.sessionID, agentName)
|
updateSessionAgent(toolContext.sessionID, agentName)
|
||||||
setPendingHandoff(toolContext.sessionID, agentName, args.context)
|
setPendingSwitch(toolContext.sessionID, agentName, args.context)
|
||||||
|
|
||||||
return `Handoff queued. Session will switch to ${agentName} when your turn completes.`
|
return `Agent switch queued. Session will switch to ${agentName} when your turn completes.`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
4
src/tools/switch-agent/types.ts
Normal file
4
src/tools/switch-agent/types.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface SwitchAgentArgs {
|
||||||
|
agent: string
|
||||||
|
context: string
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user