diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index 84e70ba..9fe8667 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -76,9 +76,14 @@ impl ChatMessage { Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None } } - /// 是否处于 active 态(status 为 None 或 "active")。truncated 返回 false。 + /// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。 + /// + /// 正面白名单(F-15 §3.2):仅认 None / "active",新状态 + /// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支, + /// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated") + /// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。 pub fn is_active(&self) -> bool { - !matches!(self.status.as_deref(), Some("truncated")) + matches!(self.status.as_deref(), None | Some("active")) } } @@ -233,3 +238,40 @@ pub trait LlmProvider: Send + Sync { self.name().to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_active_whitelist() { + // F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。 + // 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价; + // "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单 + // matches! 只认 None/active 自动落入 false,面向未来验证。 + + // None(构造默认值,向前兼容老 JSON) + let m = ChatMessage::user("hi"); + assert!(m.is_active(), "None 应 active"); + + // "active" + let mut m = ChatMessage::user("hi"); + m.status = Some("active".to_string()); + assert!(m.is_active(), "Some(active) 应 active"); + + // "truncated" — 当前取值,与旧实现等价(false) + let mut m = ChatMessage::user("hi"); + m.status = Some("truncated".to_string()); + assert!(!m.is_active(), "truncated 应不 active"); + + // "archived_segment" — 阶段2 待引入,白名单自动隔离 + let mut m = ChatMessage::user("hi"); + m.status = Some("archived_segment".to_string()); + assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)"); + + // "compressed" — 阶段2 待引入,白名单自动隔离 + let mut m = ChatMessage::user("hi"); + m.status = Some("compressed".to_string()); + assert!(!m.is_active(), "compressed 应不 active(白名单隔离)"); + } +} diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index cf0fb74..5bb4fc1 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -176,7 +176,13 @@ impl ContextManager { pub fn push(&mut self, message: ChatMessage) { let tokens = self.estimator.estimate_message(&message); let group = classify_group(&message); - self.history_tokens += tokens; + // 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment / + // compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算 + // 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响), + // sanitize_messages step0(is_active 过滤)在发送视图统一剔除。 + if message.is_active() { + self.history_tokens += tokens; + } self.messages.push(TrackedMessage { message, token_count: tokens, @@ -851,6 +857,59 @@ mod tests { assert_eq!(mgr.len(), 3); } + #[test] + fn push_token_only_active() { + // F-15 §3.3:!active 消息(truncated / archived_segment / compressed)仍 push + // 到 self.messages(全量保留,持久化/前端视图自管),但不计入 history_tokens, + // 避免 build_for_request 误判超预算触发不必要裁剪。 + + // 1) 直接 push 路径 + let mut mgr = ContextManager::new(cfg(100_000)); + let active_msg = ChatMessage::user("这条是 active 的"); + let active_tokens = TokenEstimator::default().estimate_message(&active_msg); + let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token"); + inactive_msg.status = Some("truncated".to_string()); + let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg); + + mgr.push(active_msg); + mgr.push(inactive_msg); + + // 全量保留(两条都在内存) + assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages"); + assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响"); + // token 预算只含 active + assert_eq!( + mgr.history_tokens(), + active_tokens, + "history_tokens 应只含 active,多算了 {}(inactive 应被忽略)", + mgr.history_tokens().saturating_sub(active_tokens) + ); + assert!( + inactive_tokens > 0, + "前提:inactive 消息本身确有 token,否则无法证明它被排除" + ); + + // 2) restore_from_messages 路径(调 push,token 同步仅 active) + let mut mgr2 = ContextManager::new(cfg(100_000)); + let mut a = ChatMessage::user("active 一"); + a.status = Some("active".to_string()); + let mut b = ChatMessage::user("archived 一"); + b.status = Some("archived_segment".to_string()); + let mut c = ChatMessage::user("compressed 一"); + c.status = Some("compressed".to_string()); + mgr2.restore_from_messages(vec![a, b, c]); + + assert_eq!(mgr2.len(), 3, "restore 后全量保留三条"); + // 只 active 一条计 token(b/c 是白名单外状态,is_active 返回 false) + let only_active_tokens = TokenEstimator::default() + .estimate_message(&ChatMessage::user("active 一")); + assert_eq!( + mgr2.history_tokens(), + only_active_tokens, + "restore 后 history_tokens 应只含 active 一条,archived/compressed 不计" + ); + } + #[test] fn empty_history_returns_empty() { let mgr = ContextManager::new(cfg(100_000));