gitdataai/lib/service/agent/compaction.rs

166 lines
5.2 KiB
Rust

use ai::agent::AgentConfig;
use ai::agent::RigAgent;
use ai::agent::request::AgentRequest;
use ai::client::AiClient;
use db::sqlx;
use tracing::{info, warn};
use uuid::Uuid;
use crate::AppService;
use crate::error::AppError;
const COMPACTION_SYSTEM_PROMPT: &str = r#"You are a conversation context compaction assistant.
Your task: summarize the older portion of a conversation so the agent can continue working with only the summary + recent messages.
Rules:
- Preserve: key decisions, file paths, technical details, user preferences, unresolved questions.
- Discard: redundant tool outputs, verbose explanations that were already acted upon, pleasantries.
- Write in the same language as the conversation.
- Output a concise structured summary using bullet points.
- Keep the summary under 800 tokens.
- Do NOT answer any questions from the conversation. Only summarize."#;
const COMPACTION_TRIGGER_CHARS: usize = 80_000;
const RECENT_MESSAGES_TO_KEEP: usize = 10;
impl AppService {
pub async fn agent_maybe_compact(
&self,
ai_client: &AiClient,
model_name: &str,
conversation_id: Uuid,
) -> Result<(), AppError> {
let rows: Vec<(Uuid, String, String)> = sqlx::query_as(
"SELECT id, role, content \
FROM agent_message \
WHERE conversation = $1 \
AND deleted_at IS NULL \
AND status = 'completed' \
ORDER BY created_at ASC",
)
.bind(conversation_id)
.fetch_all(self.db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
let total_chars: usize = rows.iter().map(|(_, _, c)| c.len()).sum();
if total_chars < COMPACTION_TRIGGER_CHARS {
return Ok(());
}
if rows.len() <= RECENT_MESSAGES_TO_KEEP {
return Ok(());
}
let split_at = rows.len().saturating_sub(RECENT_MESSAGES_TO_KEEP);
let older = &rows[..split_at];
let existing_summary: Option<String> = sqlx::query_scalar(
"SELECT compacted_summary FROM agent_conversation WHERE id = $1",
)
.bind(conversation_id)
.fetch_optional(self.db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?
.flatten();
let mut body = String::new();
if let Some(ref prev) = existing_summary {
body.push_str("<previous-summary>\n");
body.push_str(prev);
body.push_str("\n</previous-summary>\n\n");
body.push_str(
"Merge the previous summary with the new messages below:\n\n",
);
}
for (_, role, content) in older {
body.push_str(&format!("[{}]: {}\n\n", role, content));
}
let summary = match self
.agent_run_compaction_llm(ai_client, model_name, &body)
.await
{
Ok(s) => s,
Err(e) => {
warn!(
conversation_id = %conversation_id,
error = %e,
"compaction LLM call failed, skipping compaction"
);
return Ok(());
}
};
sqlx::query(
"UPDATE agent_conversation \
SET compacted_summary = $1, updated_at = now() \
WHERE id = $2",
)
.bind(&summary)
.bind(conversation_id)
.execute(self.db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
let ids: Vec<Uuid> = older.iter().map(|(id, _, _)| *id).collect();
if !ids.is_empty() {
let now = chrono::Utc::now();
sqlx::query(
"UPDATE agent_message \
SET deleted_at = $1, updated_at = $1 \
WHERE id = ANY($2::uuid[]) AND deleted_at IS NULL",
)
.bind(now)
.bind(&ids)
.execute(self.db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
}
info!(
conversation_id = %conversation_id,
compacted_count = older.len(),
kept_recent = RECENT_MESSAGES_TO_KEEP,
"conversation compacted successfully"
);
Ok(())
}
async fn agent_run_compaction_llm(
&self,
ai_client: &AiClient,
model_name: &str,
body: &str,
) -> Result<String, AppError> {
let config = AgentConfig::new(model_name)
.map_err(|e| AppError::AiError(e))?
.with_system_prompt(COMPACTION_SYSTEM_PROMPT)
.with_temperature(Some(0.2))
.with_max_completion_tokens(Some(1024))
.with_quiet_mode(true);
let agent = RigAgent::new(ai_client.clone(), config)
.map_err(|e| AppError::AiError(e))?;
let request = AgentRequest::new(body);
let summary = agent
.chat(request, Vec::new())
.await
.map_err(|e| AppError::AiError(e))?;
let summary = summary.trim().to_string();
if summary.is_empty() {
return Err(AppError::InternalServerError(
"compaction returned empty summary".to_string(),
));
}
Ok(summary)
}
}