Skip to content

Commit 9fcac81

Browse files
refactor: extract pure compaction logic to packages/agent and fix code duplication
Move pure-logic functions (stripImages, grouping, token estimation, config, cached MC state, session memory calc, snip compact core, prompts) into packages/agent/compaction with zero runtime deps via dependency injection. src/services/compact/ files become thin bridge modules that inject real deps. Fixes: - Remove duplicate README.md (345 lines duplicating type docs) - Consolidate dual import/export boilerplate in compact.ts (6 redundant statements) - Clean up re-export formatting in sessionMemoryCompact.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e162a47 commit 9fcac81

45 files changed

Lines changed: 4945 additions & 1225 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* API-based microcompact — 使用原生 context management API 的微压缩。
3+
*
4+
* 纯逻辑:接受 toolName 常量和环境变量函数参数,不直接导入工具模块。
5+
* 构造 clear_tool_uses / clear_thinking 策略,在 API 调用时发送。
6+
*/
7+
8+
import type {
9+
ContextEditStrategy,
10+
ContextManagementConfig,
11+
ToolNameConstants,
12+
} from '../types/compaction.js'
13+
14+
// ── 依赖接口 ──
15+
16+
export interface ApiMicrocompactDeps {
17+
/** 工具名称常量 */
18+
toolNames: ToolNameConstants
19+
/** 读取环境变量 */
20+
getEnv(key: string): string | undefined
21+
}
22+
23+
// ── 常量 ──
24+
25+
// Default values for context management strategies
26+
// Match client-side microcompact token values
27+
const DEFAULT_MAX_INPUT_TOKENS = 180_000
28+
const DEFAULT_TARGET_INPUT_TOKENS = 40_000
29+
30+
// ── 核心 ──
31+
32+
/**
33+
* 获取 API context management 配置。
34+
*
35+
* 根据环境变量和模型能力,构造 clear_tool_uses 和 clear_thinking 策略。
36+
*/
37+
export function getAPIContextManagement(
38+
deps: ApiMicrocompactDeps,
39+
options?: {
40+
hasThinking?: boolean
41+
isRedactThinkingActive?: boolean
42+
clearAllThinking?: boolean
43+
},
44+
): ContextManagementConfig | undefined {
45+
const {
46+
hasThinking = false,
47+
isRedactThinkingActive = false,
48+
clearAllThinking = false,
49+
} = options ?? {}
50+
51+
const strategies: ContextEditStrategy[] = []
52+
53+
// 可清除结果的工具列表
54+
const TOOLS_CLEARABLE_RESULTS = [
55+
...deps.toolNames.shellToolNames,
56+
deps.toolNames.glob,
57+
deps.toolNames.grep,
58+
deps.toolNames.fileRead,
59+
deps.toolNames.webFetch,
60+
deps.toolNames.webSearch,
61+
]
62+
63+
// 可清除 uses 的工具列表
64+
const TOOLS_CLEARABLE_USES = [
65+
deps.toolNames.fileEdit,
66+
deps.toolNames.fileWrite,
67+
deps.toolNames.notebookEdit,
68+
]
69+
70+
// Preserve thinking blocks in previous assistant turns.
71+
if (hasThinking && !isRedactThinkingActive) {
72+
strategies.push({
73+
type: 'clear_thinking_20251015',
74+
keep: clearAllThinking ? { type: 'thinking_turns', value: 1 } : 'all',
75+
})
76+
}
77+
78+
// Tool clearing strategies are ant-only
79+
const userType = deps.getEnv('USER_TYPE')
80+
if (userType !== 'ant') {
81+
return strategies.length > 0 ? { edits: strategies } : undefined
82+
}
83+
84+
const isEnvTruthy = (val: string | undefined): boolean => val === '1' || val === 'true'
85+
86+
const useClearToolResults = isEnvTruthy(deps.getEnv('USE_API_CLEAR_TOOL_RESULTS'))
87+
const useClearToolUses = isEnvTruthy(deps.getEnv('USE_API_CLEAR_TOOL_USES'))
88+
89+
if (!useClearToolResults && !useClearToolUses) {
90+
return strategies.length > 0 ? { edits: strategies } : undefined
91+
}
92+
93+
if (useClearToolResults) {
94+
const triggerThreshold = deps.getEnv('API_MAX_INPUT_TOKENS')
95+
? parseInt(deps.getEnv('API_MAX_INPUT_TOKENS')!)
96+
: DEFAULT_MAX_INPUT_TOKENS
97+
const keepTarget = deps.getEnv('API_TARGET_INPUT_TOKENS')
98+
? parseInt(deps.getEnv('API_TARGET_INPUT_TOKENS')!)
99+
: DEFAULT_TARGET_INPUT_TOKENS
100+
101+
strategies.push({
102+
type: 'clear_tool_uses_20250919',
103+
trigger: { type: 'input_tokens', value: triggerThreshold },
104+
clear_at_least: { type: 'input_tokens', value: triggerThreshold - keepTarget },
105+
clear_tool_inputs: TOOLS_CLEARABLE_RESULTS,
106+
})
107+
}
108+
109+
if (useClearToolUses) {
110+
const triggerThreshold = deps.getEnv('API_MAX_INPUT_TOKENS')
111+
? parseInt(deps.getEnv('API_MAX_INPUT_TOKENS')!)
112+
: DEFAULT_MAX_INPUT_TOKENS
113+
const keepTarget = deps.getEnv('API_TARGET_INPUT_TOKENS')
114+
? parseInt(deps.getEnv('API_TARGET_INPUT_TOKENS')!)
115+
: DEFAULT_TARGET_INPUT_TOKENS
116+
117+
strategies.push({
118+
type: 'clear_tool_uses_20250919',
119+
trigger: { type: 'input_tokens', value: triggerThreshold },
120+
clear_at_least: { type: 'input_tokens', value: triggerThreshold - keepTarget },
121+
exclude_tools: TOOLS_CLEARABLE_USES,
122+
})
123+
}
124+
125+
return strategies.length > 0 ? { edits: strategies } : undefined
126+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* GrowthBook 配置:cached microcompact (cache editing API)。
3+
*
4+
* 纯逻辑:接受 getFeatureValue 函数参数,不直接导入 GrowthBook。
5+
* 从远程配置读取 tengu_cached_microcompact 实验配置,
6+
* 提供 enabled / triggerThreshold / keepRecent / supportedModels 等字段。
7+
* 当远程配置不可用时使用本地默认值;支持环境变量覆盖。
8+
*/
9+
10+
import type { CachedMCConfig } from '../types/compaction.js'
11+
12+
// ── 依赖接口 ──
13+
14+
export interface CachedMCConfigDeps {
15+
/** 读取 GrowthBook 远程配置 */
16+
getFeatureValue<T>(key: string, defaultValue: T): T
17+
/** 读取环境变量 */
18+
getEnv(key: string): string | undefined
19+
}
20+
21+
// ── 默认值 ──
22+
23+
export const DEFAULT_CACHED_MC_CONFIG: CachedMCConfig = {
24+
enabled: true,
25+
triggerThreshold: 20,
26+
keepRecent: 5,
27+
supportedModels: [
28+
'claude-sonnet-4',
29+
'claude-opus-4',
30+
'claude-3-5-sonnet',
31+
'claude-3-7-sonnet',
32+
],
33+
systemPromptSuggestSummaries: false,
34+
}
35+
36+
// ── 核心 ──
37+
38+
/**
39+
* 获取 cached microcompact 的运行时配置。
40+
*
41+
* 优先级:环境变量 > 远程配置 > 本地默认值。
42+
*/
43+
export function getCachedMCConfig(deps: CachedMCConfigDeps): CachedMCConfig {
44+
// 环境变量覆盖(用于测试 / 离线调试)
45+
const envEnabled = deps.getEnv('CLAUDE_CACHED_MC_ENABLED')
46+
if (envEnabled !== undefined) {
47+
return {
48+
enabled: envEnabled === '1',
49+
triggerThreshold:
50+
parseInt(deps.getEnv('CLAUDE_CACHED_MC_TRIGGER') ?? '', 10) ||
51+
DEFAULT_CACHED_MC_CONFIG.triggerThreshold,
52+
keepRecent:
53+
parseInt(deps.getEnv('CLAUDE_CACHED_MC_KEEP_RECENT') ?? '', 10) ||
54+
DEFAULT_CACHED_MC_CONFIG.keepRecent,
55+
supportedModels: DEFAULT_CACHED_MC_CONFIG.supportedModels,
56+
systemPromptSuggestSummaries:
57+
deps.getEnv('CLAUDE_CACHED_MC_SUGGEST_SUMMARIES') === '1',
58+
}
59+
}
60+
61+
// 远程配置
62+
const remoteConfig = deps.getFeatureValue<CachedMCConfig>(
63+
'tengu_cached_microcompact',
64+
DEFAULT_CACHED_MC_CONFIG,
65+
)
66+
return remoteConfig ?? DEFAULT_CACHED_MC_CONFIG
67+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Cached Microcompact 核心:基于 cache editing API 的微压缩。
3+
*
4+
* 纯逻辑:接受 config 参数,不直接导入 GrowthBook 配置。
5+
* 通过 Anthropic 内部 cache_edits API,在不破坏 prompt cache 前缀的前提下,
6+
* 从缓存的上下文中移除旧的 tool_result,从而减小 context 大小。
7+
*/
8+
9+
import type { CachedMCConfig } from '../types/compaction.js'
10+
11+
// ── 类型 ──
12+
13+
export type CachedMCState = {
14+
/** 已注册的 tool_use_id 集合 */
15+
registeredTools: Set<string>
16+
/** tool_use_id 注册顺序(用于 LRU 选择) */
17+
toolOrder: string[]
18+
/** 已被 cache_edit 删除的 tool_use_id */
19+
deletedRefs: Set<string>
20+
/** 缓存已固定到特定 user message 位置的 edits */
21+
pinnedEdits: PinnedCacheEdits[]
22+
/** 标记本轮已发送给 API */
23+
toolsSentToAPI: boolean
24+
}
25+
26+
export type CacheEditsBlock = {
27+
type: 'cache_edits'
28+
edits: Array<{ type: string; tool_use_id: string }>
29+
}
30+
31+
export type PinnedCacheEdits = {
32+
userMessageIndex: number
33+
block: CacheEditsBlock
34+
}
35+
36+
// ── 状态管理 ──
37+
38+
/**
39+
* 创建全新的 cached MC 状态实例。
40+
*/
41+
export function createCachedMCState(): CachedMCState {
42+
return {
43+
registeredTools: new Set(),
44+
toolOrder: [],
45+
deletedRefs: new Set(),
46+
pinnedEdits: [],
47+
toolsSentToAPI: false,
48+
}
49+
}
50+
51+
/**
52+
* 重置状态(保留 pinnedEdits 供后续重放)。
53+
*/
54+
export function resetCachedMCState(state: CachedMCState): void {
55+
state.registeredTools.clear()
56+
state.toolOrder = []
57+
state.deletedRefs.clear()
58+
// pinnedEdits 保留 — 后续 API 调用需要重放已固定的 edits
59+
state.toolsSentToAPI = false
60+
}
61+
62+
/**
63+
* 标记本轮所有已注册工具已发送给 API。
64+
*/
65+
export function markToolsSentToAPI(state: CachedMCState): void {
66+
state.toolsSentToAPI = true
67+
}
68+
69+
// ── 注册 ──
70+
71+
/**
72+
* 注册单个 tool_result 对应的 tool_use_id。
73+
* 忽略重复注册和已删除的引用。
74+
*/
75+
export function registerToolResult(
76+
state: CachedMCState,
77+
toolId: string,
78+
): void {
79+
if (state.deletedRefs.has(toolId)) return
80+
if (state.registeredTools.has(toolId)) return
81+
state.registeredTools.add(toolId)
82+
state.toolOrder.push(toolId)
83+
}
84+
85+
/**
86+
* 注册同一条 user message 内的多个 tool_result 为一组。
87+
*/
88+
export function registerToolMessage(
89+
state: CachedMCState,
90+
groupIds: string[],
91+
): void {
92+
for (const id of groupIds) {
93+
registerToolResult(state, id)
94+
}
95+
}
96+
97+
// ── 删除决策 ──
98+
99+
/**
100+
* 根据配置的阈值,返回应被 cache_edit 删除的 tool_use_id 列表。
101+
*
102+
* 策略:保留最近 keepRecent 个工具,删除较早的。
103+
* 仅当已注册工具总数超过 triggerThreshold 时才触发。
104+
* 已被删除的引用不参与计算。
105+
*/
106+
export function getToolResultsToDelete(
107+
state: CachedMCState,
108+
config: CachedMCConfig,
109+
): string[] {
110+
if (!config.enabled) return []
111+
112+
// 计算当前活跃(未删除)的工具数
113+
const activeTools = state.toolOrder.filter(id => !state.deletedRefs.has(id))
114+
115+
if (activeTools.length < config.triggerThreshold) return []
116+
117+
// 保留最近 keepRecent 个,删除其余
118+
const keepRecent = Math.max(1, config.keepRecent)
119+
if (activeTools.length <= keepRecent) return []
120+
121+
const toDelete = activeTools.slice(0, activeTools.length - keepRecent)
122+
123+
// 立即标记为已删除,避免重复返回
124+
for (const id of toDelete) {
125+
state.deletedRefs.add(id)
126+
}
127+
128+
return toDelete
129+
}
130+
131+
// ── Cache Edits 构造 ──
132+
133+
/**
134+
* 为给定的 tool_use_id 列表构造 cache_edits API 块。
135+
* 将删除的 ID 记录到 state.deletedRefs 中。
136+
*/
137+
export function createCacheEditsBlock(
138+
state: CachedMCState,
139+
toolIds: string[],
140+
): CacheEditsBlock | null {
141+
if (toolIds.length === 0) return null
142+
143+
// 记录删除引用
144+
for (const id of toolIds) {
145+
state.deletedRefs.add(id)
146+
}
147+
148+
return {
149+
type: 'cache_edits',
150+
edits: toolIds.map(id => ({
151+
type: 'delete_tool_result',
152+
tool_use_id: id,
153+
})),
154+
}
155+
}
156+
157+
// ── 开关查询 ──
158+
159+
/**
160+
* 检查 cached microcompact 是否启用。
161+
*/
162+
export function isCachedMicrocompactEnabled(config: CachedMCConfig): boolean {
163+
return config.enabled
164+
}
165+
166+
/**
167+
* 检查给定模型是否支持 cache editing API。
168+
*/
169+
export function isModelSupportedForCacheEditing(
170+
model: string,
171+
config: CachedMCConfig,
172+
): boolean {
173+
if (config.supportedModels.length === 0) return true // 无白名单 = 全部支持
174+
return config.supportedModels.some(
175+
prefix => model === prefix || model.startsWith(prefix),
176+
)
177+
}
178+
179+
/**
180+
* 获取当前 cached MC 运行时配置(简化版,只含阈值和保留数)。
181+
*/
182+
export function getCachedMCSimpleConfig(config: CachedMCConfig): {
183+
triggerThreshold: number
184+
keepRecent: number
185+
} {
186+
return {
187+
triggerThreshold: config.triggerThreshold,
188+
keepRecent: config.keepRecent,
189+
}
190+
}

0 commit comments

Comments
 (0)