feat(config): 压缩算法关键参数暴露到设置页面

将 4 个压缩参数(threshold_ratio、llm_compaction_threshold_ratio、
truncate_max_tokens、preserve_count)从硬编码提取到 config.json 顶层
compaction 节,前端新增 CompactionTab 供用户调整。

后端:
- config: 新增 CompactionConfig 结构体,Config 新增 compaction 字段
- context_compressor: 4 个参数内聚到 ContextCompressor 实例字段;
  新增 with_compaction_config() 构造函数和 truncate_tool_results() 方法;
  对用户配置做防御性 clamp(ratio ∈ [0.1,1.0],整数 ≥ 1)
- agent_loop: 调用 compressor.truncate_tool_results() 实现零参数耦合
- agent_factory: 新增 build_compressor() 方法,in-loop 与 sync 兜底
  两条压缩路径共用同一套用户配置
- session: with_factories 改用 agent_factory.build_compressor(),修复
  sync 压缩路径忽略用户配置的 P0 问题

前端:
- types: 新增 CompactionConfig 接口,TabId 新增 'compaction'
- 新建 CompactionTab.tsx,4 个 number input 分两组 SectionCard
- constants + ConfigPage: 注册"上下文压缩" Tab(Archive 图标)

清理:删除无调用方的 from_provider_config/from_runtime_config/with_config
方法及 DEFAULT_THRESHOLD_RATIO/LLM_COMPACTION_THRESHOLD_RATIO 常量。

测试:context_compressor 18 + session 31 + agent_loop 42 全通过。
This commit is contained in:
oudecheng 2026-08-06 11:03:56 +08:00
parent fe7037e4ad
commit 7b459b8ca1
12 changed files with 252 additions and 58 deletions

View File

