PicoBot/src/gateway/agent_factory.rs
oudecheng 7b459b8ca1 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 全通过。
2026-08-06 11:03:56 +08:00

279 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::sync::Arc;
use crate::agent::context_compressor::ContextCompressor;
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider};
use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
use crate::domain::CapabilityPolicy;
use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime;
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::PromptInjectionRepository;
use crate::storage::persistent_session_id;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::{ToolContext, ToolRegistry};
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
///
/// 单一来源AgentFactory::create 与命令侧(/save、/save-session、/current
/// 都调用此函数,确保保存到文件的系统提示词与 LLM 实际接收的提示词一致。
///
/// Provider 顺序AgentPrompt → SkillPrompt → ExpertPrompt → SubagentPrompt → TodoPrompt
pub(crate) fn build_system_prompt_provider(
reinject_every: usize,
provider_config: LLMProviderConfig,
prompt_repository: Arc<dyn PromptInjectionRepository>,
skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
) -> Arc<dyn SystemPromptProvider> {
Arc::new(CompositeSystemPromptProvider::new(vec![
Box::new(AgentPromptProvider::new(
reinject_every,
provider_config,
prompt_repository,
)),
Box::new(SkillPromptProvider::new(skills, experts.clone())),
Box::new(ExpertPromptProvider::new(experts.clone())),
Box::new(SubagentPromptProvider::new(subagent_runtime, experts)),
Box::new(ToolPromptProvider::new()),
]))
}
#[derive(Clone)]
pub(crate) struct AgentFactory {
tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>,
/// Provider/Model 解析器:按专家 frontmatter 中的 provider/model 字段覆盖基础配置
model_resolver: Arc<ModelResolver>,
/// per-session 的用户模型选择(最高优先级,覆盖专家配置)
model_selections: Arc<ModelSelectionStore>,
/// 上下文压缩算法配置(所有 agent 共享)
compaction_config: CompactionConfig,
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
instance_id: u64,
}
pub(crate) struct AgentBuildRequest<'a> {
pub(crate) channel_name: &'a str,
pub(crate) session_chat_id: &'a str,
pub(crate) notification_chat_id: Option<&'a str>,
pub(crate) sender_id: Option<&'a str>,
pub(crate) message_id: Option<&'a str>,
pub(crate) provider_config: LLMProviderConfig,
/// 当前话题 ID可选用于 todo 等按 topic 隔离的工具
pub(crate) topic_id: Option<String>,
/// 取消信号接收端可选Agent 在每次迭代时检查是否被取消
pub(crate) cancel_token: Option<tokio::sync::watch::Receiver<()>>,
}
impl AgentFactory {
pub(crate) fn new(
tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>,
model_resolver: Arc<ModelResolver>,
model_selections: Arc<ModelSelectionStore>,
compaction_config: CompactionConfig,
) -> Self {
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
let instance_id = Arc::as_ptr(&tools) as u64;
tracing::info!(
instance_id = instance_id,
tool_count = tools.tool_names().len(),
"AgentFactory::new created"
);
Self {
tools,
skills,
experts,
subagent_runtime,
reinject_every,
prompt_repository,
model_resolver,
model_selections,
compaction_config,
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> {
let session_id = persistent_session_id(request.channel_name, request.session_chat_id);
// 读取所选专家(用于工具过滤 + 子代理策略 + 模型覆盖)
let expert = self.experts.selected_expert_for(&session_id);
let expert_capability = expert.as_ref().map(|e| e.capability.clone());
// 按专家 frontmatter 中的 provider/model 字段解析覆盖基础 provider_config。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
let expert_provider_config = match &expert {
Some(e) if e.provider.is_some() || e.model.is_some() => {
let resolved = self
.model_resolver
.resolve(
e.provider.as_deref(),
e.model.as_deref(),
&request.provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
expert = %e.name,
provider = %resolved.name,
model_id = %resolved.model_id,
"AgentFactory: applied expert model override"
);
resolved
}
_ => request.provider_config.clone(),
};
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
let effective_provider_config = match self.model_selections.get(&session_id) {
Some((user_provider, user_model))
if user_provider.is_some() || user_model.is_some() =>
{
let resolved = self
.model_resolver
.resolve(
user_provider.as_deref(),
user_model.as_deref(),
&expert_provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
provider = %resolved.name,
model_id = %resolved.model_id,
"AgentFactory: applied user model override"
);
resolved
}
_ => expert_provider_config,
};
// 诊断日志:记录 agent 实际使用的配置和实例 ID
tracing::info!(
instance_id = self.instance_id,
channel = %request.channel_name,
session_id = %session_id,
provider = %effective_provider_config.name,
model_id = %effective_provider_config.model_id,
tool_count = self.tools.tool_names().len(),
"AgentFactory: creating agent with config"
);
// 创建组合的系统提示词提供者(与命令侧 /save 等共享同一构建逻辑)
let system_prompt_provider = build_system_prompt_provider(
self.reinject_every,
effective_provider_config.clone(),
self.prompt_repository.clone(),
self.skills.clone(),
self.experts.clone(),
self.subagent_runtime.clone(),
);
// 按所选专家的工具策略过滤工具集(含内置 + MCP 工具)。
// 无专家或专家未声明工具策略时,复用共享的 Arc<ToolRegistry>(零拷贝)。
let base_tool_count = self.tools.tool_names().len();
let tools: Arc<ToolRegistry> = match &expert_capability {
Some(cap) if cap.has_tool_policy() => {
let filtered = self.build_filtered_registry(cap);
let filtered_count = filtered.tool_names().len();
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
base_tool_count,
filtered_tool_count = filtered_count,
"AgentFactory: applied expert tool policy"
);
Arc::new(filtered)
}
_ => self.tools.clone(),
};
AgentLoop::with_tools_and_system_prompt_provider(
effective_provider_config.clone(),
tools,
system_prompt_provider,
Some(self.skills.clone()),
)
.map(|agent| {
// notification_chat_id 优先,否则使用 session_chat_id
let tool_chat_id = request
.notification_chat_id
.unwrap_or(request.session_chat_id);
// 构建上下文压缩器(参数内聚到 ContextCompressorCompactionConfig 注入)
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
let compressor = Arc::new(self.build_compressor(&runtime_config));
let mut agent = agent
.with_tool_context(ToolContext {
channel_name: Some(request.channel_name.to_string()),
sender_id: request.sender_id.map(str::to_string),
chat_id: Some(tool_chat_id.to_string()),
session_id: Some(session_id),
topic_id: request.topic_id.clone(),
message_id: request.message_id.map(str::to_string),
message_seq: None,
subagent_description: None,
nesting_depth: 0,
task_id: None,
parent_task_id: None,
tool_call_id: None,
// 注入专家 capabilityTaskTool 据此强制校验子代理白/黑名单
parent_capability: expert_capability.clone(),
})
.with_compressor(Some(compressor));
// 如果有取消信号接收端,注入 Agent
if let Some(token) = request.cancel_token {
agent = agent.with_cancel_token(token);
}
agent
})
}
/// 按专家 CapabilityPolicy 构建过滤后的 ToolRegistry 副本。
/// 生效顺序:先白名单取交集,再黑名单扣除(与 subagent filter_tool_registry 语义一致)。
/// 底层工具为 Arc<dyn ToolTrait>,克隆廉价。
fn build_filtered_registry(&self, policy: &CapabilityPolicy) -> ToolRegistry {
// 1. 白名单取交集None 表示不限,复制一份以便后续黑名单过滤
let after_allow: ToolRegistry = match &policy.allowed_tools {
Some(allowed) => {
let refs: Vec<&str> = allowed.iter().map(|s| s.as_str()).collect();
self.tools.only(&refs)
}
None => self.tools.without(&[]),
};
// 2. 黑名单(扣除)
if policy.denied_tools.is_empty() {
after_allow
} else {
let refs: Vec<&str> = policy.denied_tools.iter().map(|s| s.as_str()).collect();
after_allow.without(&refs)
}
}
}