Skip to content

Commit 6c5df39

Browse files
unraidclaude
andcommitted
feat: 添加 compact 缓存与上下文压缩增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent be97a0b commit 6c5df39

4 files changed

Lines changed: 240 additions & 41 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, test, expect, beforeEach } from 'bun:test'
2+
import {
3+
createCachedMCState,
4+
registerToolResult,
5+
getToolResultsToDelete,
6+
createCacheEditsBlock,
7+
markToolsSentToAPI,
8+
resetCachedMCState,
9+
isCachedMicrocompactEnabled,
10+
isModelSupportedForCacheEditing,
11+
type CachedMCState,
12+
} from '../cachedMicrocompact.js'
13+
14+
describe('cachedMicrocompact', () => {
15+
let state: CachedMCState
16+
17+
beforeEach(() => {
18+
state = createCachedMCState()
19+
})
20+
21+
test('createCachedMCState returns clean state', () => {
22+
expect(state.registeredTools.size).toBe(0)
23+
expect(state.toolOrder).toEqual([])
24+
expect(state.deletedRefs.size).toBe(0)
25+
expect(state.pinnedEdits).toEqual([])
26+
expect(state.toolsSentToAPI).toBe(false)
27+
})
28+
29+
test('registerToolResult tracks tool IDs in order', () => {
30+
registerToolResult(state, 'tool-1')
31+
registerToolResult(state, 'tool-2')
32+
registerToolResult(state, 'tool-3')
33+
expect(state.registeredTools.size).toBe(3)
34+
expect(state.toolOrder).toEqual(['tool-1', 'tool-2', 'tool-3'])
35+
})
36+
37+
test('getToolResultsToDelete returns empty when below threshold', () => {
38+
for (let i = 0; i < 5; i++) {
39+
registerToolResult(state, `tool-${i}`)
40+
}
41+
const toDelete = getToolResultsToDelete(state)
42+
expect(toDelete).toEqual([])
43+
})
44+
45+
test('getToolResultsToDelete returns oldest when above threshold', () => {
46+
for (let i = 0; i < 12; i++) {
47+
registerToolResult(state, `tool-${i}`)
48+
}
49+
const toDelete = getToolResultsToDelete(state)
50+
// Should suggest deleting oldest, keeping recent
51+
expect(toDelete.length).toBeGreaterThan(0)
52+
// Should not include the most recent tools
53+
expect(toDelete).not.toContain('tool-11')
54+
expect(toDelete).not.toContain('tool-10')
55+
})
56+
57+
test('createCacheEditsBlock generates correct structure', () => {
58+
for (let i = 0; i < 12; i++) {
59+
registerToolResult(state, `tool-${i}`)
60+
}
61+
const toDelete = getToolResultsToDelete(state)
62+
const block = createCacheEditsBlock(state, toDelete)
63+
if (block) {
64+
expect(block.type).toBe('cache_edits')
65+
expect(block.edits.length).toBe(toDelete.length)
66+
for (const edit of block.edits) {
67+
expect(edit.type).toBe('delete_tool_result')
68+
expect(typeof edit.tool_use_id).toBe('string')
69+
}
70+
}
71+
})
72+
73+
test('createCacheEditsBlock returns null for empty list', () => {
74+
const block = createCacheEditsBlock(state, [])
75+
expect(block).toBeNull()
76+
})
77+
78+
test('already deleted tools are not suggested again', () => {
79+
for (let i = 0; i < 12; i++) {
80+
registerToolResult(state, `tool-${i}`)
81+
}
82+
const first = getToolResultsToDelete(state)
83+
// Simulate deletion
84+
for (const id of first) {
85+
state.deletedRefs.add(id)
86+
}
87+
const second = getToolResultsToDelete(state)
88+
// Should not re-suggest already deleted
89+
for (const id of first) {
90+
expect(second).not.toContain(id)
91+
}
92+
})
93+
94+
test('markToolsSentToAPI sets flag', () => {
95+
expect(state.toolsSentToAPI).toBe(false)
96+
markToolsSentToAPI(state)
97+
expect(state.toolsSentToAPI).toBe(true)
98+
})
99+
100+
test('resetCachedMCState clears everything', () => {
101+
registerToolResult(state, 'tool-1')
102+
markToolsSentToAPI(state)
103+
resetCachedMCState(state)
104+
expect(state.registeredTools.size).toBe(0)
105+
expect(state.toolOrder).toEqual([])
106+
expect(state.toolsSentToAPI).toBe(false)
107+
})
108+
109+
test('isModelSupportedForCacheEditing accepts Claude 4.x', () => {
110+
expect(isModelSupportedForCacheEditing('claude-opus-4-6')).toBe(true)
111+
expect(isModelSupportedForCacheEditing('claude-sonnet-4-6')).toBe(true)
112+
})
113+
114+
test('isModelSupportedForCacheEditing rejects old models', () => {
115+
expect(isModelSupportedForCacheEditing('claude-2')).toBe(false)
116+
expect(isModelSupportedForCacheEditing('gpt-4')).toBe(false)
117+
})
118+
})

