refactor(agent-switch): remove Athena-specific NLP fallback from hook
The fallback scanned Athena's message text for natural-language handoff
phrases ("switching to Atlas", etc.) and synthetically created a pending
switch when the switch_agent tool wasn't called. In practice this path
never fired in real sessions — Athena always correctly called the tool.
Removes ~135 lines of Athena-coupled code, keeping the generic
switch_agent → apply path fully intact.
This commit is contained in:
parent
11a4d457bf
commit
77034fec7e
@ -33,60 +33,4 @@ export function isTerminalStepFinishPart(part: unknown): boolean {
|
|||||||
return isTerminalFinishValue(record.reason)
|
return isTerminalFinishValue(record.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractTextPartsFromMessageResponse(response: unknown): string {
|
|
||||||
if (typeof response !== "object" || response === null) return ""
|
|
||||||
const data = (response as Record<string, unknown>).data
|
|
||||||
if (typeof data !== "object" || data === null) return ""
|
|
||||||
const parts = (data as Record<string, unknown>).parts
|
|
||||||
if (!Array.isArray(parts)) return ""
|
|
||||||
|
|
||||||
return parts
|
|
||||||
.map((part) => {
|
|
||||||
if (typeof part !== "object" || part === null) return ""
|
|
||||||
const partRecord = part as Record<string, unknown>
|
|
||||||
if (partRecord.type !== "text") return ""
|
|
||||||
return typeof partRecord.text === "string" ? partRecord.text : ""
|
|
||||||
})
|
|
||||||
.filter((text) => text.length > 0)
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
const HANDOFF_TARGETS = ["prometheus", "atlas"] as const
|
|
||||||
type HandoffTarget = (typeof HANDOFF_TARGETS)[number]
|
|
||||||
|
|
||||||
const HANDOFF_VERBS = [
|
|
||||||
"switching",
|
|
||||||
"handing\\s+off",
|
|
||||||
"delegating",
|
|
||||||
"routing",
|
|
||||||
"transferring",
|
|
||||||
"passing",
|
|
||||||
]
|
|
||||||
|
|
||||||
function buildHandoffPattern(target: string): RegExp {
|
|
||||||
const verbGroup = HANDOFF_VERBS.join("|")
|
|
||||||
return new RegExp(
|
|
||||||
`(?<!\\bnot\\s+)(?<!\\bdon'?t\\s+)(?<!\\bnever\\s+)(?:${verbGroup})\\s+(?:(?:control|this|it|work)\\s+)?to\\s+\\*{0,2}\\s*${target}\\b`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectFallbackHandoffTarget(messageText: string): HandoffTarget | undefined {
|
|
||||||
if (!messageText) return undefined
|
|
||||||
|
|
||||||
const normalized = messageText.toLowerCase()
|
|
||||||
|
|
||||||
for (const target of HANDOFF_TARGETS) {
|
|
||||||
if (buildHandoffPattern(target).test(normalized)) {
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFallbackContext(target: "atlas" | "prometheus"): string {
|
|
||||||
if (target === "prometheus") {
|
|
||||||
return "Athena indicated handoff to Prometheus. Continue from the current session context and produce the requested phased plan based on the council findings already gathered."
|
|
||||||
}
|
|
||||||
return "Athena indicated handoff to Atlas. Continue from the current session context and implement the agreed fixes from the council findings."
|
|
||||||
}
|
|
||||||
|
|||||||
@ -187,56 +187,6 @@ describe("agent-switch hook", () => {
|
|||||||
expect(getPendingSwitch("ses-11")).toBeUndefined()
|
expect(getPendingSwitch("ses-11")).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("recovers missing switch_agent tool call from Athena handoff text", async () => {
|
|
||||||
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
|
||||||
let switched = false
|
|
||||||
const ctx = {
|
|
||||||
client: {
|
|
||||||
session: {
|
|
||||||
promptAsync: async (args: Record<string, unknown>) => {
|
|
||||||
promptAsyncCalls.push(args)
|
|
||||||
switched = true
|
|
||||||
},
|
|
||||||
messages: async () => switched
|
|
||||||
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
|
|
||||||
: ({ data: [] }),
|
|
||||||
message: async () => ({
|
|
||||||
data: {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Switching to **Prometheus** now — they'll take it from here and craft a plan for you!",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any
|
|
||||||
|
|
||||||
const hook = createAgentSwitchHook(ctx)
|
|
||||||
|
|
||||||
await hook.event({
|
|
||||||
event: {
|
|
||||||
type: "message.updated",
|
|
||||||
properties: {
|
|
||||||
info: {
|
|
||||||
id: "msg-athena-1",
|
|
||||||
sessionID: "ses-5",
|
|
||||||
role: "assistant",
|
|
||||||
agent: "Athena (Council)",
|
|
||||||
finish: "stop",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(promptAsyncCalls).toHaveLength(1)
|
|
||||||
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
|
|
||||||
expect(body?.agent).toBe("Prometheus (Plan Builder)")
|
|
||||||
expect(getPendingSwitch("ses-5")).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("applies queued pending switch on terminal message.updated", async () => {
|
test("applies queued pending switch on terminal message.updated", async () => {
|
||||||
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
const promptAsyncCalls: Array<Record<string, unknown>> = []
|
||||||
let switched = false
|
let switched = false
|
||||||
|
|||||||
@ -1,28 +1,11 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { getPendingSwitch, setPendingSwitch } from "../../features/agent-switch"
|
import { getPendingSwitch } from "../../features/agent-switch"
|
||||||
import { applyPendingSwitch, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
|
import { applyPendingSwitch, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
|
||||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import {
|
import {
|
||||||
buildFallbackContext,
|
|
||||||
detectFallbackHandoffTarget,
|
|
||||||
extractTextPartsFromMessageResponse,
|
|
||||||
isTerminalFinishValue,
|
isTerminalFinishValue,
|
||||||
isTerminalStepFinishPart,
|
isTerminalStepFinishPart,
|
||||||
} from "./fallback-handoff"
|
} from "./fallback-handoff"
|
||||||
|
|
||||||
const processedFallbackMessages = new Set<string>()
|
|
||||||
const MAX_PROCESSED_FALLBACK_MARKERS = 500
|
|
||||||
|
|
||||||
function clearFallbackMarkersForSession(sessionID: string): void {
|
|
||||||
clearPendingSwitchRuntime(sessionID)
|
|
||||||
for (const key of Array.from(processedFallbackMessages)) {
|
|
||||||
if (key.startsWith(`${sessionID}:`)) {
|
|
||||||
processedFallbackMessages.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionIDFromStatusEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
|
function getSessionIDFromStatusEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
|
||||||
const props = input.event.properties as Record<string, unknown> | undefined
|
const props = input.event.properties as Record<string, unknown> | undefined
|
||||||
const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||||
@ -55,7 +38,7 @@ export function createAgentSwitchHook(ctx: PluginInput) {
|
|||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const deletedSessionID = info?.id
|
const deletedSessionID = info?.id
|
||||||
if (typeof deletedSessionID === "string") {
|
if (typeof deletedSessionID === "string") {
|
||||||
clearFallbackMarkersForSession(deletedSessionID)
|
clearPendingSwitchRuntime(deletedSessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -65,7 +48,7 @@ export function createAgentSwitchHook(ctx: PluginInput) {
|
|||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const erroredSessionID = info?.id ?? props?.sessionID
|
const erroredSessionID = info?.id ?? props?.sessionID
|
||||||
if (typeof erroredSessionID === "string") {
|
if (typeof erroredSessionID === "string") {
|
||||||
clearFallbackMarkersForSession(erroredSessionID)
|
clearPendingSwitchRuntime(erroredSessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -74,8 +57,6 @@ export function createAgentSwitchHook(ctx: PluginInput) {
|
|||||||
const props = input.event.properties as Record<string, unknown> | undefined
|
const props = input.event.properties as Record<string, unknown> | undefined
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
|
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
|
||||||
const messageID = typeof info?.id === "string" ? info.id : undefined
|
|
||||||
const agent = typeof info?.agent === "string" ? info.agent : undefined
|
|
||||||
const finish = info?.finish
|
const finish = info?.finish
|
||||||
|
|
||||||
if (!sessionID) {
|
if (!sessionID) {
|
||||||
@ -95,66 +76,6 @@ export function createAgentSwitchHook(ctx: PluginInput) {
|
|||||||
client: ctx.client,
|
client: ctx.client,
|
||||||
source: "message-updated",
|
source: "message-updated",
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!messageID) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getAgentConfigKey(agent ?? "") !== "athena") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = `${sessionID}:${messageID}`
|
|
||||||
if (processedFallbackMessages.has(marker)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
processedFallbackMessages.add(marker)
|
|
||||||
|
|
||||||
// Prevent unbounded growth of the Set
|
|
||||||
if (processedFallbackMessages.size >= MAX_PROCESSED_FALLBACK_MARKERS) {
|
|
||||||
const iterator = processedFallbackMessages.values()
|
|
||||||
const oldest = iterator.next().value
|
|
||||||
if (oldest) {
|
|
||||||
processedFallbackMessages.delete(oldest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If switch_agent already queued a handoff, do not synthesize fallback behavior.
|
|
||||||
if (getPendingSwitch(sessionID)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await ctx.client.session.message({
|
|
||||||
path: { id: sessionID, messageID },
|
|
||||||
})
|
|
||||||
const text = extractTextPartsFromMessageResponse(response)
|
|
||||||
const target = detectFallbackHandoffTarget(text)
|
|
||||||
if (!target) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingSwitch(sessionID, target, buildFallbackContext(target))
|
|
||||||
log("[agent-switch] Recovered missing switch_agent tool call from Athena handoff text", {
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
target,
|
|
||||||
})
|
|
||||||
|
|
||||||
await applyPendingSwitch({
|
|
||||||
sessionID,
|
|
||||||
client: ctx.client,
|
|
||||||
source: "athena-message-fallback",
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
processedFallbackMessages.delete(marker)
|
|
||||||
log("[agent-switch] Failed to recover fallback handoff from Athena message", {
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
error: String(error),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user