use super::super::constants::{
COMPACT_KEEP_RECENT, COMPACT_KEEP_RECENT_USER_MESSAGES, COMPACT_SKILL_PER_SKILL_TOKEN_BUDGET,
COMPACT_SKILL_TOKEN_BUDGET, COMPACT_TOKEN_THRESHOLD, COMPACT_TRUNCATE_MAX_CHARS,
MICRO_COMPACT_BYTES_THRESHOLD,
};
use super::super::storage::{ChatMessage, MessageRole, ModelProvider, SessionPaths};
use super::super::tools::ask::AskTool;
use super::super::tools::skill::LoadSkillTool;
use super::super::tools::task::TaskTool;
use super::super::tools::todo::{TodoReadTool, TodoWriteTool};
use super::api::create_openai_client;
use crate::command::chat::tools::agent_team::AgentTeamTool;
use crate::command::chat::tools::plan::{EnterPlanModeTool, ExitPlanModeTool};
use crate::command::chat::tools::sub_agent::SubAgentTool;
use crate::util::log::{write_error_log, write_info_log};
use async_openai::types::chat::{
ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs,
CreateChatCompletionRequestArgs,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InvokedSkill {
pub name: String,
pub dir_path: String,
pub resolved_content: String,
pub invoked_at_secs: u64,
}
pub type InvokedSkillsMap = Arc<Mutex<HashMap<String, InvokedSkill>>>;
pub fn new_invoked_skills_map() -> InvokedSkillsMap {
Arc::new(Mutex::new(HashMap::new()))
}
pub fn record_skill_invocation(
map: &InvokedSkillsMap,
name: String,
dir_path: String,
content: String,
) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if let Ok(mut skills) = map.lock() {
let log_name = name.clone();
skills.insert(
name.clone(),
InvokedSkill {
name,
dir_path,
resolved_content: content,
invoked_at_secs: now,
},
);
write_info_log("invoked_skills", &format!("记录技能调用: {}", log_name));
}
}
pub fn build_invoked_skills_attachment(map: &InvokedSkillsMap) -> Option<String> {
let skills = map.lock().ok()?;
if skills.is_empty() {
return None;
}
let mut sorted_by_recency: Vec<&InvokedSkill> = skills.values().collect();
sorted_by_recency.sort_by(|a, b| b.invoked_at_secs.cmp(&a.invoked_at_secs));
let mut result =
String::from("Skills invoked in this session (preserved across compaction):\n\n");
let mut total_tokens = 0usize;
let per_skill_budget = COMPACT_SKILL_PER_SKILL_TOKEN_BUDGET;
let total_budget = COMPACT_SKILL_TOKEN_BUDGET;
for skill in sorted_by_recency {
let skill_tokens = skill.resolved_content.len() / 4; let available = if total_tokens + per_skill_budget > total_budget {
total_budget.saturating_sub(total_tokens)
} else {
per_skill_budget
};
if available == 0 {
break;
}
result.push_str(&format!("### Skill: {}\n", skill.name));
result.push_str(&format!("Path: {}\n", skill.dir_path));
if skill_tokens <= available {
result.push_str(&skill.resolved_content);
total_tokens += skill_tokens;
} else {
let char_cutoff = available * 4;
let truncated: String = skill.resolved_content.chars().take(char_cutoff).collect();
result.push_str(&truncated);
result.push_str("\n\n[... skill content truncated for compaction ...]");
total_tokens += available;
}
result.push_str("\n\n---\n\n");
}
Some(result)
}
#[derive(Debug, Clone)]
pub struct CompactResult {
pub messages_before: usize,
pub transcript_path: String,
pub summary: String,
pub recent_user_messages: Vec<ChatMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactConfig {
#[serde(default = "default_compact_enabled")]
pub enabled: bool,
#[serde(default = "default_token_threshold")]
pub token_threshold: usize,
#[serde(default = "default_keep_recent")]
pub keep_recent: usize,
#[serde(default)]
pub micro_compact_exempt_tools: Vec<String>,
}
fn default_compact_enabled() -> bool {
true
}
fn default_token_threshold() -> usize {
COMPACT_TOKEN_THRESHOLD
}
fn default_keep_recent() -> usize {
COMPACT_KEEP_RECENT
}
impl Default for CompactConfig {
fn default() -> Self {
Self {
enabled: default_compact_enabled(),
token_threshold: default_token_threshold(),
keep_recent: default_keep_recent(),
micro_compact_exempt_tools: Vec::new(),
}
}
}
impl CompactConfig {
pub fn effective_token_threshold(&self) -> usize {
if self.token_threshold == 0 {
COMPACT_TOKEN_THRESHOLD
} else {
self.token_threshold
}
}
}
pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
serde_json::to_string(messages).unwrap_or_default().len() / 4
}
pub fn extract_user_messages(messages: &[ChatMessage]) -> Vec<ChatMessage> {
messages
.iter()
.filter(|m| m.role == MessageRole::User)
.cloned()
.collect()
}
pub fn extract_recent_user_messages(messages: &[ChatMessage], count: usize) -> Vec<ChatMessage> {
let mut recent: Vec<ChatMessage> = Vec::new();
for m in messages.iter().rev() {
if m.role == MessageRole::User {
recent.push(m.clone());
if recent.len() >= count {
break;
}
}
}
recent.reverse();
recent
}
pub const BUILTIN_EXEMPT_TOOLS: &[&str] = &[
LoadSkillTool::NAME,
TaskTool::NAME,
TodoWriteTool::NAME,
TodoReadTool::NAME,
EnterPlanModeTool::NAME,
ExitPlanModeTool::NAME,
SubAgentTool::NAME,
AgentTeamTool::NAME,
AskTool::NAME,
crate::command::chat::tools::send_message::SendMessageTool::NAME,
crate::command::chat::tools::create_teammate::CreateTeammateTool::NAME,
];
pub fn is_exempt_tool(tool_name: &str, extra_exempt_tools: &[String]) -> bool {
BUILTIN_EXEMPT_TOOLS.contains(&tool_name) || extra_exempt_tools.iter().any(|t| t == tool_name)
}
pub fn micro_compact(
messages: &mut [ChatMessage],
keep_recent: usize,
extra_exempt_tools: &[String],
) {
let mut tool_call_id_to_name: HashMap<String, String> = HashMap::new();
for msg in messages.iter() {
if msg.role == MessageRole::Assistant
&& let Some(ref tool_calls) = msg.tool_calls
{
for tool_call in tool_calls {
tool_call_id_to_name.insert(tool_call.id.clone(), tool_call.name.clone());
}
}
}
let tool_indices: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, msg)| msg.role == MessageRole::Tool)
.map(|(i, _)| i)
.collect();
if tool_indices.len() <= keep_recent {
return;
}
let indices_to_compact = &tool_indices[..tool_indices.len() - keep_recent];
let mut compacted_count = 0;
for &idx in indices_to_compact {
let msg = &messages[idx];
if msg.content.chars().count() > MICRO_COMPACT_BYTES_THRESHOLD {
let tool_call_id = msg.tool_call_id.clone().unwrap_or_default();
let tool_name = tool_call_id_to_name
.get(&tool_call_id)
.cloned()
.unwrap_or_else(|| "unknown".to_string());
if is_exempt_tool(&tool_name, extra_exempt_tools) {
continue;
}
messages[idx].content = format!("[Previous: used {}]", tool_name);
compacted_count += 1;
}
}
if compacted_count > 0 {
write_info_log(
"micro_compact",
&format!(
"压缩了 {} 个旧 tool result(保留最近 {} 个)",
compacted_count, keep_recent
),
);
}
}
fn save_transcript(messages: &[ChatMessage], session_id: &str) -> Option<String> {
let paths = SessionPaths::new(session_id);
let transcript_dir = paths.transcripts_dir();
if let Err(e) = fs::create_dir_all(&transcript_dir) {
write_error_log(
"save_transcript",
&format!("创建 .transcripts 目录失败: {}", e),
);
return None;
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let path = transcript_dir.join(format!("transcript_{}.jsonl", timestamp));
let mut content = String::new();
for msg in messages {
if let Ok(line) = serde_json::to_string(msg) {
content.push_str(&line);
content.push('\n');
}
}
match fs::write(&path, &content) {
Ok(_) => {
let path_str = path.display().to_string();
write_info_log(
"save_transcript",
&format!("Transcript saved: {}", path_str),
);
Some(path_str)
}
Err(e) => {
write_error_log("save_transcript", &format!("保存 transcript 失败: {}", e));
None
}
}
}
pub async fn auto_compact(
messages: &mut Vec<ChatMessage>,
provider: &ModelProvider,
invoked_skills: &InvokedSkillsMap,
session_id: &str,
protected_context: Option<&str>,
) -> Result<CompactResult, String> {
let messages_before = messages.len();
let transcript_path =
save_transcript(messages, session_id).unwrap_or_else(|| "(unsaved)".to_string());
let conversation_text = serde_json::to_string(messages).unwrap_or_default();
let truncated_conversation_text: String = conversation_text
.chars()
.take(COMPACT_TRUNCATE_MAX_CHARS)
.collect();
let summary_prompt = format!(
"Summarize this conversation for continuity. Use this structured format:\n\
1) **Primary Request**: What the user originally asked for.\n\
2) **Key Concepts**: Important technical concepts, domain knowledge, or constraints discovered.\n\
3) **Files and Code**: Key files read or modified, with important code snippets or decisions.\n\
4) **Errors and Fixes**: Any errors encountered and how they were resolved.\n\
5) **Problem Solving**: Reasoning steps and approach taken.\n\
6) **Active Skills/Workflows**: If a skill or workflow was being followed, list its name, key steps, and current progress. Include direct quotes showing exactly where you left off.\n\
7) **Pending Tasks**: Things that still need to be done.\n\
8) **Current Work**: What was being worked on most recently. Include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off.\n\
9) **Next Step**: What should happen next to continue the work.\n\
\n\
Be concise but preserve critical details. Section 6 (Active Skills/Workflows) is especially important — preserve all skill instructions and progress so the model can continue following them without re-loading.\n\n\
{}",
truncated_conversation_text
);
let summary_prompt_with_context = if let Some(protected) = protected_context {
format!(
"{}\n\n[Protected Context — MUST preserve in full]:\n{}",
summary_prompt, protected
)
} else {
summary_prompt
};
let user_msg = ChatCompletionRequestUserMessageArgs::default()
.content(summary_prompt_with_context.as_str())
.build()
.map_err(|e| format!("构建摘要请求消息失败: {}", e))?;
let request = CreateChatCompletionRequestArgs::default()
.model(&provider.model)
.messages(vec![ChatCompletionRequestMessage::User(user_msg)])
.max_tokens(20000u32)
.build()
.map_err(|e| format!("构建摘要请求失败: {}", e))?;
let client = create_openai_client(provider);
let response = client
.chat()
.create(request)
.await
.map_err(|e| format!("auto_compact LLM 请求失败: {}", e))?;
let summary = response
.choices
.first()
.and_then(|c| c.message.content.clone())
.unwrap_or_else(|| "(empty summary)".to_string());
write_info_log(
"auto_compact",
&format!("摘要完成,长度: {} chars", summary.len()),
);
let recent_user = extract_recent_user_messages(messages, COMPACT_KEEP_RECENT_USER_MESSAGES);
messages.clear();
let mut summary_content = format!(
"[Conversation compressed. Transcript: {}]\n\n{}",
transcript_path, summary
);
if let Some(skills_attachment) = build_invoked_skills_attachment(invoked_skills) {
summary_content.push_str(&format!(
"\n\n<system-reminder>\n{}\n</system-reminder>",
skills_attachment
));
write_info_log(
"auto_compact",
"已注入 invoked_skills 附件,确保压缩后技能指令可继续遵循",
);
}
messages.push(ChatMessage::text(MessageRole::User, summary_content));
messages.push(ChatMessage::text(
MessageRole::Assistant,
"Understood. I have the context from the summary and any active skill instructions. Continuing to follow them.",
));
let recent_user_clone = recent_user.clone();
if !recent_user.is_empty() {
write_info_log(
"auto_compact",
&format!(
"保留最近 {} 条 user 消息原文,确保压缩后任务意图不丢失",
recent_user.len()
),
);
for msg in recent_user {
messages.push(msg);
}
}
Ok(CompactResult {
messages_before,
transcript_path,
summary,
recent_user_messages: recent_user_clone,
})
}