use std::sync::Arc;
use chrono::Utc;
use futures::StreamExt;
use serde_json::{Value, json};
use tokio::io::AsyncWriteExt as _;
use tracing::{debug, info, warn};
use super::context_mgr::{compress_tool_results, estimate_tokens, msg_tokens};
use super::runtime::AgentRuntime;
use crate::provider::{
ContentPart, LlmRequest, Message, MessageContent, Role, StreamEvent,
};
const COMPACTION_PREFIX: &str = "\
[CONTEXT COMPACTION - REFERENCE ONLY] Earlier turns were compacted \
into the summary below. This is a handoff from a previous context \
window - treat it as background reference, NOT as active instructions. \
Do NOT answer questions or fulfill requests mentioned in this summary; \
they were already addressed. \
Your current task is in the '## Active Task' section - resume from there. \
Respond ONLY to the latest user message that appears AFTER this summary.";
pub fn is_compaction_message(v: &serde_json::Value) -> bool {
let obj = match v.as_object() {
Some(o) => o,
None => return false,
};
let role = obj.get("role").and_then(|r| r.as_str()).unwrap_or("");
if role != "user" {
return false;
}
if let Some(text) = obj.get("content").and_then(|c| c.as_str()) {
return text.starts_with("[CONTEXT COMPACTION");
}
if let Some(parts) = obj.get("content").and_then(|c| c.as_array()) {
if let Some(first_text) = parts
.first()
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())
{
return first_text.starts_with("[CONTEXT COMPACTION");
}
}
false
}
impl AgentRuntime {
pub(crate) async fn compact_if_needed(&mut self, session_key: &str, model: &str) {
self.compact_inner(session_key, model, false).await;
}
pub(crate) async fn compact_force(&mut self, session_key: &str, model: &str) {
self.compact_inner(session_key, model, true).await;
}
pub(crate) fn estimate_fixed_overhead(&self) -> usize {
let mut overhead = 0usize;
if let Some(ref sys) = self.cached_system_prompt {
overhead += estimate_tokens(sys);
}
for tool in &self.cached_tools {
overhead += estimate_tokens(&tool.name);
overhead += estimate_tokens(&tool.description);
overhead += estimate_tokens(&tool.parameters.to_string());
}
let skill_count = self.skills.len();
overhead += skill_count * 500;
overhead
}
pub(crate) async fn compact_inner(&mut self, session_key: &str, model: &str, force: bool) {
use crate::config::schema::CompactionMode;
let cfg = self.config.agents.defaults.compaction.clone()
.unwrap_or_default();
let context_tokens = self.config.agents.defaults.context_tokens.unwrap_or(64_000) as usize;
let kv_cache_mode = self.config.agents.defaults.kv_cache_mode.unwrap_or(1);
let default_threshold = if kv_cache_mode >= 1 {
(context_tokens * 4 / 5).max(16_000) } else {
(context_tokens / 2).max(16_000) };
let token_threshold = if let Some(floor) = cfg.reserve_tokens_floor {
context_tokens.saturating_sub(floor as usize).max(16_000)
} else {
default_threshold
};
let overhead_tokens = self.estimate_fixed_overhead();
let session_tokens: usize = self
.sessions
.get(session_key)
.map(|msgs| msgs.iter().map(msg_tokens).sum())
.unwrap_or(0);
let total_tokens = session_tokens + overhead_tokens;
let turns = self
.compaction_state
.get(session_key)
.map(|(_, t)| *t)
.unwrap_or(0);
let token_trigger = total_tokens > token_threshold;
debug!(
session = session_key,
total_tokens,
token_threshold,
turns,
token_trigger,
force,
"compaction check"
);
if !force && !token_trigger {
self.compaction_state
.entry(session_key.to_owned())
.and_modify(|(_, t)| *t += 1)
.or_insert((std::time::Instant::now(), 1));
return;
}
let trigger_reason = if token_trigger {
"tokens"
} else {
"time"
};
info!(
session = session_key,
trigger = trigger_reason,
total_tokens,
turns,
"compaction triggered"
);
let mode = cfg
.mode
.as_ref()
.cloned()
.unwrap_or(CompactionMode::Layered);
let compaction_model = cfg.model.as_deref().unwrap_or(model);
let configured_pairs = cfg.keep_recent_pairs.unwrap_or(5) as usize;
let keep_pairs = if total_tokens > token_threshold * 3 {
1.max(configured_pairs / 3) } else if total_tokens > token_threshold * 2 {
1.max(configured_pairs / 2) } else {
configured_pairs };
let extract_facts = cfg.extract_facts.unwrap_or(true);
let msgs_to_text = |msgs: &[Message]| -> String {
let default_transcript = (context_tokens * 7 / 10).max(16_000);
let max_total_tokens: usize = cfg.max_transcript_tokens
.map(|t| t as usize)
.unwrap_or(default_transcript);
Self::msgs_to_text_static(msgs, max_total_tokens)
};
let (head_msgs, old_text, recent_msgs) = if mode == CompactionMode::Layered {
let msgs = self.sessions.get(session_key).cloned().unwrap_or_default();
let head_end = {
let first_is_summary = msgs.first()
.and_then(|m| if let MessageContent::Text(t) = &m.content { Some(t.starts_with("[CONTEXT COMPACTION")) } else { None })
.unwrap_or(false);
if first_is_summary {
0 } else {
let mut count = 0usize;
let mut end = 0;
for (idx, m) in msgs.iter().enumerate() {
if m.role == Role::Assistant {
count += 1;
end = idx + 1;
if count >= 1 { break; }
}
}
end.min(msgs.len())
}
};
let mut pair_count = 0usize;
let mut split_idx = msgs.len();
let mut i = msgs.len();
while i > head_end && pair_count < keep_pairs {
i -= 1;
if msgs[i].role == Role::User {
pair_count += 1;
split_idx = i;
}
}
split_idx = split_idx.max(head_end);
while split_idx > head_end
&& split_idx < msgs.len()
&& msgs[split_idx].role == Role::Tool
{
split_idx -= 1;
}
let head = msgs[..head_end].to_vec();
let mut old_portion = msgs[head_end..split_idx].to_vec();
let recent = msgs[split_idx..].to_vec();
if old_portion.is_empty() {
return; }
compress_tool_results(&mut old_portion, 6);
(head, msgs_to_text(&old_portion), recent)
} else {
let mut msgs = self.sessions.get(session_key).cloned().unwrap_or_default();
compress_tool_results(&mut msgs, 6);
(vec![], msgs_to_text(&msgs), vec![])
};
{
let entities = crate::agent::context_mgr::extract_key_entities(&old_text);
if !entities.is_empty() {
if let Some(ref mem) = self.memory {
let scope = format!("agent:{}", self.handle.id);
crate::agent::context_mgr::write_entity_memories(mem, &scope, entities).await;
debug!(session = session_key, "pre-compaction deterministic entities pinned");
}
}
}
let previous_summary = {
let msgs = self.sessions.get(session_key).cloned().unwrap_or_default();
msgs.iter().find_map(|m| {
if let MessageContent::Text(t) = &m.content {
if t.starts_with("[CONTEXT COMPACTION") {
let summary_start = t.find("\n\n").map(|i| i + 2).unwrap_or(0);
Some(t[summary_start..].to_owned())
} else {
None
}
} else {
None
}
})
};
let summary = if kv_cache_mode >= 1 && mode != CompactionMode::Safeguard {
let result = self.compact_with_kv_cache(
session_key,
compaction_model,
&old_text,
previous_summary.as_deref(),
).await;
if result.is_some() {
result
} else {
info!(session = session_key, "KV cache compact failed, falling back to standalone");
self.compact_single(compaction_model, &old_text, previous_summary.as_deref()).await
}
} else {
match mode {
CompactionMode::Default | CompactionMode::Layered => {
self.compact_single(
compaction_model,
&old_text,
previous_summary.as_deref(),
).await
}
CompactionMode::Safeguard => {
const CHUNK_SIZE: usize = 40_000;
let chunks: Vec<&str> = {
let mut result = Vec::new();
let mut remaining = old_text.as_str();
while !remaining.is_empty() {
let mut end = CHUNK_SIZE.min(remaining.len());
while end < remaining.len() && !remaining.is_char_boundary(end) {
end -= 1;
}
let (chunk, rest) = remaining.split_at(end);
result.push(chunk);
remaining = rest;
}
result
};
let mut combined = String::new();
for chunk in chunks {
match self.compact_single(compaction_model, chunk, None).await {
Some(s) => {
combined.push_str(&s);
combined.push('\n');
}
None => return,
}
}
if combined.is_empty() { None } else { Some(combined) }
}
}
};
let Some(summary) = summary else { return };
if let Some(ref mem) = self.memory {
let entities = parse_entities_from_summary(&summary);
if !entities.is_empty() {
let scope = format!("agent:{}", self.handle.id);
crate::agent::context_mgr::write_entity_memories(mem, &scope, entities).await;
debug!(session = session_key, "entities extracted from compaction summary");
}
}
if extract_facts {
if let Some(facts) = self.extract_key_facts(compaction_model, &old_text).await {
if let Some(ref mem) = self.memory {
let scope = format!("agent:{}", self.handle.id);
let mut guard = mem.lock().await;
for fact in facts.lines().filter(|l| !l.trim().is_empty()) {
let fact_text = fact.trim_start_matches("- ").trim();
if fact_text.len() > 5 {
let doc = crate::agent::memory::MemoryDoc {
id: format!("cf-{}", uuid::Uuid::new_v4()),
scope: scope.clone(),
kind: "compaction_fact".to_owned(),
text: fact_text.to_owned(),
vector: vec![],
created_at: 0, accessed_at: 0,
access_count: 0,
importance: 0.7, tier: Default::default(),
abstract_text: None,
overview_text: None,
tags: vec![],
pinned: false,
};
if let Err(e) = guard.add(doc).await {
tracing::warn!("compaction fact memory add failed: {e:#}");
}
}
}
drop(guard);
debug!(
session = session_key,
"key facts extracted to long-term memory"
);
}
}
}
if let Some(sess) = self.sessions.get_mut(session_key) {
let summary_msg = Message {
role: Role::User,
content: MessageContent::Text(format!(
"{COMPACTION_PREFIX}\n\n{summary}"
)),
};
sess.clear();
sess.extend(head_msgs);
sess.push(summary_msg);
sess.extend(recent_msgs);
}
self.compaction_state
.insert(session_key.to_owned(), (std::time::Instant::now(), 0));
if let Some(sess) = self.sessions.get(session_key) {
if let Err(e) = self.store.db.delete_session(session_key) {
tracing::warn!("compaction: failed to delete old session: {e:#}");
}
for msg in sess.iter() {
let val = serde_json::to_value(msg).unwrap_or_default();
if let Err(e) = self.store.db.append_message(session_key, &val) {
tracing::warn!("compaction: failed to persist message: {e:#}");
}
}
}
self.invalidate_plugins_skills_cache();
let new_tokens: usize = self
.sessions
.get(session_key)
.map(|msgs| msgs.iter().map(msg_tokens).sum())
.unwrap_or(0);
info!(
session = session_key,
tokens_before = total_tokens,
tokens_after = new_tokens,
keep_pairs,
"auto-compaction complete (layered)"
);
if new_tokens > token_threshold * 4 / 5 {
let zh = crate::i18n::default_lang() == "zh";
let hint = if zh {
"[system] 上下文压缩后仍然较大,响应可能变慢。请告知用户发送 /reset 重置会话以恢复正常速度。"
} else {
"[system] Context is still large after compaction and responses may slow down. Please tell the user to send /reset to start a fresh session."
};
if let Some(sess) = self.sessions.get_mut(session_key) {
sess.push(Message {
role: Role::System,
content: MessageContent::Text(hint.to_owned()),
});
}
warn!(
session = session_key,
tokens_after = new_tokens,
threshold = token_threshold,
"compaction insufficient, /reset recommended"
);
}
self.append_transcript(
session_key,
"[auto-compaction triggered]",
&format!("[summary: {summary}]"),
)
.await;
}
pub(crate) fn msgs_to_text_static(msgs: &[Message], max_total_tokens: usize) -> String {
fn trunc(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
None => s.to_owned(),
Some((byte_idx, _)) => {
let mut t = s[..byte_idx].to_owned();
t.push_str("...[truncated]");
t
}
}
}
fn compact_args(input: &Value) -> String {
const BULK_FIELDS: &[&str] = &["content", "old_string", "new_string"];
const MAX_BULK: usize = 300;
const MAX_CMD: usize = 500;
const MAX_TOTAL: usize = 2000;
if let Some(obj) = input.as_object() {
let needs = obj.iter().any(|(k, v)| {
let limit = if BULK_FIELDS.contains(&k.as_str()) { MAX_BULK }
else if k == "command" { MAX_CMD }
else { return false; };
v.as_str().map(|s| s.char_indices().nth(limit).is_some()).unwrap_or(false)
});
if needs {
let mut compact = serde_json::Map::new();
for (k, v) in obj {
let limit = if BULK_FIELDS.contains(&k.as_str()) { Some(MAX_BULK) }
else if k == "command" { Some(MAX_CMD) }
else { None };
if let (Some(lim), Some(s)) = (limit, v.as_str()) {
compact.insert(k.clone(), Value::String(trunc(s, lim)));
} else {
compact.insert(k.clone(), v.clone());
}
}
let ser = serde_json::to_string(&Value::Object(compact)).unwrap_or_default();
return if ser.char_indices().nth(MAX_TOTAL).is_some() { trunc(&ser, MAX_TOTAL) } else { ser };
}
}
let full = serde_json::to_string(input).unwrap_or_default();
if full.char_indices().nth(MAX_TOTAL).is_some() { trunc(&full, MAX_TOTAL) } else { full }
}
let render_msg = |m: &Message, detail: u8| -> String {
let role = format!("{:?}", m.role).to_lowercase();
let body = match &m.content {
MessageContent::Text(t) => {
if detail == 0 { trunc(t, 200) } else { t.clone() }
}
MessageContent::Parts(parts) => parts
.iter()
.filter_map(|p| match p {
ContentPart::Text { text } => Some(
if detail == 0 { trunc(text, 200) } else { text.clone() }
),
ContentPart::ToolUse { name, input, .. } => match detail {
2 => Some(format!("[tool_call: {name}({})]", compact_args(input))),
1 => Some(format!("[tool_call: {name}({})]",
trunc(&serde_json::to_string(input).unwrap_or_default(), 100))),
_ => Some(format!("[tool_call: {name}]")),
},
ContentPart::ToolResult { tool_use_id: _, content, .. } => match detail {
2 => Some(format!("[tool_result: {}]", trunc(content, 800))),
1 => Some(format!("[tool_result: {}]", trunc(content, 150))),
_ => None,
},
ContentPart::Image { .. } => Some("[image]".to_owned()),
#[allow(unreachable_patterns)]
_ => None,
})
.collect::<Vec<_>>()
.join(" "),
};
format!("{role}: {body}")
};
let full: Vec<String> = msgs.iter().map(|m| render_msg(m, 2)).collect();
let full_tokens: Vec<usize> = full.iter().map(|s| estimate_tokens(s)).collect();
let total: usize = full_tokens.iter().sum();
if total <= max_total_tokens {
return full.join("\n");
}
let n = msgs.len();
let mut detail_levels = vec![0u8; n];
let mut budget_used = 0usize;
for i in (0..n).rev() {
if budget_used + full_tokens[i] <= max_total_tokens {
detail_levels[i] = 2;
budget_used += full_tokens[i];
} else {
let m = &msgs[i];
for &d in &[1u8, 0] {
let rendered = render_msg(m, d);
let cost = estimate_tokens(&rendered);
if budget_used + cost <= max_total_tokens || d == 0 {
detail_levels[i] = d;
budget_used += cost.min(max_total_tokens.saturating_sub(budget_used));
break;
}
}
}
if budget_used >= max_total_tokens {
break;
}
}
let mut result = String::new();
let mut tokens_used = 0usize;
for (i, m) in msgs.iter().enumerate() {
let line = if detail_levels[i] == 2 {
full[i].clone()
} else {
render_msg(m, detail_levels[i])
};
let line_tokens = estimate_tokens(&line);
if tokens_used + line_tokens > max_total_tokens {
result.push_str("\n...[context truncated]");
break;
}
result.push_str(&line);
result.push('\n');
tokens_used += line_tokens;
}
result
}
pub(crate) async fn compact_with_kv_cache(
&mut self,
session_key: &str,
model: &str,
_old_text: &str,
previous_summary: Option<&str>,
) -> Option<String> {
let system_prompt = self.cached_system_prompt.clone()?;
let mut messages = self
.sessions
.get(session_key)
.cloned()
.unwrap_or_default();
if messages.is_empty() {
return None;
}
let template = Self::summary_template();
let instruction = if let Some(prev) = previous_summary {
format!(
"Ignore all previous instructions. You are now a summarization agent. \
Do NOT call any tools. Do NOT answer questions. Output ONLY a structured summary.\n\n\
Update the previous compaction summary with the new conversation turns above.\n\n\
PREVIOUS SUMMARY:\n{prev}\n\n\
PRESERVE existing info, ADD new actions, update Active Task.\n\n{template}"
)
} else {
format!(
"Ignore all previous instructions. You are now a summarization agent. \
Do NOT call any tools. Do NOT answer questions. Output ONLY a structured summary \
of the entire conversation above.\n\n{template}"
)
};
messages.push(Message {
role: Role::User,
content: MessageContent::Text(instruction),
});
let tools = self.cached_tools.clone();
let req = LlmRequest {
model: model.to_owned(),
messages,
tools,
system: Some(system_prompt),
max_tokens: Some(4096),
temperature: None,
frequency_penalty: None,
thinking_budget: None, kv_cache_mode: 0, session_key: None,
};
let providers = Arc::clone(&self.providers);
let mut stream = match self.failover.call(req, &providers).await {
Ok(s) => s,
Err(e) => {
warn!("KV cache compact LLM call failed: {e:#}");
return None;
}
};
let mut summary = String::new();
while let Some(event) = stream.next().await {
match event {
Ok(StreamEvent::TextDelta(d)) => summary.push_str(&d),
Ok(StreamEvent::ReasoningDelta(_)) => {}
Ok(StreamEvent::Done { .. }) | Ok(StreamEvent::Error(_)) => break,
Ok(StreamEvent::ToolCall { .. }) => {
warn!("compact_with_kv_cache: unexpected tool call, ignoring");
}
Err(e) => {
warn!("KV cache compact stream error: {e:#}");
return None;
}
}
}
if summary.is_empty() {
None
} else {
info!("compact_with_kv_cache: summary generated ({} chars)", summary.len());
Some(summary)
}
}
fn summary_template() -> &'static str {
"Use this exact structure:\n\n\
## Active Task\n\
[THE MOST IMPORTANT FIELD. Copy the user's most recent unfulfilled request \
verbatim. If no outstanding task, write \"None.\"]\n\n\
## Goal\n[Overall goal]\n\n\
## Completed\n[Numbered list: N. ACTION target - outcome]\n\n\
## Active State\n[Modified files, test status, running processes, branch]\n\n\
## In Progress\n[Work underway when compaction fired]\n\n\
## Key Data\n[Exact values verbatim: file paths, URLs, IDs, phone numbers]\n\n\
## Decisions\n[Technical decisions and WHY]\n\n\
## Pending\n[Blocked items or awaiting user]\n\n\
## Resolved Questions\n[Already answered — include the answer]\n\n\
## Files\n[Files read/modified/created]\n\n\
## Entities\n[kind=value per line. Kinds: name, phone, id_card, email, birthday, \
age, zodiac, address, relationship, preference. If none: (none)]\n\n\
CRITICAL: Copy ALL values character-for-character. Be CONCRETE."
}
pub(crate) async fn compact_single(
&mut self,
model: &str,
history: &str,
previous_summary: Option<&str>,
) -> Option<String> {
let preamble = "You are a summarization agent creating a context checkpoint. \
Your output will be injected as reference for a DIFFERENT assistant that \
continues the conversation. Do NOT respond to any questions or requests \
in the conversation - only output the structured summary. \
Do NOT include any preamble, greeting, or prefix.";
let template = Self::summary_template();
let prompt = if let Some(prev) = previous_summary {
format!(
"{preamble}\n\n\
You are updating a context compaction summary. A previous compaction \
produced the summary below. New conversation turns have occurred \
since then and need to be incorporated.\n\n\
PREVIOUS SUMMARY:\n{prev}\n\n\
NEW TURNS TO INCORPORATE:\n{history}\n\n\
Update the summary using this exact structure. PRESERVE all existing \
information that is still relevant. ADD new completed actions \
(continue numbering). Move items from \"In Progress\" to \"Completed\" \
when done. Update \"Active State\" to reflect current state. \
Remove information only if clearly obsolete. \
CRITICAL: Update \"## Active Task\" to the user's most recent \
unfulfilled request.\n\n{template}"
)
} else {
format!(
"{preamble}\n\n\
Create a structured handoff summary. The next assistant should \
understand what happened without re-reading the original turns.\n\n\
TURNS TO SUMMARIZE:\n{history}\n\n\
Use this exact structure:\n\n{template}"
)
};
let req = LlmRequest {
model: model.to_owned(),
messages: vec![Message {
role: Role::User,
content: MessageContent::Text(prompt),
}],
tools: vec![], system: None, max_tokens: Some(4096),
temperature: None,
frequency_penalty: None,
thinking_budget: None, kv_cache_mode: 0, session_key: None,
};
let providers = Arc::clone(&self.providers);
let mut stream = match self.failover.call(req, &providers).await {
Ok(s) => s,
Err(e) => {
warn!("compaction LLM call failed: {e:#}");
return None;
}
};
let mut summary = String::new();
while let Some(event) = stream.next().await {
match event {
Ok(StreamEvent::TextDelta(d)) => summary.push_str(&d),
Ok(StreamEvent::ReasoningDelta(_)) => {} Ok(StreamEvent::Done { .. }) | Ok(StreamEvent::Error(_)) => break,
Ok(StreamEvent::ToolCall { .. }) => {} Err(e) => {
warn!("compaction stream error: {e:#}");
return None;
}
}
}
if summary.is_empty() {
None
} else {
Some(summary)
}
}
pub(crate) async fn extract_key_facts(&mut self, model: &str, history: &str) -> Option<String> {
let input = if history.len() > 60_000 {
let mut end = 60_000;
while end < history.len() && !history.is_char_boundary(end) {
end += 1;
}
&history[..end]
} else {
history
};
let req = LlmRequest {
model: model.to_owned(),
messages: vec![Message {
role: Role::User,
content: MessageContent::Text(format!(
"Extract the key facts from this conversation that should be remembered \
long-term. Output ONLY a bullet list (one fact per line, prefixed with \
'- '). Include: names, user IDs, chat IDs, phone numbers, account numbers, \
any numeric sequences that were looked up or confirmed, important decisions, \
file paths, URLs, preferences, and action items. \
IMPORTANT: copy numeric values (phone numbers, IDs) character-for-character — \
never truncate or paraphrase them. Be concise. Skip ephemeral chit-chat.\n\n{input}"
)),
}],
tools: vec![],
system: Some(
"You extract key facts from conversations. Output only a bullet list.".to_owned(),
),
max_tokens: Some(1024),
temperature: None,
frequency_penalty: None,
thinking_budget: None, kv_cache_mode: 0, session_key: None,
};
let providers = Arc::clone(&self.providers);
let mut stream = match self.failover.call(req, &providers).await {
Ok(s) => s,
Err(e) => {
warn!("key fact extraction failed: {e:#}");
return None;
}
};
let mut result = String::new();
while let Some(event) = stream.next().await {
match event {
Ok(StreamEvent::TextDelta(d)) => result.push_str(&d),
Ok(StreamEvent::Done { .. }) | Ok(StreamEvent::Error(_)) => break,
_ => {}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
pub(crate) async fn append_transcript(&self, session_key: &str, user_text: &str, assistant_text: &str) {
let transcripts_dir = dirs_next::home_dir()
.unwrap_or_default()
.join(".rsclaw/transcripts");
let safe_key: String = session_key
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let path = transcripts_dir.join(format!("{safe_key}.jsonl"));
if let Err(e) = tokio::fs::create_dir_all(&transcripts_dir).await {
warn!("transcript mkdir: {e:#}");
return;
}
let ts = Utc::now().to_rfc3339();
let mut lines = String::new();
for (role, content) in [("user", user_text), ("assistant", assistant_text)] {
let entry = json!({
"role": role,
"content": content,
"session": session_key,
"agent": self.handle.id,
"ts": ts,
});
if let Ok(s) = serde_json::to_string(&entry) {
lines.push_str(&s);
lines.push('\n');
}
}
match tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
{
Ok(mut f) => {
if let Err(e) = f.write_all(lines.as_bytes()).await {
warn!("transcript write: {e:#}");
}
}
Err(e) => warn!("transcript open: {e:#}"),
}
}
}
fn parse_entities_from_summary(summary: &str) -> Vec<crate::agent::context_mgr::KeyEntity> {
let mut entities = Vec::new();
let section_start = summary.find("## Entities");
let Some(start) = section_start else {
return entities;
};
let content = &summary[start..];
let section_end = content[3..].find("\n## ").map(|i| i + 3).unwrap_or(content.len());
let section = &content[..section_end];
let kind_to_label: &[(&str, &str, &'static str)] = &[
("name", "用户姓名", "name"),
("phone", "用户手机号", "phone_number"),
("id_card", "用户身份证", "id_card"),
("email", "用户邮箱", "email"),
("birthday", "用户生日", "birthday"),
("age", "用户年龄", "age"),
("zodiac", "用户星座", "zodiac"),
("lucky_number", "用户幸运数字", "lucky_number"),
("address", "用户地址", "address"),
("relationship", "用户关系", "relationship"),
("preference", "用户偏好", "preference"),
];
for line in section.lines().skip(1) {
let line = line.trim();
if line.is_empty() || line == "(none)" || line.starts_with("##") {
continue;
}
if let Some((kind, value)) = line.split_once('=') {
let kind = kind.trim().to_lowercase();
let value = value.trim();
if value.is_empty() {
continue;
}
if let Some((_, label, static_kind)) = kind_to_label.iter().find(|(k, _, _)| *k == kind) {
entities.push(crate::agent::context_mgr::KeyEntity {
kind: static_kind,
value: value.to_owned(),
memory_text: format!("{label}: {value}"),
});
}
}
}
entities
}