@ -1443,17 +1443,15 @@ impl AgentLoop {
if let Some(prompt_tokens) = last_prompt_tokens { if let Some(prompt_tokens) = last_prompt_tokens {
if compressor.should_compress_by_usage(prompt_tokens) { if compressor.should_compress_by_usage(prompt_tokens) {
// 阶段 1a工程化压缩截断非子代理 tool 结果到 100 token仅改内存 // 阶段 1a工程化压缩截断非子代理 tool 结果,仅改内存)
crate::agent::context_compressor::truncate_tool_results_in_place( // 参数内聚到 ContextCompressorAgentLoop 不持有截断 token 数
&mut messages, compressor.truncate_tool_results(&mut messages);
100,
);
engineering_compaction_applied = true; engineering_compaction_applied = true;
tracing::info!( tracing::info!(
iteration, iteration,
prompt_tokens, prompt_tokens,
threshold = compressor.threshold(), threshold = compressor.threshold(),
"Engineering compaction applied (tool results truncated to 100 tokens)" "Engineering compaction applied (tool results truncated)"
); );
// 阶段 1b重新估算判断是否需要 LLM 压缩50% 阈值) // 阶段 1b重新估算判断是否需要 LLM 压缩50% 阈值)

View File

@ -3,7 +3,7 @@ use crate::bus::{
ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION, ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
SYSTEM_CONTEXT_SCHEDULED_PROMPT, SYSTEM_CONTEXT_SCHEDULED_PROMPT,
}; };
use crate::config::LLMProviderConfig; use crate::config::{CompactionConfig, LLMProviderConfig};
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider}; use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider};
use crate::text::{char_count, take_prefix_chars}; use crate::text::{char_count, take_prefix_chars};
@ -17,12 +17,6 @@ pub const SYSTEM_CONTEXT_HISTORY_COMPACTION_OLDER: &str = "history_compaction_ol
/// System context marker for the light compression (newer segment) summary. /// System context marker for the light compression (newer segment) summary.
pub const SYSTEM_CONTEXT_HISTORY_COMPACTION_NEWER: &str = "history_compaction_newer"; pub const SYSTEM_CONTEXT_HISTORY_COMPACTION_NEWER: &str = "history_compaction_newer";
/// Default threshold ratio: compress when estimated tokens exceed 70% of context window.
const DEFAULT_THRESHOLD_RATIO: f64 = 0.7;
/// LLM 压缩阈值比例:工程化压缩后仍超过此比例才调 LLM
const LLM_COMPACTION_THRESHOLD_RATIO: f64 = 0.5;
// ============================================================================ // ============================================================================
// HistoryUnit — atomic message units for compression // HistoryUnit — atomic message units for compression
// ============================================================================ // ============================================================================
@ -275,6 +269,12 @@ pub struct ContextCompressor {
context_window: usize, context_window: usize,
/// Threshold ratio to trigger compression (70% of context window). /// Threshold ratio to trigger compression (70% of context window).
threshold_ratio: f64, threshold_ratio: f64,
/// LLM 压缩阈值比例(工程化压缩后仍超此比例才调 LLM
llm_compaction_threshold_ratio: f64,
/// 三段压缩保留 unit 数(最旧 N + 最新 N
preserve_count: usize,
/// 工程化压缩时 tool 结果截断 token 数
truncate_max_tokens: usize,
} }
impl ContextCompressor { impl ContextCompressor {
@ -655,13 +655,13 @@ OLDER SEGMENT (events from earlier in the session):
self.compress_two_segment_inner(history, provider).await self.compress_two_segment_inner(history, provider).await
} }
/// 三段压缩核心逻辑:保留最旧5 + 最新5 unit中间段用 LLM 压缩。 /// 三段压缩核心逻辑:保留最旧 N + 最新 N unit中间段用 LLM 压缩。
/// ///
/// 策略: /// 策略:
/// - SystemGuard 永远保留在头部(不计入 5 条配额) /// - SystemGuard 永远保留在头部(不计入 N 条配额)
/// - 可压缩单元UserMessage / AssistantText / ToolRound按时间顺序 /// - 可压缩单元UserMessage / AssistantText / ToolRound按时间顺序
/// - 最旧 PRESERVE_COUNT 个 unit 原样保留 /// - 最旧 preserve_count 个 unit 原样保留
/// - 最新 PRESERVE_COUNT 个 unit 原样保留 /// - 最新 preserve_count 个 unit 原样保留
/// - 中间段用 LLM 生成摘要system 消息,无 tool_calls /// - 中间段用 LLM 生成摘要system 消息,无 tool_calls
/// - ToolRound 原子性由 parse_to_units 保证,切分在 unit 边界 /// - ToolRound 原子性由 parse_to_units 保证,切分在 unit 边界
/// - 中间段摘要为纯文本 system 消息,符合 API 提交要求 /// - 中间段摘要为纯文本 system 消息,符合 API 提交要求
@ -670,14 +670,14 @@ OLDER SEGMENT (events from earlier in the session):
history: &[ChatMessage], history: &[ChatMessage],
provider: &dyn LLMProvider, provider: &dyn LLMProvider,
) -> Result<Vec<ChatMessage>, AgentError> { ) -> Result<Vec<ChatMessage>, AgentError> {
const PRESERVE_COUNT: usize = 5; let preserve_count = self.preserve_count;
let tokens = estimate_tokens(history); let tokens = estimate_tokens(history);
tracing::info!( tracing::info!(
tokens = tokens, tokens = tokens,
threshold = self.threshold(), threshold = self.threshold(),
msg_count = history.len(), msg_count = history.len(),
preserve_count = PRESERVE_COUNT, preserve_count = preserve_count,
"Starting three-segment compression (preserve oldest 5 + newest 5)" "Starting three-segment compression"
); );
let units = parse_to_units(history); let units = parse_to_units(history);
@ -693,10 +693,10 @@ OLDER SEGMENT (events from earlier in the session):
} }
// Step 2: If compressible units are too few, skip LLM compression // Step 2: If compressible units are too few, skip LLM compression
if compressible.len() <= PRESERVE_COUNT * 2 { if compressible.len() <= preserve_count * 2 {
tracing::info!( tracing::info!(
compressible_count = compressible.len(), compressible_count = compressible.len(),
preserve_threshold = PRESERVE_COUNT * 2, preserve_threshold = preserve_count * 2,
"Too few compressible units, skipping LLM compaction" "Too few compressible units, skipping LLM compaction"
); );
let mut result = system_guards; let mut result = system_guards;
@ -707,10 +707,10 @@ OLDER SEGMENT (events from earlier in the session):
} }
// Step 3: Three-segment split // Step 3: Three-segment split
let split = compressible.len() - PRESERVE_COUNT; let split = compressible.len() - preserve_count;
let oldest_units = &compressible[..PRESERVE_COUNT]; let oldest_units = &compressible[..preserve_count];
let newest_units = &compressible[split..]; let newest_units = &compressible[split..];
let middle_units = &compressible[PRESERVE_COUNT..split]; let middle_units = &compressible[preserve_count..split];
// Step 4: Build middle segment messages and transcript // Step 4: Build middle segment messages and transcript
let middle_messages: Vec<ChatMessage> = middle_units let middle_messages: Vec<ChatMessage> = middle_units
@ -743,7 +743,7 @@ OLDER SEGMENT (events from earlier in the session):
// System guards first // System guards first
compressed.extend(system_guards); compressed.extend(system_guards);
// Oldest PRESERVE_COUNT units (raw) // Oldest preserve_count units (raw)
for unit in oldest_units { for unit in oldest_units {
compressed.extend(unit_to_messages(unit)); compressed.extend(unit_to_messages(unit));
} }
@ -756,7 +756,7 @@ OLDER SEGMENT (events from earlier in the session):
)); ));
} }
// Newest PRESERVE_COUNT units (raw) // Newest preserve_count units (raw)
for unit in newest_units { for unit in newest_units {
compressed.extend(unit_to_messages(unit)); compressed.extend(unit_to_messages(unit));
} }
@ -766,8 +766,8 @@ OLDER SEGMENT (events from earlier in the session):
original_msg_count = history.len(), original_msg_count = history.len(),
final_tokens = estimate_tokens(&compressed), final_tokens = estimate_tokens(&compressed),
final_msg_count = compressed.len(), final_msg_count = compressed.len(),
oldest_units = PRESERVE_COUNT, oldest_units = preserve_count,
newest_units = PRESERVE_COUNT, newest_units = preserve_count,
middle_units = middle_units.len(), middle_units = middle_units.len(),
"Three-segment compression completed" "Three-segment compression completed"
); );
@ -780,34 +780,45 @@ OLDER SEGMENT (events from earlier in the session):
// ========================================================================= // =========================================================================
/// Create a new compressor with the given context window size. /// Create a new compressor with the given context window size.
/// 测试 fallback 路径:所有压缩参数用 CompactionConfig::default()。
pub fn new(context_window: usize) -> Self { pub fn new(context_window: usize) -> Self {
let default = CompactionConfig::default();
Self { Self {
config: ContextCompressionConfig::default(), config: ContextCompressionConfig::default(),
context_window, context_window,
threshold_ratio: DEFAULT_THRESHOLD_RATIO, threshold_ratio: default.threshold_ratio,
llm_compaction_threshold_ratio: default.llm_compaction_threshold_ratio,
preserve_count: default.preserve_count,
truncate_max_tokens: default.truncate_max_tokens,
} }
} }
pub fn from_provider_config(provider_config: &LLMProviderConfig) -> Self { /// 从 runtime config + compaction config 构造AgentFactory 生产路径调用)。
Self::from_runtime_config(&AgentRuntimeConfig::from(provider_config.clone())) /// 所有压缩参数内聚到 ContextCompressorAgentLoop 零参数耦合。
} /// 对用户配置做防御性 clamp避免极端值破坏压缩逻辑
/// - ratio 限制在 [0.1, 1.0]:过低导致每轮压缩,过高导致永不压缩
pub fn from_runtime_config(config: &AgentRuntimeConfig) -> Self { /// - truncate_max_tokens 限制 >= 10 会把所有 tool 结果截断成空串
Self::with_config( /// - preserve_count 限制 >= 10 会丢失全部原始上下文
config.context_window_tokens, pub fn with_compaction_config(
ContextCompressionConfig { context_window: usize,
summary_max_chars: config.context_summary_char_budget, summary_max_chars: usize,
compaction: &CompactionConfig,
) -> Self {
let clamp_ratio = |r: f64| r.clamp(0.1, 1.0);
let threshold_ratio = clamp_ratio(compaction.threshold_ratio);
let llm_compaction_threshold_ratio = clamp_ratio(compaction.llm_compaction_threshold_ratio);
let preserve_count = compaction.preserve_count.max(1);
let truncate_max_tokens = compaction.truncate_max_tokens.max(1);
Self {
config: ContextCompressionConfig {
summary_max_chars,
..ContextCompressionConfig::default() ..ContextCompressionConfig::default()
}, },
)
}
/// Create with custom configuration.
pub fn with_config(context_window: usize, config: ContextCompressionConfig) -> Self {
Self {
config,
context_window, context_window,
threshold_ratio: DEFAULT_THRESHOLD_RATIO, threshold_ratio,
llm_compaction_threshold_ratio,
preserve_count,
truncate_max_tokens,
} }
} }
@ -827,7 +838,13 @@ OLDER SEGMENT (events from earlier in the session):
/// LLM 压缩阈值50%):工程化压缩后用 estimate_tokens 判断是否需要 LLM 压缩。 /// LLM 压缩阈值50%):工程化压缩后用 estimate_tokens 判断是否需要 LLM 压缩。
pub fn llm_compaction_threshold(&self) -> usize { pub fn llm_compaction_threshold(&self) -> usize {
(self.context_window as f64 * LLM_COMPACTION_THRESHOLD_RATIO) as usize (self.context_window as f64 * self.llm_compaction_threshold_ratio) as usize
}
/// 工程化压缩:截断非子代理 tool 结果到 self.truncate_max_tokens。
/// AgentLoop 调用此方法,不需要知道截断参数细节(参数内聚)。
pub fn truncate_tool_results(&self, messages: &mut [ChatMessage]) {
truncate_tool_results_in_place(messages, self.truncate_max_tokens);
} }
fn user_turn_ranges(&self, history: &[ChatMessage]) -> Vec<UserTurnRange> { fn user_turn_ranges(&self, history: &[ChatMessage]) -> Vec<UserTurnRange> {

View File

@ -81,6 +81,7 @@ impl InitWizard {
image_context: crate::config::ImageContextConfig::default(), image_context: crate::config::ImageContextConfig::default(),
subagents: crate::config::SubagentsConfig::default(), subagents: crate::config::SubagentsConfig::default(),
experts: crate::config::ExpertsConfig::default(), experts: crate::config::ExpertsConfig::default(),
compaction: crate::config::CompactionConfig::default(),
} }
} }
@ -845,6 +846,7 @@ impl InitWizard {
image_context: existing.image_context.clone(), image_context: existing.image_context.clone(),
subagents: existing.subagents.clone(), subagents: existing.subagents.clone(),
experts: existing.experts.clone(), experts: existing.experts.clone(),
compaction: existing.compaction.clone(),
} }
} }