src/services/compact/apiMicrocompact.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,27 +86,24 @@ export function getAPIContextManagement(options?: {
8686
})
8787
}
8888

89-
// Tool clearing strategies are ant-only
90-
if (process.env.USER_TYPE !== 'ant') {
91-
return strategies.length > 0 ? { edits: strategies } : undefined
92-
}
93-
94-
const useClearToolResults = isEnvTruthy(
95-
process.env.USE_API_CLEAR_TOOL_RESULTS,
96-
)
89+
// Tool clearing: default enabled for all users (upstream gates on USER_TYPE=ant).
90+
// Opt out via USE_API_CLEAR_TOOL_RESULTS=0 / USE_API_CLEAR_TOOL_USES=0.
91+
const useClearToolResults =
92+
process.env.USE_API_CLEAR_TOOL_RESULTS !== undefined
93+
? isEnvTruthy(process.env.USE_API_CLEAR_TOOL_RESULTS)
94+
: true
9795
const useClearToolUses = isEnvTruthy(process.env.USE_API_CLEAR_TOOL_USES)
9896

99-
// If no tool clearing strategy is enabled, return early
10097
if (!useClearToolResults && !useClearToolUses) {
10198
return strategies.length > 0 ? { edits: strategies } : undefined
10299
}
103100

