gitdataai/libs/agent/compact/room_compactor.rs
ZhenYi 8d144ac139 feat(agent): add architect, debugger, implementer, tester, security sub-agent roles
Extend delegation system with 5 new specialized roles alongside
researcher/analyst/reviewer. Each role has curated tool access.
Refactor profile lookup to use profile_for_role_name and update
compact/summarizer and tool context accordingly.
2026-05-18 20:42:57 +08:00

423 lines
15 KiB
Rust

use models::rooms::room_message::{
Column as RmCol, Entity as RoomMessage, Model as RoomMessageModel,
};
use sea_orm::ColumnTrait;
use sea_orm::{ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use crate::compact::types::{CompactConfig, CompactLevel, RoomCompactContext, RoomCompactRecord};
use crate::tokent::resolve_usage;
use crate::{AgentError, CompactSummary, MessageSummary};
impl super::CompactService {
pub async fn latest_room_compact_record(
&self,
room_id: uuid::Uuid,
) -> Result<Option<RoomCompactRecord>, AgentError> {
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DbBackend::Postgres,
"SELECT id, room, from_seq, to_seq, summary, message_count, source_message_ids, created_at \
FROM room_compact_summary WHERE room = $1 ORDER BY to_seq DESC, created_at DESC LIMIT 1",
vec![room_id.into()],
);
let Some(row) = self
.db
.query_one_raw(stmt)
.await
.map_err(|e| AgentError::Internal(e.to_string()))?
else {
return Ok(None);
};
let source_json: serde_json::Value = row
.try_get("", "source_message_ids")
.map_err(|e| AgentError::Internal(e.to_string()))?;
let source_message_ids = source_json
.as_array()
.map(|ids| {
ids.iter()
.filter_map(|v| v.as_str())
.filter_map(|s| uuid::Uuid::parse_str(s).ok())
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(Some(RoomCompactRecord {
id: row
.try_get("", "id")
.map_err(|e| AgentError::Internal(e.to_string()))?,
room_id: row
.try_get("", "room")
.map_err(|e| AgentError::Internal(e.to_string()))?,
from_seq: row
.try_get("", "from_seq")
.map_err(|e| AgentError::Internal(e.to_string()))?,
to_seq: row
.try_get("", "to_seq")
.map_err(|e| AgentError::Internal(e.to_string()))?,
summary: row
.try_get("", "summary")
.map_err(|e| AgentError::Internal(e.to_string()))?,
message_count: row
.try_get("", "message_count")
.map_err(|e| AgentError::Internal(e.to_string()))?,
source_message_ids,
created_at: row
.try_get("", "created_at")
.map_err(|e| AgentError::Internal(e.to_string()))?,
}))
}
async fn insert_room_compact_record(
&self,
room_id: uuid::Uuid,
from_seq: i64,
to_seq: i64,
summary: &str,
source_message_ids: &[uuid::Uuid],
) -> Result<RoomCompactRecord, AgentError> {
let id = uuid::Uuid::new_v4();
let now = chrono::Utc::now();
let source_json = serde_json::Value::Array(
source_message_ids
.iter()
.map(|id| serde_json::Value::String(id.to_string()))
.collect(),
);
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DbBackend::Postgres,
"INSERT INTO room_compact_summary \
(id, room, from_seq, to_seq, summary, message_count, source_message_ids, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
vec![
id.into(),
room_id.into(),
from_seq.into(),
to_seq.into(),
summary.to_string().into(),
(source_message_ids.len() as i32).into(),
source_json.into(),
now.into(),
now.into(),
],
);
self.db
.execute_raw(stmt)
.await
.map_err(|e| AgentError::Internal(e.to_string()))?;
Ok(RoomCompactRecord {
id,
room_id,
from_seq,
to_seq,
summary: summary.to_string(),
message_count: source_message_ids.len() as i32,
source_message_ids: source_message_ids.to_vec(),
created_at: now,
})
}
fn clean_dedupe_sort_messages(mut messages: Vec<RoomMessageModel>) -> Vec<RoomMessageModel> {
messages.retain(|m| {
m.revoked.is_none()
&& !m.content.trim().is_empty()
&& matches!(m.content_type, models::rooms::MessageContentType::Text)
});
messages.sort_by_key(|m| (m.seq, m.send_at));
let mut seen = std::collections::HashSet::new();
messages
.into_iter()
.filter(|m| {
let normalized = m
.content
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
let key = format!("{}:{:?}:{}", m.sender_type, m.sender_id, normalized);
seen.insert(key)
})
.collect()
}
fn resolve_retain_count(config: CompactConfig, estimated_tokens: usize) -> usize {
let level = if config.auto_level {
CompactLevel::auto_select(estimated_tokens, config.token_threshold)
} else {
config.default_level
};
level.retain_count()
}
pub async fn prepare_room_compact_context(
&self,
room_id: uuid::Uuid,
requester_id: uuid::Uuid,
user_names: Option<std::collections::HashMap<uuid::Uuid, String>>,
config: CompactConfig,
) -> Result<RoomCompactContext, AgentError> {
let latest = self.latest_room_compact_record(room_id).await?;
let cutoff_seq = latest.as_ref().map(|r| r.to_seq);
let previous_summary = latest.as_ref().map(|r| r.summary.as_str());
let messages = self
.fetch_room_messages_secure(room_id, requester_id)
.await?;
let messages = messages
.into_iter()
.filter(|m| cutoff_seq.map(|seq| m.seq > seq).unwrap_or(true))
.collect::<Vec<_>>();
let messages = Self::clean_dedupe_sort_messages(messages);
let user_ids: Vec<uuid::Uuid> = messages
.iter()
.filter_map(|m| m.sender_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let user_name_map = match user_names {
Some(map) => map,
None => self.get_user_name_map(&user_ids).await?,
};
let sender_mapper = |m: &RoomMessageModel| {
if let Some(user_id) = m.sender_id {
if let Some(username) = user_name_map.get(&user_id) {
return username.clone();
}
}
m.sender_type.to_string()
};
let incremental_text = crate::compact::helpers::messages_to_text(&messages, sender_mapper);
let estimate_input = match previous_summary {
Some(summary) if !summary.is_empty() => format!("{}\n{}", summary, incremental_text),
_ => incremental_text.clone(),
};
let estimated_tokens = crate::tokent::count_message_text(&estimate_input, &self.model)
.unwrap_or_else(|_| estimate_input.len() / 4);
let retain_count = Self::resolve_retain_count(config, estimated_tokens);
if estimated_tokens >= config.token_threshold && messages.len() > retain_count {
let split_index = messages.len().saturating_sub(retain_count);
let (to_summarize, retained_messages) = messages.split_at(split_index);
let from_seq = to_summarize
.first()
.map(|m| m.seq)
.unwrap_or(cutoff_seq.unwrap_or(0) + 1);
let to_seq = to_summarize.last().map(|m| m.seq).unwrap_or(from_seq);
let source_ids: Vec<uuid::Uuid> = to_summarize.iter().map(|m| m.id).collect();
let (summary, _usage) = self
.summarize_room_increment(previous_summary, to_summarize, config.max_summary_tokens)
.await?;
let record = self
.insert_room_compact_record(room_id, from_seq, to_seq, &summary, &source_ids)
.await?;
let retained = retained_messages
.iter()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
return Ok(RoomCompactContext {
room_id,
cutoff_seq: Some(record.to_seq),
summary: Some(record.summary),
retained,
estimated_tokens,
compacted: true,
});
}
let retained = messages
.iter()
.rev()
.take(50)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
Ok(RoomCompactContext {
room_id,
cutoff_seq,
summary: latest.map(|r| r.summary),
retained,
estimated_tokens,
compacted: false,
})
}
pub async fn compact_room(
&self,
room_id: uuid::Uuid,
level: CompactLevel,
user_names: Option<std::collections::HashMap<uuid::Uuid, String>>,
requester_id: uuid::Uuid,
context_window_tokens: i32,
compaction_max_summary_ratio: f32,
) -> Result<CompactSummary, AgentError> {
let messages = self
.fetch_room_messages_secure(room_id, requester_id)
.await?;
if messages.is_empty() {
let room_exists = models::rooms::room::Entity::find_by_id(room_id)
.one(&self.db)
.await
.map_err(|e| AgentError::Internal(e.to_string()))?
.is_some();
if room_exists {
return Err(AgentError::Internal("Access denied or room empty".into()));
} else {
return Err(AgentError::Internal("Room not found".into()));
}
}
let user_ids: Vec<uuid::Uuid> = messages
.iter()
.filter_map(|m| m.sender_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let user_name_map = match user_names {
Some(map) => map,
None => self.get_user_name_map(&user_ids).await?,
};
if messages.len() <= level.retain_count() {
let retained: Vec<MessageSummary> = messages
.iter()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
return Ok(CompactSummary {
session_id: uuid::Uuid::new_v4(),
room_id,
retained,
summary: String::new(),
compacted_at: chrono::Utc::now(),
messages_compressed: 0,
usage: None,
});
}
let retain_count = level.retain_count();
let split_index = messages.len().saturating_sub(retain_count);
let (to_summarize, retained_messages) = messages.split_at(split_index);
let retained: Vec<MessageSummary> = retained_messages
.iter()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
let max_summary_tokens = CompactConfig::summary_token_budget(
context_window_tokens.max(0) as usize,
compaction_max_summary_ratio,
);
let (summary, remote_usage) = self
.summarize_messages(to_summarize, max_summary_tokens)
.await?;
let summarized_text = to_summarize
.iter()
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let usage = resolve_usage(remote_usage, &self.model, &summarized_text, &summary);
Ok(CompactSummary {
session_id: uuid::Uuid::new_v4(),
room_id,
retained,
summary,
compacted_at: chrono::Utc::now(),
messages_compressed: to_summarize.len(),
usage: Some(usage),
})
}
pub async fn compact_session(
&self,
session_id: uuid::Uuid,
level: CompactLevel,
user_names: Option<std::collections::HashMap<uuid::Uuid, String>>,
context_window_tokens: i32,
compaction_max_summary_ratio: f32,
) -> Result<CompactSummary, AgentError> {
let messages: Vec<RoomMessageModel> = RoomMessage::find()
.filter(RmCol::Room.eq(session_id))
.order_by_asc(RmCol::Seq)
.limit(10000)
.all(&self.db)
.await
.map_err(|e| AgentError::Internal(e.to_string()))?;
if messages.is_empty() {
return Err(AgentError::Internal("session has no messages".into()));
}
let user_ids: Vec<uuid::Uuid> = messages
.iter()
.filter_map(|m| m.sender_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let user_name_map = match user_names {
Some(map) => map,
None => self.get_user_name_map(&user_ids).await?,
};
if messages.len() <= level.retain_count() {
let retained: Vec<MessageSummary> = messages
.iter()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
return Ok(CompactSummary {
session_id,
room_id: uuid::Uuid::nil(),
retained,
summary: String::new(),
compacted_at: chrono::Utc::now(),
messages_compressed: 0,
usage: None,
});
}
let retain_count = level.retain_count();
let split_index = messages.len().saturating_sub(retain_count);
let (to_summarize, retained_messages) = messages.split_at(split_index);
let retained: Vec<MessageSummary> = retained_messages
.iter()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
let max_summary_tokens = CompactConfig::summary_token_budget(
context_window_tokens.max(0) as usize,
compaction_max_summary_ratio,
);
let (summary, remote_usage) = self
.summarize_messages(to_summarize, max_summary_tokens)
.await?;
let summarized_text = to_summarize
.iter()
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let usage = resolve_usage(remote_usage, &self.model, &summarized_text, &summary);
Ok(CompactSummary {
session_id,
room_id: uuid::Uuid::nil(),
retained,
summary,
compacted_at: chrono::Utc::now(),
messages_compressed: to_summarize.len(),
usage: Some(usage),
})
}
}