From 77034fec7e012cb041a983a2f2c6dde0859b42ce Mon Sep 17 00:00:00 2001 From: ismeth Date: Fri, 20 Feb 2026 19:12:10 +0100 Subject: [PATCH] refactor(agent-switch): remove Athena-specific NLP fallback from hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/agent-switch/fallback-handoff.ts | 56 -------------- src/hooks/agent-switch/hook.test.ts | 50 ------------- src/hooks/agent-switch/hook.ts | 85 +--------------------- 3 files changed, 3 insertions(+), 188 deletions(-) diff --git a/src/hooks/agent-switch/fallback-handoff.ts b/src/hooks/agent-switch/fallback-handoff.ts index 51f88e382..926e7845d 100644 --- a/src/hooks/agent-switch/fallback-handoff.ts +++ b/src/hooks/agent-switch/fallback-handoff.ts @@ -33,60 +33,4 @@ export function isTerminalStepFinishPart(part: unknown): boolean { return isTerminalFinishValue(record.reason) } -export function extractTextPartsFromMessageResponse(response: unknown): string { - if (typeof response !== "object" || response === null) return "" - const data = (response as Record).data - if (typeof data !== "object" || data === null) return "" - const parts = (data as Record).parts - if (!Array.isArray(parts)) return "" - return parts - .map((part) => { - if (typeof part !== "object" || part === null) return "" - const partRecord = part as Record - 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( - `(? { expect(getPendingSwitch("ses-11")).toBeUndefined() }) - test("recovers missing switch_agent tool call from Athena handoff text", async () => { - const promptAsyncCalls: Array> = [] - let switched = false - const ctx = { - client: { - session: { - promptAsync: async (args: Record) => { - 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 () => { const promptAsyncCalls: Array> = [] let switched = false diff --git a/src/hooks/agent-switch/hook.ts b/src/hooks/agent-switch/hook.ts index bd2eff809..f7dcc1813 100644 --- a/src/hooks/agent-switch/hook.ts +++ b/src/hooks/agent-switch/hook.ts @@ -1,28 +1,11 @@ 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 { getAgentConfigKey } from "../../shared/agent-display-names" -import { log } from "../../shared/logger" import { - buildFallbackContext, - detectFallbackHandoffTarget, - extractTextPartsFromMessageResponse, isTerminalFinishValue, isTerminalStepFinishPart, } from "./fallback-handoff" -const processedFallbackMessages = new Set() -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 | undefined { const props = input.event.properties as Record | 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 | undefined const deletedSessionID = info?.id if (typeof deletedSessionID === "string") { - clearFallbackMarkersForSession(deletedSessionID) + clearPendingSwitchRuntime(deletedSessionID) } return } @@ -65,7 +48,7 @@ export function createAgentSwitchHook(ctx: PluginInput) { const info = props?.info as Record | undefined const erroredSessionID = info?.id ?? props?.sessionID if (typeof erroredSessionID === "string") { - clearFallbackMarkersForSession(erroredSessionID) + clearPendingSwitchRuntime(erroredSessionID) } return } @@ -74,8 +57,6 @@ export function createAgentSwitchHook(ctx: PluginInput) { const props = input.event.properties as Record | undefined const info = props?.info as Record | 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 if (!sessionID) { @@ -95,66 +76,6 @@ export function createAgentSwitchHook(ctx: PluginInput) { client: ctx.client, 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