104101
if (useClearToolResults) {
105102
const triggerThreshold = process.env.API_MAX_INPUT_TOKENS
106-
? parseInt(process.env.API_MAX_INPUT_TOKENS)
103+
? parseInt(process.env.API_MAX_INPUT_TOKENS, 10)
107104
: DEFAULT_MAX_INPUT_TOKENS
108105
const keepTarget = process.env.API_TARGET_INPUT_TOKENS
109-
? parseInt(process.env.API_TARGET_INPUT_TOKENS)
106+
? parseInt(process.env.API_TARGET_INPUT_TOKENS, 10)
110107
: DEFAULT_TARGET_INPUT_TOKENS
111108

112109
const strategy: ContextEditStrategy = {
@@ -127,10 +124,10 @@ export function getAPIContextManagement(options?: {
127124

128125
if (useClearToolUses) {
129126
const triggerThreshold = process.env.API_MAX_INPUT_TOKENS
130-
? parseInt(process.env.API_MAX_INPUT_TOKENS)
127+
? parseInt(process.env.API_MAX_INPUT_TOKENS, 10)
131128
: DEFAULT_MAX_INPUT_TOKENS
132129
const keepTarget = process.env.API_TARGET_INPUT_TOKENS
133-
? parseInt(process.env.API_TARGET_INPUT_TOKENS)
130+
? parseInt(process.env.API_TARGET_INPUT_TOKENS, 10)
134131
: DEFAULT_TARGET_INPUT_TOKENS
135132

136133
const strategy: ContextEditStrategy = {
Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// Auto-generated stub — replace with real implementation
2-
export {};
3-
41
export type CachedMCState = {
52
registeredTools: Set<string>
63
toolOrder: string[]
@@ -19,19 +16,97 @@ export type PinnedCacheEdits = {
1916
block: CacheEditsBlock
2017
}
2118

22-
export const isCachedMicrocompactEnabled: () => boolean = () => false;
23-
export const isModelSupportedForCacheEditing: (model: string) => boolean = () => false;
24-
export const getCachedMCConfig: () => { triggerThreshold: number; keepRecent: number } = () => ({ triggerThreshold: 0, keepRecent: 0 });
25-
export const createCachedMCState: () => CachedMCState = () => ({
26-
registeredTools: new Set(),
27-
toolOrder: [],
28-
deletedRefs: new Set(),
29-
pinnedEdits: [],
30-
toolsSentToAPI: false,
31-
});
32-
export const markToolsSentToAPI: (state: CachedMCState) => void = () => {};
33-
export const resetCachedMCState: (state: CachedMCState) => void = () => {};
34-
export const registerToolResult: (state: CachedMCState, toolId: string) => void = () => {};
35-
export const registerToolMessage: (state: CachedMCState, groupIds: string[]) => void = () => {};
36-
export const getToolResultsToDelete: (state: CachedMCState) => string[] = () => [];
37-
export const createCacheEditsBlock: (state: CachedMCState, toolIds: string[]) => CacheEditsBlock | null = () => null;
19+
const TRIGGER_THRESHOLD = 10
20+
const KEEP_RECENT = 5
21+
22+
/**
23+
* Returns true when the CLAUDE_CACHED_MICROCOMPACT env var is set to '1'
24+
* or the feature is explicitly enabled.
25+
*/
26+
export function isCachedMicrocompactEnabled(): boolean {
27+
return process.env.CLAUDE_CACHED_MICROCOMPACT === '1'
28+
}
29+
30+
/**
31+
* Returns true for Claude 4.x models that support cache_edits.
32+
*/
33+
export function isModelSupportedForCacheEditing(model: string): boolean {
34+
return /claude-[a-z]+-4[-\d]/.test(model)
35+
}
36+
37+
export function getCachedMCConfig(): {
38+
triggerThreshold: number
39+
keepRecent: number
40+
} {
41+
return { triggerThreshold: TRIGGER_THRESHOLD, keepRecent: KEEP_RECENT }
42+
}
43+
44+
export function createCachedMCState(): CachedMCState {
45+
return {
46+
registeredTools: new Set(),
47+
toolOrder: [],
48+
deletedRefs: new Set(),
49+
pinnedEdits: [],
50+
toolsSentToAPI: false,
51+
}
52+
}
53+
54+
export function markToolsSentToAPI(state: CachedMCState): void {
55+
state.toolsSentToAPI = true
56+
}
57+
58+
export function resetCachedMCState(state: CachedMCState): void {
59+
state.registeredTools.clear()
60+
state.toolOrder = []
61+
state.deletedRefs.clear()
62+
state.pinnedEdits = []
63+
state.toolsSentToAPI = false
64+
}
65+
66+
export function registerToolResult(state: CachedMCState, toolId: string): void {
67+
if (!state.registeredTools.has(toolId)) {
68+
state.registeredTools.add(toolId)
69+
state.toolOrder.push(toolId)
70+
}
71+
}
72+
73+
export function registerToolMessage(
74+
state: CachedMCState,
75+
groupIds: string[],
76+
): void {
77+
for (const id of groupIds) {
78+
registerToolResult(state, id)
79+
}
80+
}
81+
82+
/**
83+
* Returns the tool IDs that should be deleted (oldest first) to bring
84+
* the count below the threshold, excluding already-deleted tools and
85+
* the most recently seen ones.
86+
*/
87+
export function getToolResultsToDelete(state: CachedMCState): string[] {
88+
const { triggerThreshold, keepRecent } = getCachedMCConfig()
89+
const active = state.toolOrder.filter(id => !state.deletedRefs.has(id))
90+
if (active.length <= triggerThreshold) return []
91+
// Keep the last keepRecent tools
92+
const toDelete = active.slice(0, active.length - keepRecent)
93+
return toDelete
94+
}
95+
96+
/**
97+
* Creates a cache_edits block that deletes the given tool result IDs.
98+
* Returns null if toolIds is empty.
99+
*/
100+
export function createCacheEditsBlock(
101+
state: CachedMCState,
102+
toolIds: string[],
103+
): CacheEditsBlock | null {
104+
if (toolIds.length === 0) return null
105+
return {
106+
type: 'cache_edits',
107+
edits: toolIds.map(id => ({
108+
type: 'delete_tool_result',
109+
tool_use_id: id,
110+
})),
111+
}
112+
}

src/services/contextCollapse/index.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface DrainResult {
2727
messages: Message[]
2828
}
2929

30-
export const getStats: () => ContextCollapseStats = (() => ({
30+
export const getStats: () => ContextCollapseStats = () => ({
3131
collapsedSpans: 0,
3232
collapsedMessages: 0,
3333
stagedSpans: 0,
@@ -38,29 +38,38 @@ export const getStats: () => ContextCollapseStats = (() => ({
3838
emptySpawnWarningEmitted: false,
3939
totalEmptySpawns: 0,
4040
},
41-
}));
41+
})
4242

43-
export const isContextCollapseEnabled: () => boolean = (() => false);
43+
let _contextCollapseEnabled = false
4444

45-
export const subscribe: (callback: () => void) => () => void = ((_callback: () => void) => () => {});
45+
export function isContextCollapseEnabled(): boolean {
46+
return _contextCollapseEnabled
47+
}
48+
49+
export const subscribe: (callback: () => void) => () => void =
50+
(_callback: () => void) => () => {}
4651

4752
export const applyCollapsesIfNeeded: (
4853
messages: Message[],
4954
toolUseContext: ToolUseContext,
5055
querySource: QuerySource,
51-
) => Promise<CollapseResult> = (async (messages: Message[]) => ({ messages }));
56+
) => Promise<CollapseResult> = async (messages: Message[]) => ({ messages })
5257

5358
export const isWithheldPromptTooLong: (
5459
message: Message,
5560
isPromptTooLongMessage: (msg: Message) => boolean,
5661
querySource: QuerySource,
57-
) => boolean = (() => false);
62+
) => boolean = () => false
5863

5964
export const recoverFromOverflow: (
6065
messages: Message[],
6166
querySource: QuerySource,
62-
) => DrainResult = ((messages: Message[]) => ({ committed: 0, messages }));
67+
) => DrainResult = (messages: Message[]) => ({ committed: 0, messages })
6368

64-
export const resetContextCollapse: () => void = (() => {});
69+
export function resetContextCollapse(): void {
70+
_contextCollapseEnabled = false
71+
}
6572

66-
export const initContextCollapse: () => void = (() => {});
73+
export function initContextCollapse(): void {
74+
_contextCollapseEnabled = true
75+
}

0 commit comments

Comments
 (0)