feat: add agent fallback and preemptive-compaction restoration

- Add agent visibility fallback for first-run scenarios
- Restore preemptive-compaction hook
- Update migration and schema for preemptive-compaction restoration
This commit is contained in:
YeonGyu-Kim
2026-02-02 22:40:59 +09:00
parent 1bba54c7f6
commit 39dc2ff303
10 changed files with 259 additions and 14 deletions
+1
View File
@@ -36,3 +36,4 @@ export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
export { createCompactionContextInjector, type SummarizeContext } from "./compaction-context-injector";
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, mock, test } from "bun:test"
import { createPreemptiveCompactionHook } from "./preemptive-compaction.ts"
describe("preemptive-compaction", () => {
const sessionID = "preemptive-compaction-session"
function createMockCtx(overrides?: {
messages?: ReturnType<typeof mock>
summarize?: ReturnType<typeof mock>
}) {
const messages = overrides?.messages ?? mock(() => Promise.resolve({ data: [] }))
const summarize = overrides?.summarize ?? mock(() => Promise.resolve())
return {
client: {
session: {
messages,
summarize,
},
tui: {
showToast: mock(() => Promise.resolve()),
},
},
directory: "/tmp/test",
} as never
}
test("triggers summarize when usage exceeds threshold", async () => {
// #given
const messages = mock(() =>
Promise.resolve({
data: [
{
info: {
role: "assistant",
providerID: "anthropic",
modelID: "claude-opus-4-5",
tokens: {
input: 180000,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
},
],
})
)
const summarize = mock(() => Promise.resolve())
const hook = createPreemptiveCompactionHook(createMockCtx({ messages, summarize }))
const output = { title: "", output: "", metadata: {} }
// #when
await hook["tool.execute.after"](
{ tool: "Read", sessionID, callID: "call-1" },
output
)
// #then
expect(summarize).toHaveBeenCalled()
})
test("does not summarize when usage is below threshold", async () => {
// #given
const messages = mock(() =>
Promise.resolve({
data: [
{
info: {
role: "assistant",
providerID: "anthropic",
modelID: "claude-opus-4-5",
tokens: {
input: 100000,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
},
],
})
)
const summarize = mock(() => Promise.resolve())
const hook = createPreemptiveCompactionHook(createMockCtx({ messages, summarize }))
const output = { title: "", output: "", metadata: {} }
// #when
await hook["tool.execute.after"](
{ tool: "Read", sessionID, callID: "call-2" },
output
)
// #then
expect(summarize).not.toHaveBeenCalled()
})
})
+103
View File
@@ -0,0 +1,103 @@
const ANTHROPIC_ACTUAL_LIMIT =
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? 1_000_000
: 200_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
interface AssistantMessageInfo {
role: "assistant"
providerID: string
modelID?: string
tokens: {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
}
interface MessageWrapper {
info: { role: string } & Partial<AssistantMessageInfo>
}
type PluginInput = {
client: {
session: {
messages: (...args: any[]) => any
summarize: (...args: any[]) => any
}
tui: {
showToast: (...args: any[]) => any
}
}
directory: string
}
export function createPreemptiveCompactionHook(ctx: PluginInput) {
const compactionInProgress = new Set<string>()
const compactedSessions = new Set<string>()
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },
_output: { title: string; output: string; metadata: unknown }
) => {
const { sessionID } = input
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
})
const payload = response as { data?: MessageWrapper[] } | MessageWrapper[]
const messages = Array.isArray(payload) ? payload : (payload.data ?? [])
const assistantMessages = messages
.filter((m) => m.info.role === "assistant")
.map((m) => m.info as AssistantMessageInfo)
if (assistantMessages.length === 0) return
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (lastAssistant.providerID !== "anthropic") return
const lastTokens = lastAssistant.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const usageRatio = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return
const modelID = lastAssistant.modelID
if (!modelID) return
compactionInProgress.add(sessionID)
await ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: lastAssistant.providerID, modelID, auto: true } as never,
query: { directory: ctx.directory },
})
compactedSessions.add(sessionID)
} catch {
// best-effort; do not disrupt tool execution
} finally {
compactionInProgress.delete(sessionID)
}
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.deleted") return
const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
compactionInProgress.delete(sessionInfo.id)
compactedSessions.delete(sessionInfo.id)
}
}
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
}
}