Skip to main content

katu_core/
compaction.rs

1//! # katu_core::compaction
2//!
3//! ## 职责
4//! 定义上下文压缩(Compaction)的配置与数据类型。
5//!
6//! ## 设计来源
7//! 综合 oh-my-pi、opencode、claude-code 三个项目的压缩系统设计:
8//!
9//! | 维度         | oh-my-pi           | opencode        | claude-code     | katu          |
10//! |-------------|--------------------|-----------------|-----------------| --------------|
11//! | 触发方式     | threshold+overflow | 仅 overflow     | threshold       | 可配置        |
12//! | 阈值模型     | 百分比/固定/reserve | 固定 buffer     | effectiveWindow | 统一三种      |
13//! | 保留策略     | keepRecentTokens   | tail_turns+tokens| 无             | turns+tokens  |
14//! | 修剪(Prune)  | 无                 | 旧工具输出修剪   | 无             | 可配置        |
15//! | 策略         | summarize/handoff  | summarize       | summarize       | 可扩展        |
16//! | 熔断器       | 无                 | 无              | 3次失败         | 可配置        |
17//! | 压缩模型     | 可选               | compaction agent | 主模型          | 可选          |
18//!
19//! ## 分层原则
20//! - **katu-core(本模块)** — 纯数据配置、结果类型、token 状态枚举
21//! - **katu-agent(future)** — 运行时压缩逻辑、overflow 检测、LLM 调用、状态机
22//!
23//! ## 对外接口
24//! - `CompactionConfig` — 压缩完整配置
25//! - `CompactionThreshold` — 阈值配置
26//! - `CompactionTriggerMode` — 触发模式
27//! - `CompactionStrategy` — 压缩策略
28//! - `PreserveConfig` — 消息保留策略
29//! - `PruneConfig` — 旧工具输出修剪配置
30//! - `CompactionResult` — 压缩执行结果
31//! - `TokenBudgetState` — token 用量警告状态
32//!
33//! ## 调用者
34//! - `katu-agent` (future) — Agent loop 读取配置驱动压缩
35//! - `AgentDefinition` (future) — 可选嵌入 CompactionConfig
36//! - UI 层 — 展示 TokenBudgetState 进度条
37
38use serde::{Deserialize, Serialize};
39
40use crate::agent::AgentModelRef;
41
42// ===========================================================================
43// CompactionConfig
44// ===========================================================================
45
46/// 上下文压缩完整配置。
47///
48/// 控制 Agent loop 何时触发压缩、如何保留近期上下文、是否修剪旧内容、
49/// 以及压缩失败时的熔断行为。
50///
51/// ## 配置合并优先级
52/// ```text
53/// AgentDefinition.compaction > SessionConfig.compaction > 全局默认
54/// ```
55///
56/// # Examples
57///
58/// ```
59/// use katu_core::compaction::CompactionConfig;
60///
61/// // 默认配置:自动压缩开启,threshold 模式
62/// let config = CompactionConfig::default();
63/// assert!(config.auto_enabled);
64///
65/// // 禁用自动压缩
66/// let manual_only = CompactionConfig::default().with_auto_enabled(false);
67/// assert!(!manual_only.auto_enabled);
68///
69/// // 只在 overflow 时被动压缩(opencode 风格)
70/// use katu_core::compaction::CompactionTriggerMode;
71/// let passive = CompactionConfig::default()
72///     .with_trigger_mode(CompactionTriggerMode::Overflow);
73/// ```
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct CompactionConfig {
76    // ── 开关 ──
77
78    /// 是否启用自动压缩。
79    ///
80    /// false 时仅支持手动触发(如 `/compact` 命令)。
81    /// 三个参考项目都默认开启。
82    pub auto_enabled: bool,
83
84    // ── 触发 ──
85
86    /// 触发模式 — 何时启动自动压缩。
87    pub trigger_mode: CompactionTriggerMode,
88
89    /// 阈值配置 — 在 Threshold 模式下生效。
90    pub threshold: CompactionThreshold,
91
92    /// 为输出预留的 token 缓冲。
93    ///
94    /// 阈值 fallback 计算: threshold = context_window - reserve_tokens。
95    /// 同时确保压缩过程本身不会因为摘要输出而 overflow。
96    ///
97    /// - oh-my-pi: 16,384
98    /// - opencode: min(20,000, max_output_tokens)
99    /// - claude-code: 13,000 (auto) / 3,000 (manual)
100    pub reserve_tokens: u64,
101
102    // ── 保留策略 ──
103
104    /// 消息保留策略 — 压缩时哪些近期内容保持原文不总结。
105    pub preserve: PreserveConfig,
106
107    // ── 修剪 ──
108
109    /// 旧工具输出修剪配置。
110    ///
111    /// 独立于压缩的轻量级优化:截断旧工具调用的输出内容,
112    /// 释放 token 空间,延迟全量压缩的触发。
113    /// 来源: opencode 的 prune 机制。
114    pub prune: PruneConfig,
115
116    // ── 策略 ──
117
118    /// 压缩策略 — 如何处理旧消息。
119    pub strategy: CompactionStrategy,
120
121    // ── 行为 ──
122
123    /// 压缩完成后是否自动继续 Agent loop。
124    ///
125    /// true: 压缩后自动发送 "continue" 消息继续执行。
126    /// false: 压缩后等待用户输入。
127    /// oh-my-pi 和 opencode 都默认 true。
128    pub auto_continue: bool,
129
130    /// 连续失败熔断次数。
131    ///
132    /// 连续 N 次自动压缩失败后停止尝试,防止无限循环。
133    /// 手动压缩不受此限制。
134    /// 来源: claude-code 的 MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3。
135    /// 0 = 不限制。
136    pub max_consecutive_failures: u32,
137
138    // ── 压缩模型 ──
139
140    /// 用于执行压缩摘要的模型。
141    ///
142    /// None = 使用 Agent 当前的主模型。
143    /// 来源: opencode 有独立的 "compaction" agent 配置。
144    pub model: Option<AgentModelRef>,
145
146    // ── 摘要输出 ──
147
148    /// 摘要的最大输出 token 数。
149    ///
150    /// 限制 LLM 生成摘要时的输出长度。
151    /// 来源: claude-code MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20,000。
152    pub summary_max_tokens: Option<u32>,
153}
154
155impl Default for CompactionConfig {
156    fn default() -> Self {
157        Self {
158            auto_enabled: true,
159            trigger_mode: CompactionTriggerMode::default(),
160            threshold: CompactionThreshold::default(),
161            reserve_tokens: 16_384,
162            preserve: PreserveConfig::default(),
163            prune: PruneConfig::default(),
164            strategy: CompactionStrategy::default(),
165            auto_continue: true,
166            max_consecutive_failures: 3,
167            model: None,
168            summary_max_tokens: Some(20_000),
169        }
170    }
171}
172
173impl CompactionConfig {
174    /// 设置自动压缩开关。
175    pub fn with_auto_enabled(mut self, enabled: bool) -> Self {
176        self.auto_enabled = enabled;
177        self
178    }
179
180    /// 设置触发模式。
181    pub fn with_trigger_mode(mut self, mode: CompactionTriggerMode) -> Self {
182        self.trigger_mode = mode;
183        self
184    }
185
186    /// 设置阈值配置。
187    pub fn with_threshold(mut self, threshold: CompactionThreshold) -> Self {
188        self.threshold = threshold;
189        self
190    }
191
192    /// 设置预留 token 数。
193    pub fn with_reserve_tokens(mut self, tokens: u64) -> Self {
194        self.reserve_tokens = tokens;
195        self
196    }
197
198    /// 设置消息保留策略。
199    pub fn with_preserve(mut self, preserve: PreserveConfig) -> Self {
200        self.preserve = preserve;
201        self
202    }
203
204    /// 设置修剪配置。
205    pub fn with_prune(mut self, prune: PruneConfig) -> Self {
206        self.prune = prune;
207        self
208    }
209
210    /// 设置压缩策略。
211    pub fn with_strategy(mut self, strategy: CompactionStrategy) -> Self {
212        self.strategy = strategy;
213        self
214    }
215
216    /// 设置是否压缩后自动继续。
217    pub fn with_auto_continue(mut self, auto_continue: bool) -> Self {
218        self.auto_continue = auto_continue;
219        self
220    }
221
222    /// 设置连续失败熔断次数。
223    pub fn with_max_consecutive_failures(mut self, max: u32) -> Self {
224        self.max_consecutive_failures = max;
225        self
226    }
227
228    /// 设置压缩模型。
229    pub fn with_model(mut self, model: AgentModelRef) -> Self {
230        self.model = Some(model);
231        self
232    }
233
234    /// 设置摘要最大输出 token 数。
235    pub fn with_summary_max_tokens(mut self, tokens: u32) -> Self {
236        self.summary_max_tokens = Some(tokens);
237        self
238    }
239}
240
241// ===========================================================================
242// CompactionTriggerMode
243// ===========================================================================
244
245/// 压缩触发模式 — 决定何时启动自动压缩。
246///
247/// 两种模式对应不同的产品哲学:
248/// - `Threshold`: 主动式 — 接近上限时提前压缩,避免 overflow(oh-my-pi, claude-code)
249/// - `Overflow`: 被动式 — 仅在实际溢出时才压缩,最大化上下文利用率(opencode)
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum CompactionTriggerMode {
253    /// 达到阈值时主动压缩。
254    ///
255    /// 在 token 用量超过阈值(百分比或固定值)时触发,
256    /// 留出足够空间完成当前对话而不 overflow。
257    /// 这是 oh-my-pi 和 claude-code 的做法。
258    #[default]
259    Threshold,
260
261    /// 仅在 context overflow 时被动压缩。
262    ///
263    /// 不提前触发,等到 LLM 返回 prompt-too-long 错误时才压缩。
264    /// 最大化上下文利用率,但用户可能感知到短暂中断。
265    /// 这是 opencode 的做法。
266    Overflow,
267}
268
269// ===========================================================================
270// CompactionThreshold
271// ===========================================================================
272
273/// 压缩阈值配置 — 控制在 Threshold 模式下何时触发。
274///
275/// ## 解析优先级(与 oh-my-pi 一致)
276/// ```text
277/// tokens (固定值) > ratio (百分比) > fallback (context_window - reserve_tokens)
278/// ```
279///
280/// # Examples
281///
282/// ```
283/// use katu_core::compaction::CompactionThreshold;
284///
285/// // 固定阈值: 超过 150K tokens 时触发
286/// let fixed = CompactionThreshold::fixed(150_000);
287///
288/// // 百分比阈值: 超过 context window 的 85% 时触发
289/// let ratio = CompactionThreshold::ratio(0.85);
290///
291/// // 默认: 都不设 — 使用 fallback (context_window - reserve_tokens)
292/// let fallback = CompactionThreshold::default();
293/// ```
294#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
295pub struct CompactionThreshold {
296    /// 固定 token 阈值 — 优先级最高。
297    ///
298    /// 当 context_tokens > tokens 时触发压缩。
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub tokens: Option<u64>,
301
302    /// 百分比阈值 (0.0 ~ 1.0) — tokens 未设置时使用。
303    ///
304    /// 当 context_tokens > context_window * ratio 时触发。
305    /// 来源: oh-my-pi thresholdPercent。
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub ratio: Option<f64>,
308}
309
310impl CompactionThreshold {
311    /// 创建固定 token 阈值。
312    pub fn fixed(tokens: u64) -> Self {
313        Self {
314            tokens: Some(tokens),
315            ratio: None,
316        }
317    }
318
319    /// 创建百分比阈值。
320    pub fn ratio(ratio: f64) -> Self {
321        Self {
322            tokens: None,
323            ratio: Some(ratio),
324        }
325    }
326
327    /// 解析最终阈值 token 数。
328    ///
329    /// ## 优先级
330    /// 1. `self.tokens` — 固定值,直接返回(clamp 到 [1, context_window-1])
331    /// 2. `self.ratio` — 百分比,返回 `context_window * ratio`
332    /// 3. fallback — `context_window - reserve_tokens`
333    pub fn resolve(&self, context_window: u64, reserve_tokens: u64) -> u64 {
334        // 固定值优先
335        if let Some(tokens) = self.tokens {
336            return tokens.clamp(1, context_window.saturating_sub(1));
337        }
338
339        // 百分比
340        if let Some(ratio) = self.ratio {
341            let clamped = ratio.clamp(0.01, 0.99);
342            return (context_window as f64 * clamped) as u64;
343        }
344
345        // Fallback: context_window - max(reserve_tokens, 15% of window)
346        // 与 oh-my-pi 的 effectiveReserveTokens 一致
347        let effective_reserve = reserve_tokens.max((context_window as f64 * 0.15) as u64);
348        context_window.saturating_sub(effective_reserve)
349    }
350}
351
352// ===========================================================================
353// PreserveConfig
354// ===========================================================================
355
356/// 消息保留策略 — 压缩时保留哪些近期内容不总结。
357///
358/// 保留的消息保持原始内容,不被 LLM 重新摘要。
359/// 这对保留最近的工具调用上下文、用户指令尤其重要。
360///
361/// ## 两种维度
362/// - **turns** — 按 user turn 数量保留(opencode tail_turns=2)
363/// - **tokens** — 按 token 预算保留(oh-my-pi keepRecentTokens=20K)
364///
365/// 两者取 **交集**:先按 turns 选出候选,再按 tokens 预算裁剪。
366///
367/// # Examples
368///
369/// ```
370/// use katu_core::compaction::PreserveConfig;
371///
372/// // 保留最近 2 个 user turn,最多 8K tokens
373/// let config = PreserveConfig::new(2, 8_000);
374/// ```
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376pub struct PreserveConfig {
377    /// 保留最近 N 个 user turn(含其后续的 assistant/tool 回复)。
378    ///
379    /// 0 = 不按 turn 保留。
380    /// 来源: opencode DEFAULT_TAIL_TURNS = 2。
381    pub recent_turns: u32,
382
383    /// 保留最近内容的 token 预算上限。
384    ///
385    /// None = 自动计算(usable_tokens * 0.25,clamp 到 2K~8K)。
386    /// 来源: oh-my-pi keepRecentTokens=20K, opencode preserve_recent_tokens。
387    pub recent_tokens: Option<u64>,
388}
389
390impl Default for PreserveConfig {
391    fn default() -> Self {
392        Self {
393            recent_turns: 2,
394            recent_tokens: None, // 自动计算
395        }
396    }
397}
398
399impl PreserveConfig {
400    /// 创建保留配置。
401    pub fn new(recent_turns: u32, recent_tokens: u64) -> Self {
402        Self {
403            recent_turns,
404            recent_tokens: Some(recent_tokens),
405        }
406    }
407
408    /// 设置 turn 数量。
409    pub fn with_recent_turns(mut self, turns: u32) -> Self {
410        self.recent_turns = turns;
411        self
412    }
413
414    /// 设置 token 预算。
415    pub fn with_recent_tokens(mut self, tokens: u64) -> Self {
416        self.recent_tokens = Some(tokens);
417        self
418    }
419
420    /// 解析最终的保留 token 预算。
421    ///
422    /// 如果 `recent_tokens` 已设置,直接返回。
423    /// 否则自动计算:`usable_tokens * 0.25`,clamp 到 [min, max]。
424    ///
425    /// # Arguments
426    /// - `usable_tokens`: 可用 token 数(context_window - reserve - output)
427    /// - `min`: 最小保留(默认 2,000)
428    /// - `max`: 最大保留(默认 8,000)
429    pub fn resolve_recent_tokens(&self, usable_tokens: u64, min: u64, max: u64) -> u64 {
430        self.recent_tokens.unwrap_or_else(|| {
431            let auto = (usable_tokens as f64 * 0.25) as u64;
432            auto.clamp(min, max)
433        })
434    }
435}
436
437// ===========================================================================
438// PruneConfig
439// ===========================================================================
440
441/// 旧工具输出修剪配置。
442///
443/// Prune 是一种**轻量级**的上下文优化手段,独立于全量压缩:
444/// 把旧的、体积大的工具输出内容截断或标记为已压缩,
445/// 释放 token 空间,延迟全量压缩的触发。
446///
447/// ## 算法(来源: opencode)
448/// 1. 从最新消息向旧遍历,跳过最近 2 个 user turn
449/// 2. 累计 tool output tokens,超过 `protect_tokens` 后开始标记
450/// 3. 仅在总修剪量超过 `minimum_tokens` 时才实际执行
451///
452/// # Examples
453///
454/// ```
455/// use katu_core::compaction::PruneConfig;
456///
457/// let config = PruneConfig::default();
458/// assert!(config.enabled);
459/// assert_eq!(config.protect_tokens, 40_000);
460/// ```
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462pub struct PruneConfig {
463    /// 是否启用修剪。
464    pub enabled: bool,
465
466    /// 保护最近的 tool output token 数不被修剪。
467    ///
468    /// 从最新往旧遍历,累计超过此值后才开始标记修剪。
469    /// 来源: opencode PRUNE_PROTECT = 40,000。
470    pub protect_tokens: u64,
471
472    /// 修剪的最小触发阈值。
473    ///
474    /// 仅当可修剪量超过此值时才执行,避免无意义的小量修剪。
475    /// 来源: opencode PRUNE_MINIMUM = 20,000。
476    pub minimum_tokens: u64,
477
478    /// 修剪时工具输出截断的最大字符数。
479    ///
480    /// 超过此长度的工具输出在修剪时被截断。
481    /// 来源: opencode TOOL_OUTPUT_MAX_CHARS = 2,000。
482    pub tool_output_max_chars: usize,
483
484    /// 不受修剪影响的工具名称列表。
485    ///
486    /// 某些工具(如 skill)的输出对上下文非常重要,不应被修剪。
487    /// 来源: opencode PRUNE_PROTECTED_TOOLS = ["skill"]。
488    #[serde(default)]
489    pub protected_tools: Vec<String>,
490}
491
492impl Default for PruneConfig {
493    fn default() -> Self {
494        Self {
495            enabled: true,
496            protect_tokens: 40_000,
497            minimum_tokens: 20_000,
498            tool_output_max_chars: 2_000,
499            protected_tools: Vec::new(),
500        }
501    }
502}
503
504impl PruneConfig {
505    /// 设置修剪开关。
506    pub fn with_enabled(mut self, enabled: bool) -> Self {
507        self.enabled = enabled;
508        self
509    }
510
511    /// 设置保护 token 数。
512    pub fn with_protect_tokens(mut self, tokens: u64) -> Self {
513        self.protect_tokens = tokens;
514        self
515    }
516
517    /// 设置最小触发阈值。
518    pub fn with_minimum_tokens(mut self, tokens: u64) -> Self {
519        self.minimum_tokens = tokens;
520        self
521    }
522
523    /// 设置工具输出截断字符数。
524    pub fn with_tool_output_max_chars(mut self, chars: usize) -> Self {
525        self.tool_output_max_chars = chars;
526        self
527    }
528
529    /// 添加受保护的工具。
530    pub fn add_protected_tool(mut self, tool: impl Into<String>) -> Self {
531        self.protected_tools.push(tool.into());
532        self
533    }
534}
535
536// ===========================================================================
537// CompactionStrategy
538// ===========================================================================
539
540/// 压缩策略 — 旧消息如何被处理。
541///
542/// # Examples
543///
544/// ```
545/// use katu_core::compaction::CompactionStrategy;
546///
547/// let strategy = CompactionStrategy::Summarize;
548/// assert!(strategy.is_summarize());
549/// ```
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
551#[serde(rename_all = "snake_case")]
552pub enum CompactionStrategy {
553    /// 用 LLM 总结旧消息,就地替换为摘要。
554    ///
555    /// 最常见的策略,三个项目都支持。
556    /// 旧消息被丢弃,摘要作为新的 system/user message 注入。
557    #[default]
558    Summarize,
559
560    /// 生成 handoff 文档,开始新会话。
561    ///
562    /// 将旧对话总结为一个完整的 "交接文档",然后开启新 session。
563    /// 来源: oh-my-pi strategy="handoff"。
564    Handoff,
565}
566
567impl CompactionStrategy {
568    /// 是否为 Summarize 策略。
569    pub fn is_summarize(&self) -> bool {
570        matches!(self, Self::Summarize)
571    }
572
573    /// 是否为 Handoff 策略。
574    pub fn is_handoff(&self) -> bool {
575        matches!(self, Self::Handoff)
576    }
577}
578
579// ===========================================================================
580// CompactionResult
581// ===========================================================================
582
583/// 压缩执行结果 — 一次压缩操作完成后的数据。
584///
585/// 由 `katu-agent` 层的压缩逻辑产出,用于:
586/// - `AgentEvent::CompactionEnded` 事件
587/// - 持久化到 session 历史
588/// - UI 展示压缩效果
589///
590/// # Examples
591///
592/// ```
593/// use katu_core::compaction::{CompactionResult, CompactTrigger};
594///
595/// let result = CompactionResult {
596///     summary: "User asked about Rust ownership...".into(),
597///     short_summary: Some("Discussed Rust ownership".into()),
598///     trigger: CompactTrigger::Auto,
599///     tokens_before: 150_000,
600///     tokens_after: Some(5_000),
601///     messages_compacted: 42,
602///     messages_kept: 8,
603///     success: true,
604/// };
605/// assert!(result.success);
606/// ```
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608pub struct CompactionResult {
609    /// 压缩生成的完整摘要文本。
610    pub summary: String,
611
612    /// 短摘要(用于 UI 显示,类似 PR title)。
613    ///
614    /// 来源: oh-my-pi shortSummary。
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub short_summary: Option<String>,
617
618    /// 触发原因。
619    pub trigger: CompactTrigger,
620
621    /// 压缩前的 prompt token 数。
622    pub tokens_before: u64,
623
624    /// 压缩后的估计 token 数。
625    ///
626    /// None = 未测量。
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub tokens_after: Option<u64>,
629
630    /// 被压缩掉的消息数。
631    pub messages_compacted: usize,
632
633    /// 保留不变的消息数(recent turns)。
634    pub messages_kept: usize,
635
636    /// 是否成功。
637    ///
638    /// false 时 summary 可能包含错误信息。
639    pub success: bool,
640}
641
642impl CompactionResult {
643    /// 计算节省的 token 数。
644    pub fn tokens_saved(&self) -> Option<u64> {
645        self.tokens_after
646            .map(|after| self.tokens_before.saturating_sub(after))
647    }
648
649    /// 计算压缩比 (0.0 ~ 1.0)。
650    ///
651    /// 0.0 = 完全没减少,1.0 = 全部压缩掉。
652    pub fn compression_ratio(&self) -> Option<f64> {
653        self.tokens_after.map(|after| {
654            if self.tokens_before == 0 {
655                return 0.0;
656            }
657            1.0 - (after as f64 / self.tokens_before as f64)
658        })
659    }
660}
661
662// ===========================================================================
663// CompactTrigger (moved from agent_event)
664// ===========================================================================
665
666/// 上下文压缩触发方式。
667///
668/// 用于 `AgentEvent::CompactionStarted` 和 `CompactionResult`,
669/// 标识本次压缩是如何被触发的。
670///
671/// # Examples
672///
673/// ```
674/// use katu_core::compaction::CompactTrigger;
675///
676/// let trigger = CompactTrigger::Auto;
677/// assert!(trigger.is_auto());
678/// ```
679#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
680#[serde(rename_all = "snake_case")]
681pub enum CompactTrigger {
682    /// 自动触发 — token 用量超过阈值。
683    Auto,
684
685    /// 用户手动触发 — 如 `/compact` 命令。
686    Manual,
687
688    /// Overflow 触发 — LLM 返回 prompt-too-long。
689    ///
690    /// 与 Auto 不同:Auto 是提前预防,Overflow 是事后补救。
691    /// 来源: opencode overflow 标志、oh-my-pi "overflow" reason。
692    Overflow,
693
694    /// 空闲触发 — 用户一段时间无操作后预压缩。
695    ///
696    /// 来源: oh-my-pi idleEnabled + idleTimeoutSeconds。
697    Idle,
698}
699
700impl CompactTrigger {
701    /// 是否为自动触发(Auto 或 Overflow 或 Idle)。
702    pub fn is_auto(&self) -> bool {
703        !matches!(self, Self::Manual)
704    }
705
706    /// 是否为手动触发。
707    pub fn is_manual(&self) -> bool {
708        matches!(self, Self::Manual)
709    }
710}
711
712// ===========================================================================
713// TokenBudgetState
714// ===========================================================================
715
716/// Token 用量状态 — 当前上下文占用量的分级警告。
717///
718/// UI 层用此枚举渲染进度条颜色和警告提示。
719/// Agent loop 用此判断是否触发自动压缩。
720///
721/// ## 阈值对照(来源: claude-code)
722/// ```text
723/// |------ Normal ------|-- Warning --|-- Error --|-- Blocking --|
724/// 0%                 ~70%          ~85%        ~95%          100%
725/// ```
726///
727/// # Examples
728///
729/// ```
730/// use katu_core::compaction::TokenBudgetState;
731///
732/// let state = TokenBudgetState::from_usage(150_000, 200_000, 13_000);
733/// ```
734#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
735#[serde(tag = "level", rename_all = "snake_case")]
736pub enum TokenBudgetState {
737    /// 正常 — 充足余量。
738    Normal {
739        /// 剩余可用百分比 (0.0 ~ 1.0)。
740        percent_remaining: f64,
741    },
742
743    /// 警告 — 接近阈值,UI 显示黄色提示。
744    Warning {
745        percent_remaining: f64,
746    },
747
748    /// 危险 — 非常接近上限,UI 显示红色提示。
749    Error {
750        percent_remaining: f64,
751    },
752
753    /// 阻塞 — 已达到上限,应阻止新消息发送。
754    Blocking,
755}
756
757impl TokenBudgetState {
758    /// 根据当前 token 用量计算状态。
759    ///
760    /// # Arguments
761    /// - `used_tokens`: 当前已使用的 token 数
762    /// - `context_window`: 模型 context window 大小
763    /// - `auto_compact_buffer`: 自动压缩缓冲区大小(reserve_tokens)
764    ///
765    /// # 阈值计算
766    /// ```text
767    /// effective_window = context_window - summary_reserve (通常 20K)
768    /// auto_compact_threshold = effective_window - auto_compact_buffer
769    /// warning_threshold = auto_compact_threshold - 20K
770    /// error_threshold = effective_window - 20K
771    /// ```
772    pub fn from_usage(
773        used_tokens: u64,
774        context_window: u64,
775        auto_compact_buffer: u64,
776    ) -> Self {
777        if context_window == 0 {
778            return Self::Blocking;
779        }
780
781        let percent_remaining = 1.0 - (used_tokens as f64 / context_window as f64);
782
783        // 阻塞: 已达到或超过 context window
784        if used_tokens >= context_window {
785            return Self::Blocking;
786        }
787
788        // 自动压缩阈值
789        let auto_threshold = context_window.saturating_sub(auto_compact_buffer);
790
791        // 错误阈值: 距离 context window 20K
792        let error_threshold = context_window.saturating_sub(20_000);
793
794        // 警告阈值: 距离自动压缩阈值 20K
795        let warning_threshold = auto_threshold.saturating_sub(20_000);
796
797        if used_tokens >= error_threshold {
798            Self::Error { percent_remaining }
799        } else if used_tokens >= warning_threshold {
800            Self::Warning { percent_remaining }
801        } else {
802            Self::Normal { percent_remaining }
803        }
804    }
805
806    /// 是否应触发自动压缩。
807    pub fn should_auto_compact(&self) -> bool {
808        matches!(self, Self::Error { .. } | Self::Blocking)
809    }
810
811    /// 是否应阻止新消息发送。
812    pub fn is_blocking(&self) -> bool {
813        matches!(self, Self::Blocking)
814    }
815
816    /// 是否处于警告或更严重状态。
817    pub fn is_warning_or_worse(&self) -> bool {
818        !matches!(self, Self::Normal { .. })
819    }
820}
821
822// ===========================================================================
823// Tests
824// ===========================================================================
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    // -- CompactionConfig --
831
832    #[test]
833    fn test_default_config() {
834        let config = CompactionConfig::default();
835        assert!(config.auto_enabled);
836        assert_eq!(config.trigger_mode, CompactionTriggerMode::Threshold);
837        assert_eq!(config.reserve_tokens, 16_384);
838        assert_eq!(config.preserve.recent_turns, 2);
839        assert!(config.prune.enabled);
840        assert_eq!(config.strategy, CompactionStrategy::Summarize);
841        assert!(config.auto_continue);
842        assert_eq!(config.max_consecutive_failures, 3);
843        assert!(config.model.is_none());
844        assert_eq!(config.summary_max_tokens, Some(20_000));
845    }
846
847    #[test]
848    fn test_config_builder() {
849        let config = CompactionConfig::default()
850            .with_auto_enabled(false)
851            .with_trigger_mode(CompactionTriggerMode::Overflow)
852            .with_reserve_tokens(20_000)
853            .with_auto_continue(false)
854            .with_max_consecutive_failures(5);
855
856        assert!(!config.auto_enabled);
857        assert_eq!(config.trigger_mode, CompactionTriggerMode::Overflow);
858        assert_eq!(config.reserve_tokens, 20_000);
859        assert!(!config.auto_continue);
860        assert_eq!(config.max_consecutive_failures, 5);
861    }
862
863    #[test]
864    fn test_config_serde_roundtrip() {
865        let config = CompactionConfig::default()
866            .with_strategy(CompactionStrategy::Handoff)
867            .with_prune(PruneConfig::default().with_enabled(false));
868
869        let json = serde_json::to_string(&config).unwrap();
870        let restored: CompactionConfig = serde_json::from_str(&json).unwrap();
871        assert_eq!(config, restored);
872    }
873
874    // -- CompactionThreshold --
875
876    #[test]
877    fn test_threshold_fixed() {
878        let t = CompactionThreshold::fixed(150_000);
879        assert_eq!(t.resolve(200_000, 16_384), 150_000);
880    }
881
882    #[test]
883    fn test_threshold_fixed_clamp() {
884        let t = CompactionThreshold::fixed(300_000);
885        // clamp to context_window - 1
886        assert_eq!(t.resolve(200_000, 16_384), 199_999);
887    }
888
889    #[test]
890    fn test_threshold_ratio() {
891        let t = CompactionThreshold::ratio(0.85);
892        assert_eq!(t.resolve(200_000, 16_384), 170_000);
893    }
894
895    #[test]
896    fn test_threshold_fallback() {
897        let t = CompactionThreshold::default();
898        // fallback = context_window - max(reserve, 15% of window)
899        // max(16_384, 200_000 * 0.15 = 30_000) = 30_000
900        // 200_000 - 30_000 = 170_000
901        assert_eq!(t.resolve(200_000, 16_384), 170_000);
902    }
903
904    #[test]
905    fn test_threshold_fallback_small_window() {
906        let t = CompactionThreshold::default();
907        // max(16_384, 50_000 * 0.15 = 7_500) = 16_384
908        // 50_000 - 16_384 = 33_616
909        assert_eq!(t.resolve(50_000, 16_384), 33_616);
910    }
911
912    // -- PreserveConfig --
913
914    #[test]
915    fn test_preserve_default() {
916        let p = PreserveConfig::default();
917        assert_eq!(p.recent_turns, 2);
918        assert!(p.recent_tokens.is_none());
919    }
920
921    #[test]
922    fn test_preserve_resolve_auto() {
923        let p = PreserveConfig::default();
924        // usable = 100_000, auto = 25_000, clamp to [2K, 8K] => 8_000
925        assert_eq!(p.resolve_recent_tokens(100_000, 2_000, 8_000), 8_000);
926        // usable = 4_000, auto = 1_000, clamp to [2K, 8K] => 2_000
927        assert_eq!(p.resolve_recent_tokens(4_000, 2_000, 8_000), 2_000);
928        // usable = 20_000, auto = 5_000, clamp to [2K, 8K] => 5_000
929        assert_eq!(p.resolve_recent_tokens(20_000, 2_000, 8_000), 5_000);
930    }
931
932    #[test]
933    fn test_preserve_resolve_explicit() {
934        let p = PreserveConfig::new(3, 15_000);
935        // explicit 值直接返回,不受 clamp 影响
936        assert_eq!(p.resolve_recent_tokens(100_000, 2_000, 8_000), 15_000);
937    }
938
939    // -- PruneConfig --
940
941    #[test]
942    fn test_prune_default() {
943        let p = PruneConfig::default();
944        assert!(p.enabled);
945        assert_eq!(p.protect_tokens, 40_000);
946        assert_eq!(p.minimum_tokens, 20_000);
947        assert_eq!(p.tool_output_max_chars, 2_000);
948        assert!(p.protected_tools.is_empty());
949    }
950
951    #[test]
952    fn test_prune_builder() {
953        let p = PruneConfig::default()
954            .with_enabled(false)
955            .with_protect_tokens(50_000)
956            .add_protected_tool("skill")
957            .add_protected_tool("memory");
958
959        assert!(!p.enabled);
960        assert_eq!(p.protect_tokens, 50_000);
961        assert_eq!(p.protected_tools, vec!["skill", "memory"]);
962    }
963
964    // -- CompactionStrategy --
965
966    #[test]
967    fn test_strategy_predicates() {
968        assert!(CompactionStrategy::Summarize.is_summarize());
969        assert!(!CompactionStrategy::Summarize.is_handoff());
970        assert!(CompactionStrategy::Handoff.is_handoff());
971        assert!(!CompactionStrategy::Handoff.is_summarize());
972    }
973
974    #[test]
975    fn test_strategy_serde() {
976        let json = serde_json::to_string(&CompactionStrategy::Handoff).unwrap();
977        assert_eq!(json, r#""handoff""#);
978        let restored: CompactionStrategy = serde_json::from_str(&json).unwrap();
979        assert_eq!(restored, CompactionStrategy::Handoff);
980    }
981
982    // -- CompactTrigger --
983
984    #[test]
985    fn test_trigger_is_auto() {
986        assert!(CompactTrigger::Auto.is_auto());
987        assert!(CompactTrigger::Overflow.is_auto());
988        assert!(CompactTrigger::Idle.is_auto());
989        assert!(!CompactTrigger::Manual.is_auto());
990    }
991
992    #[test]
993    fn test_trigger_serde() {
994        for trigger in [
995            CompactTrigger::Auto,
996            CompactTrigger::Manual,
997            CompactTrigger::Overflow,
998            CompactTrigger::Idle,
999        ] {
1000            let json = serde_json::to_string(&trigger).unwrap();
1001            let restored: CompactTrigger = serde_json::from_str(&json).unwrap();
1002            assert_eq!(trigger, restored);
1003        }
1004    }
1005
1006    // -- CompactionResult --
1007
1008    #[test]
1009    fn test_result_tokens_saved() {
1010        let result = CompactionResult {
1011            summary: "test".into(),
1012            short_summary: None,
1013            trigger: CompactTrigger::Auto,
1014            tokens_before: 150_000,
1015            tokens_after: Some(5_000),
1016            messages_compacted: 40,
1017            messages_kept: 8,
1018            success: true,
1019        };
1020        assert_eq!(result.tokens_saved(), Some(145_000));
1021    }
1022
1023    #[test]
1024    fn test_result_compression_ratio() {
1025        let result = CompactionResult {
1026            summary: "test".into(),
1027            short_summary: None,
1028            trigger: CompactTrigger::Auto,
1029            tokens_before: 100_000,
1030            tokens_after: Some(10_000),
1031            messages_compacted: 30,
1032            messages_kept: 5,
1033            success: true,
1034        };
1035        let ratio = result.compression_ratio().unwrap();
1036        assert!((ratio - 0.9).abs() < 0.001);
1037    }
1038
1039    #[test]
1040    fn test_result_no_tokens_after() {
1041        let result = CompactionResult {
1042            summary: "test".into(),
1043            short_summary: None,
1044            trigger: CompactTrigger::Manual,
1045            tokens_before: 100_000,
1046            tokens_after: None,
1047            messages_compacted: 20,
1048            messages_kept: 5,
1049            success: true,
1050        };
1051        assert!(result.tokens_saved().is_none());
1052        assert!(result.compression_ratio().is_none());
1053    }
1054
1055    #[test]
1056    fn test_result_serde_roundtrip() {
1057        let result = CompactionResult {
1058            summary: "The user asked about Rust ownership...".into(),
1059            short_summary: Some("Discussed Rust ownership".into()),
1060            trigger: CompactTrigger::Overflow,
1061            tokens_before: 180_000,
1062            tokens_after: Some(8_000),
1063            messages_compacted: 50,
1064            messages_kept: 6,
1065            success: true,
1066        };
1067        let json = serde_json::to_string(&result).unwrap();
1068        let restored: CompactionResult = serde_json::from_str(&json).unwrap();
1069        assert_eq!(result, restored);
1070    }
1071
1072    // -- TokenBudgetState --
1073
1074    #[test]
1075    fn test_budget_state_normal() {
1076        let state = TokenBudgetState::from_usage(50_000, 200_000, 13_000);
1077        assert!(matches!(state, TokenBudgetState::Normal { .. }));
1078        assert!(!state.should_auto_compact());
1079        assert!(!state.is_blocking());
1080        assert!(!state.is_warning_or_worse());
1081    }
1082
1083    #[test]
1084    fn test_budget_state_warning() {
1085        // warning threshold = (200K - 13K) - 20K = 167K
1086        let state = TokenBudgetState::from_usage(170_000, 200_000, 13_000);
1087        assert!(matches!(state, TokenBudgetState::Warning { .. }));
1088        assert!(!state.should_auto_compact());
1089        assert!(state.is_warning_or_worse());
1090    }
1091
1092    #[test]
1093    fn test_budget_state_error() {
1094        // error threshold = 200K - 20K = 180K
1095        let state = TokenBudgetState::from_usage(185_000, 200_000, 13_000);
1096        assert!(matches!(state, TokenBudgetState::Error { .. }));
1097        assert!(state.should_auto_compact());
1098        assert!(state.is_warning_or_worse());
1099    }
1100
1101    #[test]
1102    fn test_budget_state_blocking() {
1103        let state = TokenBudgetState::from_usage(200_000, 200_000, 13_000);
1104        assert!(matches!(state, TokenBudgetState::Blocking));
1105        assert!(state.should_auto_compact());
1106        assert!(state.is_blocking());
1107    }
1108
1109    #[test]
1110    fn test_budget_state_zero_window() {
1111        let state = TokenBudgetState::from_usage(0, 0, 0);
1112        assert!(matches!(state, TokenBudgetState::Blocking));
1113    }
1114
1115    #[test]
1116    fn test_budget_state_serde_roundtrip() {
1117        let states = vec![
1118            TokenBudgetState::Normal {
1119                percent_remaining: 0.75,
1120            },
1121            TokenBudgetState::Warning {
1122                percent_remaining: 0.15,
1123            },
1124            TokenBudgetState::Error {
1125                percent_remaining: 0.05,
1126            },
1127            TokenBudgetState::Blocking,
1128        ];
1129        for state in states {
1130            let json = serde_json::to_string(&state).unwrap();
1131            let restored: TokenBudgetState = serde_json::from_str(&json).unwrap();
1132            assert_eq!(state, restored);
1133        }
1134    }
1135}