refactor: rename anthropic-auto-compact to anthropic-context-window-limit-recovery
The old name 'auto-compact' was misleading - the hook does much more than just compaction. It's a full recovery pipeline for context window limit errors including: - DCP (Dynamic Context Pruning) - Aggressive/single truncation - Summarize with retry - Emergency message revert The new name accurately describes its purpose: recovering from Anthropic context window limit exceeded errors. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { executeCompact } from "./executor"
|
||||
import type { AutoCompactState } from "./types"
|
||||
|
||||
describe("executeCompact lock management", () => {
|
||||
let autoCompactState: AutoCompactState
|
||||
let mockClient: any
|
||||
const sessionID = "test-session-123"
|
||||
const directory = "/test/dir"
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
beforeEach(() => {
|
||||
// #given: Fresh state for each test
|
||||
autoCompactState = {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map(),
|
||||
retryStateBySession: new Map(),
|
||||
fallbackStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
|
||||
mockClient = {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
summarize: mock(() => Promise.resolve()),
|
||||
revert: mock(() => Promise.resolve()),
|
||||
prompt_async: mock(() => Promise.resolve()),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
test("clears lock on successful summarize completion", async () => {
|
||||
// #given: Valid session with providerID/modelID
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction successfully
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when summarize throws exception", async () => {
|
||||
// #given: Summarize will fail
|
||||
mockClient.session.summarize = mock(() =>
|
||||
Promise.reject(new Error("Network timeout")),
|
||||
)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should still be cleared despite exception
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when revert throws exception", async () => {
|
||||
// #given: Force revert path by exhausting retry attempts and making revert fail
|
||||
mockClient.session.revert = mock(() =>
|
||||
Promise.reject(new Error("Revert failed")),
|
||||
)
|
||||
mockClient.session.messages = mock(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ info: { id: "msg1", role: "user" } },
|
||||
{ info: { id: "msg2", role: "assistant" } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// Exhaust retry attempts
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 5,
|
||||
lastAttemptTime: Date.now(),
|
||||
})
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock cleared even though revert failed
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("shows toast when lock already held", async () => {
|
||||
// #given: Lock already held
|
||||
autoCompactState.compactionInProgress.add(sessionID)
|
||||
|
||||
// #when: Try to execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Toast should be shown with warning message
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
title: "Compact In Progress",
|
||||
message: expect.stringContaining("Recovery already running"),
|
||||
variant: "warning",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// #then: compactionInProgress should still have the lock
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("clears lock when fixEmptyMessages path executes", async () => {
|
||||
// #given: Empty content error scenario
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "non-empty content required",
|
||||
messageIndex: 0,
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction (fixEmptyMessages will be called)
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when truncation is sufficient", async () => {
|
||||
// #given: Aggressive truncation scenario with sufficient truncation
|
||||
// This test verifies the early return path in aggressive truncation
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const experimental = {
|
||||
truncate_all_tool_outputs: false,
|
||||
aggressive_truncation: true,
|
||||
}
|
||||
|
||||
// #when: Execute compaction with experimental flag
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
mockClient,
|
||||
directory,
|
||||
experimental,
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared even on early return
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("prevents concurrent compaction attempts", async () => {
|
||||
// #given: Lock already held (simpler test)
|
||||
autoCompactState.compactionInProgress.add(sessionID)
|
||||
|
||||
// #when: Try to execute compaction while lock is held
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Toast should be shown
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const blockedToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Compact In Progress",
|
||||
)
|
||||
expect(blockedToast).toBeDefined()
|
||||
|
||||
// #then: Lock should still be held (not cleared by blocked attempt)
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("clears lock after max recovery attempts exhausted", async () => {
|
||||
// #given: All retry/revert attempts exhausted
|
||||
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
|
||||
|
||||
// Max out all attempts
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 5,
|
||||
lastAttemptTime: Date.now(),
|
||||
})
|
||||
autoCompactState.fallbackStateBySession.set(sessionID, {
|
||||
revertAttempt: 5,
|
||||
})
|
||||
autoCompactState.truncateStateBySession.set(sessionID, {
|
||||
truncateAttempt: 5,
|
||||
})
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Should show failure toast
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const failureToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
|
||||
)
|
||||
expect(failureToast).toBeDefined()
|
||||
|
||||
// #then: Lock should still be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when client.tui.showToast throws", async () => {
|
||||
// #given: Toast will fail (this should never happen but testing robustness)
|
||||
mockClient.tui.showToast = mock(() =>
|
||||
Promise.reject(new Error("Toast failed")),
|
||||
)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared even if toast fails
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when prompt_async in continuation throws", async () => {
|
||||
// #given: prompt_async will fail during continuation
|
||||
mockClient.session.prompt_async = mock(() =>
|
||||
Promise.reject(new Error("Prompt failed")),
|
||||
)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// The continuation happens in setTimeout, but lock is cleared in finally before that
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
800
src/hooks/anthropic-context-window-limit-recovery/executor.ts
Normal file
800
src/hooks/anthropic-context-window-limit-recovery/executor.ts
Normal file
@@ -0,0 +1,800 @@
|
||||
import type {
|
||||
AutoCompactState,
|
||||
DcpState,
|
||||
FallbackState,
|
||||
RetryState,
|
||||
TruncateState,
|
||||
} from "./types";
|
||||
import type { ExperimentalConfig } from "../../config";
|
||||
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
||||
import { executeDynamicContextPruning } from "./pruning-executor";
|
||||
import {
|
||||
findLargestToolResult,
|
||||
truncateToolResult,
|
||||
truncateUntilTargetTokens,
|
||||
} from "./storage";
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
injectTextPart,
|
||||
replaceEmptyTextParts,
|
||||
} from "../session-recovery/storage";
|
||||
import { log } from "../../shared/logger";
|
||||
|
||||
const PLACEHOLDER_TEXT = "[user interrupted]";
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: {
|
||||
path: { id: string };
|
||||
query?: { directory?: string };
|
||||
}) => Promise<unknown>;
|
||||
summarize: (opts: {
|
||||
path: { id: string };
|
||||
body: { providerID: string; modelID: string };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
revert: (opts: {
|
||||
path: { id: string };
|
||||
body: { messageID: string; partID?: string };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
prompt_async: (opts: {
|
||||
path: { sessionID: string };
|
||||
body: { parts: Array<{ type: string; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
tui: {
|
||||
showToast: (opts: {
|
||||
body: {
|
||||
title: string;
|
||||
message: string;
|
||||
variant: string;
|
||||
duration: number;
|
||||
};
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
function getOrCreateRetryState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): RetryState {
|
||||
let state = autoCompactState.retryStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { attempt: 0, lastAttemptTime: 0 };
|
||||
autoCompactState.retryStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function getOrCreateFallbackState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): FallbackState {
|
||||
let state = autoCompactState.fallbackStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { revertAttempt: 0 };
|
||||
autoCompactState.fallbackStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function getOrCreateTruncateState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): TruncateState {
|
||||
let state = autoCompactState.truncateStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { truncateAttempt: 0 };
|
||||
autoCompactState.truncateStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function getOrCreateDcpState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): DcpState {
|
||||
let state = autoCompactState.dcpStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { attempted: false, itemsPruned: 0 };
|
||||
autoCompactState.dcpStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
|
||||
const emptyMessageIds = findEmptyMessages(sessionID);
|
||||
if (emptyMessageIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let fixedCount = 0;
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT);
|
||||
if (replaced) {
|
||||
fixedCount++;
|
||||
} else {
|
||||
const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT);
|
||||
if (injected) {
|
||||
fixedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedCount > 0) {
|
||||
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
|
||||
sessionID,
|
||||
fixedCount,
|
||||
totalEmpty: emptyMessageIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
return fixedCount;
|
||||
}
|
||||
|
||||
async function getLastMessagePair(
|
||||
sessionID: string,
|
||||
client: Client,
|
||||
directory: string,
|
||||
): Promise<{ userMessageID: string; assistantMessageID?: string } | null> {
|
||||
try {
|
||||
const resp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data;
|
||||
if (
|
||||
!Array.isArray(data) ||
|
||||
data.length < FALLBACK_CONFIG.minMessagesRequired
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reversed = [...data].reverse();
|
||||
|
||||
const lastAssistant = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>;
|
||||
const info = msg.info as Record<string, unknown> | undefined;
|
||||
return info?.role === "assistant";
|
||||
});
|
||||
|
||||
const lastUser = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>;
|
||||
const info = msg.info as Record<string, unknown> | undefined;
|
||||
return info?.role === "user";
|
||||
});
|
||||
|
||||
if (!lastUser) return null;
|
||||
const userInfo = (lastUser as { info?: Record<string, unknown> }).info;
|
||||
const userMessageID = userInfo?.id as string | undefined;
|
||||
if (!userMessageID) return null;
|
||||
|
||||
let assistantMessageID: string | undefined;
|
||||
if (lastAssistant) {
|
||||
const assistantInfo = (
|
||||
lastAssistant as { info?: Record<string, unknown> }
|
||||
).info;
|
||||
assistantMessageID = assistantInfo?.id as string | undefined;
|
||||
}
|
||||
|
||||
return { userMessageID, assistantMessageID };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data;
|
||||
if (!Array.isArray(data)) return null;
|
||||
|
||||
const reversed = [...data].reverse();
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>;
|
||||
const info = msg.info as Record<string, unknown> | undefined;
|
||||
return info?.role === "assistant";
|
||||
});
|
||||
if (!last) return null;
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): void {
|
||||
autoCompactState.pendingCompact.delete(sessionID);
|
||||
autoCompactState.errorDataBySession.delete(sessionID);
|
||||
autoCompactState.retryStateBySession.delete(sessionID);
|
||||
autoCompactState.fallbackStateBySession.delete(sessionID);
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
autoCompactState.dcpStateBySession.delete(sessionID);
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
|
||||
autoCompactState.compactionInProgress.delete(sessionID);
|
||||
}
|
||||
|
||||
function getOrCreateEmptyContentAttempt(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): number {
|
||||
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0;
|
||||
}
|
||||
|
||||
async function fixEmptyMessages(
|
||||
sessionID: string,
|
||||
autoCompactState: AutoCompactState,
|
||||
client: Client,
|
||||
messageIndex?: number,
|
||||
): Promise<boolean> {
|
||||
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID);
|
||||
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1);
|
||||
|
||||
let fixed = false;
|
||||
const fixedMessageIds: string[] = [];
|
||||
|
||||
if (messageIndex !== undefined) {
|
||||
const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex);
|
||||
if (targetMessageId) {
|
||||
const replaced = replaceEmptyTextParts(
|
||||
targetMessageId,
|
||||
"[user interrupted]",
|
||||
);
|
||||
if (replaced) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(targetMessageId);
|
||||
} else {
|
||||
const injected = injectTextPart(
|
||||
sessionID,
|
||||
targetMessageId,
|
||||
"[user interrupted]",
|
||||
);
|
||||
if (injected) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(targetMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixed) {
|
||||
const emptyMessageIds = findEmptyMessages(sessionID);
|
||||
if (emptyMessageIds.length === 0) {
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Empty Content Error",
|
||||
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]");
|
||||
if (replaced) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(messageID);
|
||||
} else {
|
||||
const injected = injectTextPart(
|
||||
sessionID,
|
||||
messageID,
|
||||
"[user interrupted]",
|
||||
);
|
||||
if (injected) {
|
||||
fixed = true;
|
||||
fixedMessageIds.push(messageID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed) {
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Session Recovery",
|
||||
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string,
|
||||
experimental?: ExperimentalConfig,
|
||||
): Promise<void> {
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Compact In Progress",
|
||||
message:
|
||||
"Recovery already running. Please wait or start new session if stuck.",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
autoCompactState.compactionInProgress.add(sessionID);
|
||||
|
||||
try {
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID);
|
||||
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
|
||||
|
||||
// DCP FIRST - run before any other recovery attempts when token limit exceeded
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (
|
||||
experimental?.dcp_for_compaction &&
|
||||
!dcpState.attempted &&
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens
|
||||
) {
|
||||
dcpState.attempted = true;
|
||||
log("[auto-compact] DCP triggered FIRST on token limit error", {
|
||||
sessionID,
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
});
|
||||
|
||||
const dcpConfig = experimental.dynamic_context_pruning ?? {
|
||||
enabled: true,
|
||||
notification: "detailed" as const,
|
||||
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
|
||||
};
|
||||
|
||||
try {
|
||||
const pruningResult = await executeDynamicContextPruning(
|
||||
sessionID,
|
||||
dcpConfig,
|
||||
client
|
||||
);
|
||||
|
||||
if (pruningResult.itemsPruned > 0) {
|
||||
dcpState.itemsPruned = pruningResult.itemsPruned;
|
||||
log("[auto-compact] DCP successful, proceeding to compaction", {
|
||||
itemsPruned: pruningResult.itemsPruned,
|
||||
tokensSaved: pruningResult.totalTokensSaved,
|
||||
});
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Running compaction...`,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// After DCP, immediately try summarize
|
||||
const providerID = msg.providerID as string | undefined;
|
||||
const modelID = msg.modelID as string | undefined;
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
sanitizeEmptyMessagesBeforeSummarize(sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Summarizing session after DCP...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch (summarizeError) {
|
||||
log("[auto-compact] summarize after DCP failed, continuing recovery", {
|
||||
error: String(summarizeError),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log("[auto-compact] DCP did not prune any items", { sessionID });
|
||||
}
|
||||
} catch (error) {
|
||||
log("[auto-compact] DCP failed", { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
experimental?.aggressive_truncation &&
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens &&
|
||||
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
|
||||
) {
|
||||
log("[auto-compact] aggressive truncation triggered (experimental)", {
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
||||
});
|
||||
|
||||
const aggressiveResult = truncateUntilTargetTokens(
|
||||
sessionID,
|
||||
errorData.currentTokens,
|
||||
errorData.maxTokens,
|
||||
TRUNCATE_CONFIG.targetTokenRatio,
|
||||
TRUNCATE_CONFIG.charsPerToken,
|
||||
);
|
||||
|
||||
if (aggressiveResult.truncatedCount > 0) {
|
||||
truncateState.truncateAttempt += aggressiveResult.truncatedCount;
|
||||
|
||||
const toolNames = aggressiveResult.truncatedTools
|
||||
.map((t) => t.toolName)
|
||||
.join(", ");
|
||||
const statusMsg = aggressiveResult.sufficient
|
||||
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`;
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: aggressiveResult.sufficient
|
||||
? "Aggressive Truncation"
|
||||
: "Partial Truncation",
|
||||
message: `${statusMsg}: ${toolNames}`,
|
||||
variant: "warning",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
log("[auto-compact] aggressive truncation completed", aggressiveResult);
|
||||
|
||||
if (aggressiveResult.sufficient) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncation Skipped",
|
||||
message: "No tool outputs found to truncate.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let skipSummarize = false;
|
||||
|
||||
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
||||
const largest = findLargestToolResult(sessionID);
|
||||
|
||||
if (
|
||||
largest &&
|
||||
largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate
|
||||
) {
|
||||
const result = truncateToolResult(largest.partPath);
|
||||
|
||||
if (result.success) {
|
||||
truncateState.truncateAttempt++;
|
||||
truncateState.lastTruncatedPartId = largest.partId;
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncating Large Output",
|
||||
message: `Truncated ${result.toolName} (${formatBytes(result.originalSize ?? 0)}). Retrying...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens
|
||||
) {
|
||||
skipSummarize = true;
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Summarize Skipped",
|
||||
message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (!errorData?.currentTokens) {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncation Skipped",
|
||||
message: "No large tool outputs found.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
|
||||
|
||||
if (errorData?.errorType?.includes("non-empty content")) {
|
||||
const attempt = getOrCreateEmptyContentAttempt(
|
||||
autoCompactState,
|
||||
sessionID,
|
||||
);
|
||||
if (attempt < 3) {
|
||||
const fixed = await fixEmptyMessages(
|
||||
sessionID,
|
||||
autoCompactState,
|
||||
client as Client,
|
||||
errorData.messageIndex,
|
||||
);
|
||||
if (fixed) {
|
||||
setTimeout(() => {
|
||||
executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Recovery Failed",
|
||||
message:
|
||||
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 10000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
||||
retryState.attempt = 0;
|
||||
autoCompactState.fallbackStateBySession.delete(sessionID);
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
}
|
||||
|
||||
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||
retryState.attempt++;
|
||||
retryState.lastAttemptTime = Date.now();
|
||||
|
||||
const providerID = msg.providerID as string | undefined;
|
||||
const modelID = msg.modelID as string | undefined;
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
sanitizeEmptyMessagesBeforeSummarize(sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch {
|
||||
const delay =
|
||||
RETRY_CONFIG.initialDelayMs *
|
||||
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1);
|
||||
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs);
|
||||
|
||||
setTimeout(() => {
|
||||
executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
);
|
||||
}, cappedDelay);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Summarize Skipped",
|
||||
message: "Missing providerID or modelID. Skipping to revert...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID);
|
||||
|
||||
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
|
||||
const pair = await getLastMessagePair(
|
||||
sessionID,
|
||||
client as Client,
|
||||
directory,
|
||||
);
|
||||
|
||||
if (pair) {
|
||||
try {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Emergency Recovery",
|
||||
message: "Removing last message pair...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (pair.assistantMessageID) {
|
||||
await (client as Client).session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: pair.assistantMessageID },
|
||||
query: { directory },
|
||||
});
|
||||
}
|
||||
|
||||
await (client as Client).session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: pair.userMessageID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
fallbackState.revertAttempt++;
|
||||
fallbackState.lastRevertedMessageID = pair.userMessageID;
|
||||
|
||||
// Clear all state after successful revert - don't recurse
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
// Send "Continue" prompt to resume session
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch {}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Revert Skipped",
|
||||
message: "Could not find last message pair to revert.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Failed",
|
||||
message: "All recovery attempts failed. Please start a new session.",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
autoCompactState.compactionInProgress.delete(sessionID);
|
||||
}
|
||||
}
|
||||
155
src/hooks/anthropic-context-window-limit-recovery/index.ts
Normal file
155
src/hooks/anthropic-context-window-limit-recovery/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
|
||||
function createRecoveryState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map(),
|
||||
fallbackStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput, options?: AnthropicContextWindowLimitRecoveryOptions) {
|
||||
const autoCompactState = createRecoveryState()
|
||||
const experimental = options?.experimental
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.dcpStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
|
||||
autoCompactState.compactionInProgress.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
log("[auto-compact] session.error received", { sessionID, error: props?.error })
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
log("[auto-compact] parsed result", { parsed, hasError: !!props?.error })
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
const providerID = parsed.providerID ?? (lastAssistant?.providerID as string | undefined)
|
||||
const modelID = parsed.modelID ?? (lastAssistant?.modelID as string | undefined)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Context Limit Hit",
|
||||
message: "Truncating large tool outputs and recovering...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
setTimeout(() => {
|
||||
executeCompact(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental
|
||||
)
|
||||
}, 300)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
log("[auto-compact] message.updated with error", { sessionID, error: info.error })
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
log("[auto-compact] message.updated parsed result", { parsed })
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
|
||||
if (lastAssistant?.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const providerID = errorData?.providerID ?? (lastAssistant?.providerID as string | undefined)
|
||||
const modelID = errorData?.modelID ?? (lastAssistant?.modelID as string | undefined)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Attempting recovery...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, DcpState, FallbackState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
184
src/hooks/anthropic-context-window-limit-recovery/parser.ts
Normal file
184
src/hooks/anthropic-context-window-limit-recovery/parser.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { ParsedTokenLimitError } from "./types"
|
||||
|
||||
interface AnthropicErrorData {
|
||||
type: "error"
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
const TOKEN_LIMIT_PATTERNS = [
|
||||
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
|
||||
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
|
||||
/(\d+).*?tokens.*?limit.*?(\d+)/i,
|
||||
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
|
||||
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
|
||||
]
|
||||
|
||||
const TOKEN_LIMIT_KEYWORDS = [
|
||||
"prompt is too long",
|
||||
"is too long",
|
||||
"context_length_exceeded",
|
||||
"max_tokens",
|
||||
"token limit",
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"non-empty content",
|
||||
"invalid_request_error",
|
||||
]
|
||||
|
||||
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const num1 = parseInt(match[1], 10)
|
||||
const num2 = parseInt(match[2], 10)
|
||||
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractMessageIndex(text: string): number | undefined {
|
||||
const match = text.match(MESSAGE_INDEX_PATTERN)
|
||||
if (match) {
|
||||
return parseInt(match[1], 10)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (err.toLowerCase().includes("non-empty content")) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "non-empty content",
|
||||
messageIndex: extractMessageIndex(err),
|
||||
}
|
||||
}
|
||||
if (isTokenLimitError(err)) {
|
||||
const tokens = extractTokensFromMessage(err)
|
||||
return {
|
||||
currentTokens: tokens?.current ?? 0,
|
||||
maxTokens: tokens?.max ?? 0,
|
||||
errorType: "token_limit_exceeded_string",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!err || typeof err !== "object") return null
|
||||
|
||||
const errObj = err as Record<string, unknown>
|
||||
|
||||
const dataObj = errObj.data as Record<string, unknown> | undefined
|
||||
const responseBody = dataObj?.responseBody
|
||||
const errorMessage = errObj.message as string | undefined
|
||||
const errorData = errObj.error as Record<string, unknown> | undefined
|
||||
const nestedError = errorData?.error as Record<string, unknown> | undefined
|
||||
|
||||
const textSources: string[] = []
|
||||
|
||||
if (typeof responseBody === "string") textSources.push(responseBody)
|
||||
if (typeof errorMessage === "string") textSources.push(errorMessage)
|
||||
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
|
||||
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
|
||||
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
|
||||
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
|
||||
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
|
||||
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
|
||||
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
|
||||
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
|
||||
|
||||
if (textSources.length === 0) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(errObj)
|
||||
if (isTokenLimitError(jsonStr)) {
|
||||
textSources.push(jsonStr)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const combinedText = textSources.join(" ")
|
||||
if (!isTokenLimitError(combinedText)) return null
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
// Greedy match to last } for nested JSON
|
||||
/data:\s*(\{[\s\S]*\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*\})/,
|
||||
/(\{[\s\S]*"error"[\s\S]*\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
const dataMatch = responseBody.match(pattern)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
|
||||
const message = jsonData.error?.message || ""
|
||||
const tokens = extractTokensFromMessage(message)
|
||||
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
requestId: jsonData.request_id,
|
||||
errorType: jsonData.error?.type || "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const bedrockJson = JSON.parse(responseBody)
|
||||
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "bedrock_input_too_long",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const text of textSources) {
|
||||
const tokens = extractTokensFromMessage(text)
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
errorType: "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedText.toLowerCase().includes("non-empty content")) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "non-empty content",
|
||||
messageIndex: extractMessageIndex(combinedText),
|
||||
}
|
||||
}
|
||||
|
||||
if (isTokenLimitError(combinedText)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "token_limit_exceeded_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { createToolSignature } from "./pruning-deduplication"
|
||||
|
||||
describe("createToolSignature", () => {
|
||||
test("creates consistent signature for same input", () => {
|
||||
const input1 = { filePath: "/foo/bar.ts", content: "hello" }
|
||||
const input2 = { content: "hello", filePath: "/foo/bar.ts" }
|
||||
|
||||
const sig1 = createToolSignature("read", input1)
|
||||
const sig2 = createToolSignature("read", input2)
|
||||
|
||||
expect(sig1).toBe(sig2)
|
||||
})
|
||||
|
||||
test("creates different signature for different input", () => {
|
||||
const input1 = { filePath: "/foo/bar.ts" }
|
||||
const input2 = { filePath: "/foo/baz.ts" }
|
||||
|
||||
const sig1 = createToolSignature("read", input1)
|
||||
const sig2 = createToolSignature("read", input2)
|
||||
|
||||
expect(sig1).not.toBe(sig2)
|
||||
})
|
||||
|
||||
test("includes tool name in signature", () => {
|
||||
const input = { filePath: "/foo/bar.ts" }
|
||||
|
||||
const sig1 = createToolSignature("read", input)
|
||||
const sig2 = createToolSignature("write", input)
|
||||
|
||||
expect(sig1).not.toBe(sig2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, ToolCallSignature } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface DeduplicationConfig {
|
||||
enabled: boolean
|
||||
protectedTools?: string[]
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
export function createToolSignature(toolName: string, input: unknown): string {
|
||||
const sortedInput = sortObject(input)
|
||||
return `${toolName}::${JSON.stringify(sortedInput)}`
|
||||
}
|
||||
|
||||
function sortObject(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj !== "object") return obj
|
||||
if (Array.isArray(obj)) return obj.map(sortObject)
|
||||
|
||||
const sorted: Record<string, unknown> = {}
|
||||
const keys = Object.keys(obj as Record<string, unknown>).sort()
|
||||
for (const key of keys) {
|
||||
sorted[key] = sortObject((obj as Record<string, unknown>)[key])
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export function executeDeduplication(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: DeduplicationConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
const signatures = new Map<string, ToolCallSignature[]>()
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (config.protectedTools?.includes(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
const signature = createToolSignature(part.tool, part.state?.input)
|
||||
|
||||
if (!signatures.has(signature)) {
|
||||
signatures.set(signature, [])
|
||||
}
|
||||
|
||||
signatures.get(signature)!.push({
|
||||
toolName: part.tool,
|
||||
signature,
|
||||
callID: part.callID,
|
||||
turn: currentTurn,
|
||||
})
|
||||
|
||||
if (!state.toolSignatures.has(signature)) {
|
||||
state.toolSignatures.set(signature, [])
|
||||
}
|
||||
state.toolSignatures.get(signature)!.push({
|
||||
toolName: part.tool,
|
||||
signature,
|
||||
callID: part.callID,
|
||||
turn: currentTurn,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const [signature, calls] of signatures) {
|
||||
if (calls.length > 1) {
|
||||
const toPrune = calls.slice(0, -1)
|
||||
|
||||
for (const call of toPrune) {
|
||||
state.toolIdsToPrune.add(call.callID)
|
||||
prunedCount++
|
||||
|
||||
const output = findToolOutput(messages, call.callID)
|
||||
if (output) {
|
||||
tokensSaved += estimateTokens(output)
|
||||
}
|
||||
|
||||
log("[pruning-deduplication] pruned duplicate", {
|
||||
tool: call.toolName,
|
||||
callID: call.callID,
|
||||
turn: call.turn,
|
||||
signature: signature.substring(0, 100),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-deduplication] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
uniqueSignatures: signatures.size,
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
|
||||
function findToolOutput(messages: MessagePart[], callID: string): string | null {
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.callID === callID && part.state?.output) {
|
||||
return part.state.output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { DynamicContextPruningConfig } from "../../config"
|
||||
import type { PruningState, PruningResult } from "./pruning-types"
|
||||
import { executeDeduplication } from "./pruning-deduplication"
|
||||
import { executeSupersedeWrites } from "./pruning-supersede"
|
||||
import { executePurgeErrors } from "./pruning-purge-errors"
|
||||
import { applyPruning } from "./pruning-storage"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const DEFAULT_PROTECTED_TOOLS = new Set([
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
"session_read",
|
||||
"session_write",
|
||||
"session_search",
|
||||
])
|
||||
|
||||
function createPruningState(): PruningState {
|
||||
return {
|
||||
toolIdsToPrune: new Set<string>(),
|
||||
currentTurn: 0,
|
||||
fileOperations: new Map(),
|
||||
toolSignatures: new Map(),
|
||||
erroredTools: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDynamicContextPruning(
|
||||
sessionID: string,
|
||||
config: DynamicContextPruningConfig,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any
|
||||
): Promise<PruningResult> {
|
||||
const state = createPruningState()
|
||||
|
||||
const protectedTools = new Set([
|
||||
...DEFAULT_PROTECTED_TOOLS,
|
||||
...(config.protected_tools || []),
|
||||
])
|
||||
|
||||
log("[pruning-executor] starting DCP", {
|
||||
sessionID,
|
||||
notification: config.notification,
|
||||
turnProtection: config.turn_protection,
|
||||
})
|
||||
|
||||
let dedupCount = 0
|
||||
let supersedeCount = 0
|
||||
let purgeCount = 0
|
||||
|
||||
if (config.strategies?.deduplication?.enabled !== false) {
|
||||
dedupCount = executeDeduplication(
|
||||
sessionID,
|
||||
state,
|
||||
{ enabled: true },
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
if (config.strategies?.supersede_writes?.enabled !== false) {
|
||||
supersedeCount = executeSupersedeWrites(
|
||||
sessionID,
|
||||
state,
|
||||
{
|
||||
enabled: true,
|
||||
aggressive: config.strategies?.supersede_writes?.aggressive || false,
|
||||
},
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
if (config.strategies?.purge_errors?.enabled !== false) {
|
||||
purgeCount = executePurgeErrors(
|
||||
sessionID,
|
||||
state,
|
||||
{
|
||||
enabled: true,
|
||||
turns: config.strategies?.purge_errors?.turns || 5,
|
||||
},
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
const totalPruned = state.toolIdsToPrune.size
|
||||
const tokensSaved = await applyPruning(sessionID, state)
|
||||
|
||||
log("[pruning-executor] DCP complete", {
|
||||
totalPruned,
|
||||
tokensSaved,
|
||||
deduplication: dedupCount,
|
||||
supersede: supersedeCount,
|
||||
purge: purgeCount,
|
||||
})
|
||||
|
||||
const result: PruningResult = {
|
||||
itemsPruned: totalPruned,
|
||||
totalTokensSaved: tokensSaved,
|
||||
strategies: {
|
||||
deduplication: dedupCount,
|
||||
supersedeWrites: supersedeCount,
|
||||
purgeErrors: purgeCount,
|
||||
},
|
||||
}
|
||||
|
||||
if (config.notification !== "off" && totalPruned > 0) {
|
||||
const message =
|
||||
config.notification === "detailed"
|
||||
? `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens). Dedup: ${dedupCount}, Supersede: ${supersedeCount}, Purge: ${purgeCount}`
|
||||
: `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens)`
|
||||
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, ErroredToolCall } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface PurgeErrorsConfig {
|
||||
enabled: boolean
|
||||
turns: number
|
||||
protectedTools?: string[]
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
status?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export function executePurgeErrors(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: PurgeErrorsConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.currentTurn = currentTurn
|
||||
|
||||
let turnCounter = 0
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
turnCounter++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (config.protectedTools?.includes(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
if (part.state?.status !== "error") continue
|
||||
|
||||
const turnAge = currentTurn - turnCounter
|
||||
|
||||
if (turnAge >= config.turns) {
|
||||
state.toolIdsToPrune.add(part.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = part.state.input
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
const errorInfo: ErroredToolCall = {
|
||||
callID: part.callID,
|
||||
toolName: part.tool,
|
||||
turn: turnCounter,
|
||||
errorAge: turnAge,
|
||||
}
|
||||
|
||||
state.erroredTools.set(part.callID, errorInfo)
|
||||
|
||||
log("[pruning-purge-errors] pruned old error", {
|
||||
tool: part.tool,
|
||||
callID: part.callID,
|
||||
turn: turnCounter,
|
||||
errorAge: turnAge,
|
||||
threshold: config.turns,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-purge-errors] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
currentTurn,
|
||||
threshold: config.turns,
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
status?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageData {
|
||||
parts?: ToolPart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function applyPruning(
|
||||
sessionID: string,
|
||||
state: PruningState
|
||||
): Promise<number> {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) {
|
||||
log("[pruning-storage] message dir not found", { sessionID })
|
||||
return 0
|
||||
}
|
||||
|
||||
let totalTokensSaved = 0
|
||||
let filesModified = 0
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(messageDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const data: MessageData = JSON.parse(content)
|
||||
|
||||
if (!data.parts) continue
|
||||
|
||||
let modified = false
|
||||
|
||||
for (const part of data.parts) {
|
||||
if (part.type !== "tool" || !part.callID) continue
|
||||
|
||||
if (!state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
if (part.state?.input) {
|
||||
const inputStr = JSON.stringify(part.state.input)
|
||||
totalTokensSaved += estimateTokens(inputStr)
|
||||
part.state.input = { __pruned: true, reason: "DCP" }
|
||||
modified = true
|
||||
}
|
||||
|
||||
if (part.state?.output) {
|
||||
totalTokensSaved += estimateTokens(part.state.output)
|
||||
part.state.output = "[Content pruned by Dynamic Context Pruning]"
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||
filesModified++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("[pruning-storage] error applying pruning", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
|
||||
log("[pruning-storage] applied pruning", {
|
||||
sessionID,
|
||||
filesModified,
|
||||
totalTokensSaved,
|
||||
})
|
||||
|
||||
return totalTokensSaved
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, FileOperation } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface SupersedeWritesConfig {
|
||||
enabled: boolean
|
||||
aggressive: boolean
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function extractFilePath(toolName: string, input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") return null
|
||||
|
||||
const inputObj = input as Record<string, unknown>
|
||||
|
||||
if (toolName === "write" || toolName === "edit" || toolName === "read") {
|
||||
if (typeof inputObj.filePath === "string") {
|
||||
return inputObj.filePath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function executeSupersedeWrites(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: SupersedeWritesConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
const writesByFile = new Map<string, FileOperation[]>()
|
||||
const readsByFile = new Map<string, number[]>()
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
const filePath = extractFilePath(part.tool, part.state?.input)
|
||||
if (!filePath) continue
|
||||
|
||||
if (part.tool === "write" || part.tool === "edit") {
|
||||
if (!writesByFile.has(filePath)) {
|
||||
writesByFile.set(filePath, [])
|
||||
}
|
||||
writesByFile.get(filePath)!.push({
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
filePath,
|
||||
turn: currentTurn,
|
||||
})
|
||||
|
||||
if (!state.fileOperations.has(filePath)) {
|
||||
state.fileOperations.set(filePath, [])
|
||||
}
|
||||
state.fileOperations.get(filePath)!.push({
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
filePath,
|
||||
turn: currentTurn,
|
||||
})
|
||||
} else if (part.tool === "read") {
|
||||
if (!readsByFile.has(filePath)) {
|
||||
readsByFile.set(filePath, [])
|
||||
}
|
||||
readsByFile.get(filePath)!.push(currentTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const [filePath, writes] of writesByFile) {
|
||||
const reads = readsByFile.get(filePath) || []
|
||||
|
||||
if (config.aggressive) {
|
||||
for (const write of writes) {
|
||||
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||
if (superseded) {
|
||||
state.toolIdsToPrune.add(write.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = findToolInput(messages, write.callID)
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
log("[pruning-supersede] pruned superseded write", {
|
||||
tool: write.tool,
|
||||
callID: write.callID,
|
||||
turn: write.turn,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (writes.length > 1) {
|
||||
for (const write of writes.slice(0, -1)) {
|
||||
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||
if (superseded) {
|
||||
state.toolIdsToPrune.add(write.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = findToolInput(messages, write.callID)
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
log("[pruning-supersede] pruned superseded write (conservative)", {
|
||||
tool: write.tool,
|
||||
callID: write.callID,
|
||||
turn: write.turn,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-supersede] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
filesTracked: writesByFile.size,
|
||||
mode: config.aggressive ? "aggressive" : "conservative",
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
|
||||
function findToolInput(messages: MessagePart[], callID: string): unknown | null {
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.callID === callID && part.state?.input) {
|
||||
return part.state.input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface ToolCallSignature {
|
||||
toolName: string
|
||||
signature: string
|
||||
callID: string
|
||||
turn: number
|
||||
}
|
||||
|
||||
export interface FileOperation {
|
||||
callID: string
|
||||
tool: string
|
||||
filePath: string
|
||||
turn: number
|
||||
}
|
||||
|
||||
export interface ErroredToolCall {
|
||||
callID: string
|
||||
toolName: string
|
||||
turn: number
|
||||
errorAge: number
|
||||
}
|
||||
|
||||
export interface PruningResult {
|
||||
itemsPruned: number
|
||||
totalTokensSaved: number
|
||||
strategies: {
|
||||
deduplication: number
|
||||
supersedeWrites: number
|
||||
purgeErrors: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface PruningState {
|
||||
toolIdsToPrune: Set<string>
|
||||
currentTurn: number
|
||||
fileOperations: Map<string, FileOperation[]>
|
||||
toolSignatures: Map<string, ToolCallSignature[]>
|
||||
erroredTools: Map<string, ErroredToolCall>
|
||||
}
|
||||
|
||||
export const CHARS_PER_TOKEN = 4
|
||||
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||
}
|
||||
246
src/hooks/anthropic-context-window-limit-recovery/storage.ts
Normal file
246
src/hooks/anthropic-context-window-limit-recovery/storage.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
|
||||
const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
const TRUNCATION_MESSAGE =
|
||||
"[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]"
|
||||
|
||||
interface StoredToolPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: "pending" | "running" | "completed" | "error"
|
||||
input: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
time?: {
|
||||
start: number
|
||||
end?: number
|
||||
compacted?: number
|
||||
}
|
||||
}
|
||||
truncated?: boolean
|
||||
originalSize?: number
|
||||
}
|
||||
|
||||
export interface ToolResultInfo {
|
||||
partPath: string
|
||||
partId: string
|
||||
messageID: string
|
||||
toolName: string
|
||||
outputSize: number
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return ""
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) {
|
||||
return directPath
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function getMessageIds(sessionID: string): string[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messageIds: string[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
const messageId = file.replace(".json", "")
|
||||
messageIds.push(messageId)
|
||||
}
|
||||
|
||||
return messageIds
|
||||
}
|
||||
|
||||
export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
|
||||
const messageIds = getMessageIds(sessionID)
|
||||
const results: ToolResultInfo[] = []
|
||||
|
||||
for (const messageID of messageIds) {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) continue
|
||||
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const partPath = join(partDir, file)
|
||||
const content = readFileSync(partPath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredToolPart
|
||||
|
||||
if (part.type === "tool" && part.state?.output && !part.truncated) {
|
||||
results.push({
|
||||
partPath,
|
||||
partId: part.id,
|
||||
messageID,
|
||||
toolName: part.tool,
|
||||
outputSize: part.state.output.length,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.outputSize - a.outputSize)
|
||||
}
|
||||
|
||||
export function findLargestToolResult(sessionID: string): ToolResultInfo | null {
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
return results.length > 0 ? results[0] : null
|
||||
}
|
||||
|
||||
export function truncateToolResult(partPath: string): {
|
||||
success: boolean
|
||||
toolName?: string
|
||||
originalSize?: number
|
||||
} {
|
||||
try {
|
||||
const content = readFileSync(partPath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredToolPart
|
||||
|
||||
if (!part.state?.output) {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
const originalSize = part.state.output.length
|
||||
const toolName = part.tool
|
||||
|
||||
part.truncated = true
|
||||
part.originalSize = originalSize
|
||||
part.state.output = TRUNCATION_MESSAGE
|
||||
|
||||
if (!part.state.time) {
|
||||
part.state.time = { start: Date.now() }
|
||||
}
|
||||
part.state.time.compacted = Date.now()
|
||||
|
||||
writeFileSync(partPath, JSON.stringify(part, null, 2))
|
||||
|
||||
return { success: true, toolName, originalSize }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalToolOutputSize(sessionID: string): number {
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
return results.reduce((sum, r) => sum + r.outputSize, 0)
|
||||
}
|
||||
|
||||
export function countTruncatedResults(sessionID: string): number {
|
||||
const messageIds = getMessageIds(sessionID)
|
||||
let count = 0
|
||||
|
||||
for (const messageID of messageIds) {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) continue
|
||||
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(partDir, file), "utf-8")
|
||||
const part = JSON.parse(content)
|
||||
if (part.truncated === true) {
|
||||
count++
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
export interface AggressiveTruncateResult {
|
||||
success: boolean
|
||||
sufficient: boolean
|
||||
truncatedCount: number
|
||||
totalBytesRemoved: number
|
||||
targetBytesToRemove: number
|
||||
truncatedTools: Array<{ toolName: string; originalSize: number }>
|
||||
}
|
||||
|
||||
export function truncateUntilTargetTokens(
|
||||
sessionID: string,
|
||||
currentTokens: number,
|
||||
maxTokens: number,
|
||||
targetRatio: number = 0.8,
|
||||
charsPerToken: number = 4
|
||||
): AggressiveTruncateResult {
|
||||
const targetTokens = Math.floor(maxTokens * targetRatio)
|
||||
const tokensToReduce = currentTokens - targetTokens
|
||||
const charsToReduce = tokensToReduce * charsPerToken
|
||||
|
||||
if (tokensToReduce <= 0) {
|
||||
return {
|
||||
success: true,
|
||||
sufficient: true,
|
||||
truncatedCount: 0,
|
||||
totalBytesRemoved: 0,
|
||||
targetBytesToRemove: 0,
|
||||
truncatedTools: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results = findToolResultsBySize(sessionID)
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
sufficient: false,
|
||||
truncatedCount: 0,
|
||||
totalBytesRemoved: 0,
|
||||
targetBytesToRemove: charsToReduce,
|
||||
truncatedTools: [],
|
||||
}
|
||||
}
|
||||
|
||||
let totalRemoved = 0
|
||||
let truncatedCount = 0
|
||||
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
|
||||
|
||||
for (const result of results) {
|
||||
const truncateResult = truncateToolResult(result.partPath)
|
||||
if (truncateResult.success) {
|
||||
truncatedCount++
|
||||
const removedSize = truncateResult.originalSize ?? result.outputSize
|
||||
totalRemoved += removedSize
|
||||
truncatedTools.push({
|
||||
toolName: truncateResult.toolName ?? result.toolName,
|
||||
originalSize: removedSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sufficient = totalRemoved >= charsToReduce
|
||||
|
||||
return {
|
||||
success: truncatedCount > 0,
|
||||
sufficient,
|
||||
truncatedCount,
|
||||
totalBytesRemoved: totalRemoved,
|
||||
targetBytesToRemove: charsToReduce,
|
||||
truncatedTools,
|
||||
}
|
||||
}
|
||||
59
src/hooks/anthropic-context-window-limit-recovery/types.ts
Normal file
59
src/hooks/anthropic-context-window-limit-recovery/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface ParsedTokenLimitError {
|
||||
currentTokens: number
|
||||
maxTokens: number
|
||||
requestId?: string
|
||||
errorType: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
messageIndex?: number
|
||||
}
|
||||
|
||||
export interface RetryState {
|
||||
attempt: number
|
||||
lastAttemptTime: number
|
||||
}
|
||||
|
||||
export interface FallbackState {
|
||||
revertAttempt: number
|
||||
lastRevertedMessageID?: string
|
||||
}
|
||||
|
||||
export interface TruncateState {
|
||||
truncateAttempt: number
|
||||
lastTruncatedPartId?: string
|
||||
}
|
||||
|
||||
export interface DcpState {
|
||||
attempted: boolean
|
||||
itemsPruned: number
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
retryStateBySession: Map<string, RetryState>
|
||||
fallbackStateBySession: Map<string, FallbackState>
|
||||
truncateStateBySession: Map<string, TruncateState>
|
||||
dcpStateBySession: Map<string, DcpState>
|
||||
emptyContentAttemptBySession: Map<string, number>
|
||||
compactionInProgress: Set<string>
|
||||
}
|
||||
|
||||
export const RETRY_CONFIG = {
|
||||
maxAttempts: 2,
|
||||
initialDelayMs: 2000,
|
||||
backoffFactor: 2,
|
||||
maxDelayMs: 30000,
|
||||
} as const
|
||||
|
||||
export const FALLBACK_CONFIG = {
|
||||
maxRevertAttempts: 3,
|
||||
minMessagesRequired: 2,
|
||||
} as const
|
||||
|
||||
export const TRUNCATE_CONFIG = {
|
||||
maxTruncateAttempts: 20,
|
||||
minOutputSizeToTruncate: 500,
|
||||
targetTokenRatio: 0.5,
|
||||
charsPerToken: 4,
|
||||
} as const
|
||||
Reference in New Issue
Block a user