View File

@ -40,6 +40,8 @@ pub struct Config {
pub subagents: SubagentsConfig, pub subagents: SubagentsConfig,
#[serde(default)] #[serde(default)]
pub experts: ExpertsConfig, pub experts: ExpertsConfig,
#[serde(default)]
pub compaction: CompactionConfig,
} }
/// 图片上下文限制配置 /// 图片上下文限制配置
@ -72,6 +74,54 @@ impl Default for ImageContextConfig {
} }
} }
/// 上下文压缩算法配置(全局,所有 agent 共享)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompactionConfig {
/// 工程化压缩触发阈值(占 context_window 的比例0.0-1.0
/// 当 LLM 返回的 prompt_tokens 超过 context_window × 此比例时,触发压缩流程
#[serde(default = "default_threshold_ratio")]
pub threshold_ratio: f64,
/// LLM 压缩触发阈值(占 context_window 的比例0.0-1.0
/// 工程化压缩(截断 tool 结果)后,若估算 token 仍超过此比例才调 LLM 压缩
#[serde(default = "default_llm_compaction_threshold_ratio")]
pub llm_compaction_threshold_ratio: f64,
/// 工程化压缩时 tool 结果截断到的 token 数
/// 子代理返回tool_name="task")不受此限制
#[serde(default = "default_truncate_max_tokens")]
pub truncate_max_tokens: usize,
/// LLM 三段压缩保留的最旧/最新 unit 数
/// 压缩后保留最旧 N 个 unit + 中间段摘要 + 最新 N 个 unit
#[serde(default = "default_preserve_count")]
pub preserve_count: usize,
}
fn default_threshold_ratio() -> f64 {
0.7
}
fn default_llm_compaction_threshold_ratio() -> f64 {
0.5
}
fn default_truncate_max_tokens() -> usize {
100
}
fn default_preserve_count() -> usize {
5
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
threshold_ratio: default_threshold_ratio(),
llm_compaction_threshold_ratio: default_llm_compaction_threshold_ratio(),
truncate_max_tokens: default_truncate_max_tokens(),
preserve_count: default_preserve_count(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TimeConfig { pub struct TimeConfig {
#[serde(default = "default_timezone")] #[serde(default = "default_timezone")]

View File

@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use crate::agent::context_compressor::ContextCompressor; use crate::agent::context_compressor::ContextCompressor;
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider}; use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider};
use crate::config::{LLMProviderConfig, ModelResolver}; use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
use crate::domain::CapabilityPolicy; use crate::domain::CapabilityPolicy;
use crate::experts::ExpertPromptProvider; use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime; use crate::experts::ExpertRuntime;
@ -54,6 +54,8 @@ pub(crate) struct AgentFactory {
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
/// per-session 的用户模型选择(最高优先级,覆盖专家配置) /// per-session 的用户模型选择(最高优先级,覆盖专家配置)
model_selections: Arc<ModelSelectionStore>, model_selections: Arc<ModelSelectionStore>,
/// 上下文压缩算法配置(所有 agent 共享)
compaction_config: CompactionConfig,
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例) /// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
instance_id: u64, instance_id: u64,
} }
@ -81,6 +83,7 @@ impl AgentFactory {
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
model_selections: Arc<ModelSelectionStore>, model_selections: Arc<ModelSelectionStore>,
compaction_config: CompactionConfig,
) -> Self { ) -> Self {
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例 // 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
let instance_id = Arc::as_ptr(&tools) as u64; let instance_id = Arc::as_ptr(&tools) as u64;
@ -98,10 +101,22 @@ impl AgentFactory {
prompt_repository, prompt_repository,
model_resolver, model_resolver,
model_selections, model_selections,
compaction_config,
instance_id, instance_id,
} }
} }
/// 构造 ContextCompressor参数内聚到 ContextCompressorCompactionConfig 注入)。
/// AgentLoopin-loop 压缩)和 Sessionsync 兜底压缩)共用此方法,
/// 确保两条压缩路径使用同一套用户配置的压缩参数。
pub(crate) fn build_compressor(&self, runtime_config: &AgentRuntimeConfig) -> ContextCompressor {
ContextCompressor::with_compaction_config(
runtime_config.context_window_tokens,
runtime_config.context_summary_char_budget,
&self.compaction_config,
)
}
pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result<AgentLoop, AgentError> { pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result<AgentLoop, AgentError> {
let session_id = persistent_session_id(request.channel_name, request.session_chat_id); let session_id = persistent_session_id(request.channel_name, request.session_chat_id);
@ -211,10 +226,9 @@ impl AgentFactory {
let tool_chat_id = request let tool_chat_id = request
.notification_chat_id .notification_chat_id
.unwrap_or(request.session_chat_id); .unwrap_or(request.session_chat_id);
// 构建上下文压缩器(基于 effective_provider_config 的 context_window_tokens // 构建上下文压缩器(参数内聚到 ContextCompressorCompactionConfig 注入)
let compressor = Arc::new(ContextCompressor::from_provider_config( let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
&effective_provider_config, let compressor = Arc::new(self.build_compressor(&runtime_config));
));
let mut agent = agent let mut agent = agent
.with_tool_context(ToolContext { .with_tool_context(ToolContext {
channel_name: Some(request.channel_name.to_string()), channel_name: Some(request.channel_name.to_string()),

View File

@ -120,6 +120,7 @@ impl GatewayState {
mcp_config, mcp_config,
Some(bus.clone()), Some(bus.clone()),
Arc::new(crate::config::ModelResolver::from_config(&config)), Arc::new(crate::config::ModelResolver::from_config(&config)),
config.compaction.clone(),
)?; )?;
// 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态) // 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态)

View File

@ -9,7 +9,8 @@ use tokio::sync::RwLock;
use crate::agent::AgentError; use crate::agent::AgentError;
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::config::{ use crate::config::{
LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig, CompactionConfig, LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig,
TaskConfig,
}; };
use crate::gateway::model_selection::ModelSelectionStore; use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_registry_factory::ToolRegistryFactory; use crate::gateway::tool_registry_factory::ToolRegistryFactory;
@ -57,6 +58,7 @@ pub(crate) fn build_session_manager(
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
compaction_config: CompactionConfig,
) -> Result< ) -> Result<
( (
SessionManager, SessionManager,
@ -84,6 +86,7 @@ pub(crate) fn build_session_manager(
mcp_config, mcp_config,
bus, bus,
model_resolver, model_resolver,
compaction_config,
) )
} }
@ -105,6 +108,7 @@ pub(crate) fn build_session_manager_with_sender(
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
compaction_config: CompactionConfig,
) -> Result< ) -> Result<
( (
SessionManager, SessionManager,
@ -313,6 +317,7 @@ pub(crate) fn build_session_manager_with_sender(
prompt_repository.clone(), prompt_repository.clone(),
model_resolver.clone(), model_resolver.clone(),
model_selections.clone(), model_selections.clone(),
compaction_config,
); );
let session_factory = SessionFactory::new( let session_factory = SessionFactory::new(
provider_config.clone(), provider_config.clone(),

View File

@ -1,4 +1,4 @@
use crate::agent::{AgentError, AgentLoop, ContextCompressor, EmittedMessageHandler}; use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler};
#[cfg(test)] #[cfg(test)]
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT; use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
use crate::bus::{ChatMessage, MessageBus, OutboundMessage}; use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
@ -301,6 +301,7 @@ impl Session {
prompt_repository.clone(), prompt_repository.clone(),
model_resolver, model_resolver,
Arc::new(super::model_selection::ModelSelectionStore::new()), Arc::new(super::model_selection::ModelSelectionStore::new()),
crate::config::CompactionConfig::default(),
); );
Self::with_factories( Self::with_factories(
channel_name, channel_name,
@ -325,6 +326,10 @@ impl Session {
skill_events: Arc<dyn SkillEventRepository>, skill_events: Arc<dyn SkillEventRepository>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
) -> Result<Self, AgentError> { ) -> Result<Self, AgentError> {
// Session 的 compressor 用于 sync 兜底压缩路径compaction.rs
// 必须与 AgentLoop 的 compressor 共用同一套用户配置的压缩参数。
let runtime_config = AgentRuntimeConfig::from(provider_config.clone());
let compressor = agent_factory.build_compressor(&runtime_config);
Ok(Self { Ok(Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
channel_name: channel_name.clone(), channel_name: channel_name.clone(),
@ -332,7 +337,7 @@ impl Session {
provider_config: provider_config.clone(), provider_config: provider_config.clone(),
skills, skills,
agent_factory, agent_factory,
compressor: ContextCompressor::from_provider_config(&provider_config), compressor,
history: SessionHistory::new(channel_name, conversations, skill_events), history: SessionHistory::new(channel_name, conversations, skill_events),
store, store,
pending_cancel_tokens: HashMap::new(), pending_cancel_tokens: HashMap::new(),
@ -744,6 +749,7 @@ impl SessionManager {
mcp_config, mcp_config,
None, None,
model_resolver, model_resolver,
crate::config::CompactionConfig::default(),
) )
.map(|(session_manager, _, _, _, _)| session_manager) .map(|(session_manager, _, _, _, _)| session_manager)
} }

View File

@ -28,6 +28,7 @@ const ToolsTab = lazy(() => import('./tabs/ToolsTab').then((m) => ({ default: m.
const MemoryTab = lazy(() => import('./tabs/MemoryTab').then((m) => ({ default: m.MemoryTab }))); const MemoryTab = lazy(() => import('./tabs/MemoryTab').then((m) => ({ default: m.MemoryTab })));
const SchedulerTab = lazy(() => import('./tabs/SchedulerTab').then((m) => ({ default: m.SchedulerTab }))); const SchedulerTab = lazy(() => import('./tabs/SchedulerTab').then((m) => ({ default: m.SchedulerTab })));
const ImageTab = lazy(() => import('./tabs/ImageTab').then((m) => ({ default: m.ImageTab }))); const ImageTab = lazy(() => import('./tabs/ImageTab').then((m) => ({ default: m.ImageTab })));
const CompactionTab = lazy(() => import('./tabs/CompactionTab').then((m) => ({ default: m.CompactionTab })));
const TimeTab = lazy(() => import('./tabs/TimeTab').then((m) => ({ default: m.TimeTab }))); const TimeTab = lazy(() => import('./tabs/TimeTab').then((m) => ({ default: m.TimeTab })));
const GatewayTab = lazy(() => import('./tabs/GatewayTab').then((m) => ({ default: m.GatewayTab }))); const GatewayTab = lazy(() => import('./tabs/GatewayTab').then((m) => ({ default: m.GatewayTab })));
const ConnectionTab = lazy(() => import('./tabs/ConnectionTab').then((m) => ({ default: m.ConnectionTab }))); const ConnectionTab = lazy(() => import('./tabs/ConnectionTab').then((m) => ({ default: m.ConnectionTab })));
@ -212,6 +213,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
return <MemoryTab {...commonProps} />; return <MemoryTab {...commonProps} />;
case 'image': case 'image':
return <ImageTab {...commonProps} />; return <ImageTab {...commonProps} />;
case 'compaction':
return <CompactionTab {...commonProps} />;
case 'subagents': case 'subagents':
return <SubagentsTab {...commonProps} setToast={showToast} />; return <SubagentsTab {...commonProps} setToast={showToast} />;
case 'experts': case 'experts':

View File

@ -14,6 +14,7 @@ import {
Server, Server,
Users, Users,
UserCheck, UserCheck,
Archive,
} from 'lucide-react'; } from 'lucide-react';
import type { TabId } from './types'; import type { TabId } from './types';
@ -54,6 +55,7 @@ export const TAB_GROUPS: TabGroup[] = [
tabs: [ tabs: [
{ id: 'gateway', label: '网关', icon: Server }, { id: 'gateway', label: '网关', icon: Server },
{ id: 'scheduler', label: '调度器', icon: Calendar }, { id: 'scheduler', label: '调度器', icon: Calendar },
{ id: 'compaction', label: '上下文压缩', icon: Archive },
{ id: 'memory', label: '记忆维护', icon: Users }, { id: 'memory', label: '记忆维护', icon: Users },
{ id: 'image', label: '图片上下文', icon: Image }, { id: 'image', label: '图片上下文', icon: Image },
{ id: 'time', label: '时间', icon: Clock }, { id: 'time', label: '时间', icon: Clock },

View File

@ -0,0 +1,87 @@
// CompactionTab - 上下文压缩算法配置
import { Field, SectionCard } from '../ui';
import { inputCls } from '../constants';
import type { TabProps } from '../shared';
export function CompactionTab({ config, update }: TabProps) {
return (
<>
<SectionCard title="压缩触发阈值">
<Field
label="工程化压缩阈值"
hint="当 LLM 返回的 prompt_tokens 超过 context_window × 此比例时触发压缩。默认 0.770%"
>
<input
type="number"
step="0.1"
min="0.1"
max="1.0"
value={config.compaction.threshold_ratio}
onChange={(e) =>
update('compaction', {
...config.compaction,
threshold_ratio: +e.target.value,
})
}
className={inputCls}
/>
</Field>
<Field
label="LLM 压缩阈值"
hint="工程化压缩(截断 tool 结果)后,若估算 token 仍超过此比例才调 LLM 压缩。默认 0.550%"
>
<input
type="number"
step="0.1"
min="0.1"
max="1.0"
value={config.compaction.llm_compaction_threshold_ratio}
onChange={(e) =>
update('compaction', {
...config.compaction,
llm_compaction_threshold_ratio: +e.target.value,
})
}
className={inputCls}
/>
</Field>
</SectionCard>
<SectionCard title="压缩参数">
<Field
label="Tool 结果截断 Token 数"
hint="工程化压缩时,非子代理的 tool 结果截断到此 token 数。子代理返回不受此限制。默认 100"
>
<input
type="number"
min="10"
value={config.compaction.truncate_max_tokens}
onChange={(e) =>
update('compaction', {
...config.compaction,
truncate_max_tokens: +e.target.value,
})
}
className={inputCls}
/>
</Field>
<Field
label="三段保留 Unit 数"
hint="LLM 压缩时保留最旧 N 个和最新 N 个 unit 原样,中间段用 LLM 生成摘要。默认 5"
>
<input
type="number"
min="1"
value={config.compaction.preserve_count}
onChange={(e) =>
update('compaction', {
...config.compaction,
preserve_count: +e.target.value,
})
}
className={inputCls}
/>
</Field>
</SectionCard>
</>
);
}

View File

@ -233,6 +233,13 @@ export interface SchedulerJobConfig {
[key: string]: unknown; [key: string]: unknown;
} }
export interface CompactionConfig {
threshold_ratio: number;
llm_compaction_threshold_ratio: number;
truncate_max_tokens: number;
preserve_count: number;
}
export interface AppConfig { export interface AppConfig {
providers: Record<string, ProviderConfig>; providers: Record<string, ProviderConfig>;
models: Record<string, ModelConfig>; models: Record<string, ModelConfig>;
@ -246,6 +253,7 @@ export interface AppConfig {
image_context: ImageContextConfig; image_context: ImageContextConfig;
subagents: SubagentsConfig; subagents: SubagentsConfig;
experts: ExpertsConfig; experts: ExpertsConfig;
compaction: CompactionConfig;
client: ClientConfig; client: ClientConfig;
channels: Record<string, ChannelConfig>; channels: Record<string, ChannelConfig>;
mcpServers: Record<string, McpServerConfig>; mcpServers: Record<string, McpServerConfig>;
@ -266,7 +274,8 @@ export type TabId =
| 'subagents' | 'subagents'
| 'experts' | 'experts'
| 'mcp' | 'mcp'
| 'channels'; | 'channels'
| 'compaction';
export interface ConfigPageProps { export interface ConfigPageProps {
onClose: () => void; onClose: () => void;