//! Chat endpoint for LLM-powered file editing.
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::db::{models::BodyElement, repository::{self, RepositoryError}};
use crate::llm::{
claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
execute_tool_call,
groq::{GroqClient, GroqError, Message, ToolCallResponse},
LlmModel, ToolCall, ToolResult, UserQuestion, VersionToolRequest, AVAILABLE_TOOLS,
};
use crate::server::state::{FileUpdateNotification, SharedState};
/// Maximum number of tool-calling rounds to prevent infinite loops
const MAX_TOOL_ROUNDS: usize = 20;
/// Context limits for different models (in tokens)
/// Claude models have 200K context, Groq models vary
const CLAUDE_CONTEXT_LIMIT: usize = 200_000;
const GROQ_CONTEXT_LIMIT: usize = 32_000;
/// Threshold for triggering context compaction (90% of limit)
const CONTEXT_COMPACTION_THRESHOLD: f32 = 0.90;
/// Approximate characters per token (rough estimate for English text)
const CHARS_PER_TOKEN: usize = 4;
#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChatHistoryMessage {
/// Role: "user" or "assistant"
pub role: String,
/// Message content
pub content: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChatRequest {
/// The user's message/instruction
pub message: String,
/// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq"
#[serde(default)]
pub model: Option<String>,
/// Optional conversation history for context continuity
#[serde(default)]
pub history: Option<Vec<ChatHistoryMessage>>,
/// Optional focused element index (for targeted editing)
#[serde(default)]
pub focused_element_index: Option<usize>,
}
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChatResponse {
/// The LLM's response message
pub response: String,
/// Tool calls that were executed
pub tool_calls: Vec<ToolCallInfo>,
/// Updated file body after tool execution
pub updated_body: Vec<BodyElement>,
/// Updated summary (if changed)
pub updated_summary: Option<String>,
/// Questions pending user answers (pauses conversation)
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_questions: Option<Vec<UserQuestion>>,
}
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ToolCallInfo {
pub name: String,
pub result: ToolResult,
}
/// Enum to hold LLM clients
enum LlmClient {
Groq(GroqClient),
Claude(ClaudeClient),
}
/// Unified result from LLM call
struct LlmResult {
content: Option<String>,
tool_calls: Vec<ToolCall>,
raw_tool_calls: Vec<ToolCallResponse>,
finish_reason: String,
}
/// Chat with a file using LLM tool calling
#[utoipa::path(
post,
path = "/api/v1/files/{id}/chat",
request_body = ChatRequest,
responses(
(status = 200, description = "Chat completed successfully", body = ChatResponse),
(status = 404, description = "File not found"),
(status = 500, description = "Internal server error")
),
params(
("id" = Uuid, Path, description = "File ID")
),
tag = "chat"
)]
pub async fn chat_handler(
State(state): State<SharedState>,
Path(id): Path<Uuid>,
Json(request): Json<ChatRequest>,
) -> impl IntoResponse {
// Check if database is configured
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "Database not configured"
})),
)
.into_response();
};
// Get the file
let file = match repository::get_file(pool, id).await {
Ok(Some(file)) => file,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "File not found"
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Database error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Database error: {}", e)
})),
)
.into_response();
}
};
// Parse model selection (default to Claude Sonnet)
let model = request
.model
.as_ref()
.and_then(|m| LlmModel::from_str(m))
.unwrap_or_default();
tracing::info!("Using LLM model: {:?}", model);
// Initialize the appropriate LLM client
let llm_client = match model {
LlmModel::ClaudeSonnet => {
match ClaudeClient::from_env(ClaudeModel::Sonnet) {
Ok(client) => LlmClient::Claude(client),
Err(ClaudeError::MissingApiKey) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "ANTHROPIC_API_KEY not configured"
})),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Claude client error: {}", e)
})),
)
.into_response();
}
}
}
LlmModel::ClaudeOpus => {
match ClaudeClient::from_env(ClaudeModel::Opus) {
Ok(client) => LlmClient::Claude(client),
Err(ClaudeError::MissingApiKey) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "ANTHROPIC_API_KEY not configured"
})),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Claude client error: {}", e)
})),
)
.into_response();
}
}
}
LlmModel::GroqKimi => {
match GroqClient::from_env() {
Ok(client) => LlmClient::Groq(client),
Err(GroqError::MissingApiKey) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "GROQ_API_KEY not configured"
})),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Groq client error: {}", e)
})),
)
.into_response();
}
}
}
};
// Build context about the file
let file_context = build_file_context(&file);
// Build focused element context if specified
let focused_context = build_focused_element_context(&file.body, request.focused_element_index);
// Build agentic system prompt
let system_prompt = format!(
r#"You are an intelligent document editing agent. You help users view, analyze, and modify document files.
## Your Capabilities
You have access to tools for:
- **Viewing content**: view_body (see all elements), read_element (inspect specific element), view_transcript (read full transcript)
- **Adding content**: add_heading, add_paragraph, add_code, add_list, add_chart
- **Modifying content**: update_element, remove_element, reorder_elements, clear_body
- **Document metadata**: set_summary
- **Data processing**: parse_csv (convert CSV to JSON), jq (transform JSON data)
- **Version history**: list_versions, read_version, restore_version
- **Templates**: suggest_templates (get phase-appropriate templates), apply_template (apply a template structure)
## Agentic Behavior Guidelines
### 1. Analyze Before Acting
- For complex requests, first gather information using view_body, view_transcript, or read_element
- Understand the current state of the document before making changes
- For simple, direct requests (e.g., "add a heading called X"), you can act immediately without prior inspection
### 2. Plan Multi-Step Operations
- Break complex tasks into logical steps
- For data visualization: parse_csv → (optionally jq to transform) → add_chart
- For restructuring: view_body → understand structure → make targeted changes
### 3. Handle Errors Gracefully
- If a tool call fails, analyze the error message
- Try an alternative approach or different parameters
- Don't repeat the exact same failing call
### 4. Know When to Stop
- Stop when you've completed the user's request
- Stop when you've provided the requested information
- Provide a clear summary of what you did in your final response
### 5. Be Efficient
- Don't over-analyze simple requests
- Use the minimum number of tool calls needed
- Combine operations when possible
## Current Document Context
{file_context}
{focused_context}
## Important Notes
- Body element indices are 0-based
- When updating elements, provide ALL required fields for that element type
- The transcript is read-only (you cannot modify it, only read it)
- Changes are saved automatically after tool execution"#,
file_context = file_context,
focused_context = focused_context
);
// Build initial messages (Groq/OpenAI format - will be converted for Claude)
let mut messages = vec![
Message {
role: "system".to_string(),
content: Some(system_prompt),
tool_calls: None,
tool_call_id: None,
},
];
// Add conversation history if provided (for context continuity)
if let Some(history) = &request.history {
for hist_msg in history {
messages.push(Message {
role: hist_msg.role.clone(),
content: Some(hist_msg.content.clone()),
tool_calls: None,
tool_call_id: None,
});
}
tracing::info!(
history_messages = history.len(),
"Loaded conversation history"
);
}
// Add current user message
messages.push(Message {
role: "user".to_string(),
content: Some(request.message.clone()),
tool_calls: None,
tool_call_id: None,
});
// State for tracking changes
let mut current_body = file.body.clone();
let mut current_summary = file.summary.clone();
let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new();
let mut final_response: Option<String> = None;
// Track if a version restore already happened (to avoid double-saving)
let mut version_restored = false;
// Track if there were modifications after a restore
let mut has_changes_after_restore = false;
// Track consecutive failures for agentic retry logic
let mut consecutive_failures = 0;
const MAX_CONSECUTIVE_FAILURES: usize = 3;
// Track pending user questions (pauses the conversation)
let mut pending_questions: Option<Vec<UserQuestion>> = None;
// Multi-turn agentic tool calling loop
for round in 0..MAX_TOOL_ROUNDS {
tracing::info!(
round = round,
body_elements = current_body.len(),
total_tool_calls = all_tool_call_infos.len(),
"Agentic loop iteration"
);
// Check if we've hit too many consecutive failures
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
tracing::warn!("Breaking loop due to {} consecutive failures", consecutive_failures);
final_response = Some(format!(
"I encountered multiple consecutive errors and stopped to avoid an infinite loop. \
Please try rephrasing your request or check if the document state is as expected."
));
break;
}
// Check context usage and compact if nearing limit
if is_context_near_limit(&messages, &model) {
let estimated_tokens = estimate_total_tokens(&messages);
tracing::warn!(
estimated_tokens = estimated_tokens,
round = round,
"Context nearing limit, compacting conversation"
);
compact_conversation(&mut messages, &all_tool_call_infos);
// Log the new token count
let new_tokens = estimate_total_tokens(&messages);
tracing::info!(
tokens_before = estimated_tokens,
tokens_after = new_tokens,
tokens_saved = estimated_tokens - new_tokens,
"Conversation compacted"
);
}
// Call the appropriate LLM API
let result = match &llm_client {
LlmClient::Groq(groq) => {
match groq.chat_with_tools(messages.clone(), &AVAILABLE_TOOLS).await {
Ok(r) => LlmResult {
content: r.content,
tool_calls: r.tool_calls,
raw_tool_calls: r.raw_tool_calls,
finish_reason: r.finish_reason,
},
Err(e) => {
tracing::error!("Groq API error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("LLM API error: {}", e)
})),
)
.into_response();
}
}
}
LlmClient::Claude(claude_client) => {
// Convert messages to Claude format
let claude_messages = claude::groq_messages_to_claude(&messages);
match claude_client.chat_with_tools(claude_messages, &AVAILABLE_TOOLS).await {
Ok(r) => {
// Convert Claude tool uses to Groq-style ToolCallResponse for consistency
let raw_tool_calls: Vec<ToolCallResponse> = r
.tool_calls
.iter()
.map(|tc| ToolCallResponse {
id: tc.id.clone(),
call_type: "function".to_string(),
function: crate::llm::groq::FunctionCall {
name: tc.name.clone(),
arguments: tc.arguments.to_string(),
},
})
.collect();
LlmResult {
content: r.content,
tool_calls: r.tool_calls,
raw_tool_calls,
finish_reason: r.stop_reason,
}
}
Err(e) => {
tracing::error!("Claude API error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("LLM API error: {}", e)
})),
)
.into_response();
}
}
}
};
// Check if there are tool calls to execute
if result.tool_calls.is_empty() {
// No more tool calls - capture the final response and exit loop
final_response = result.content;
break;
}
// Add assistant message with tool calls to conversation
messages.push(Message {
role: "assistant".to_string(),
content: result.content.clone(),
tool_calls: Some(result.raw_tool_calls.clone()),
tool_call_id: None,
});
// Execute each tool call and add results to conversation
for (i, tool_call) in result.tool_calls.iter().enumerate() {
tracing::info!(
tool = %tool_call.name,
round = round,
"Executing tool call"
);
let mut execution_result =
execute_tool_call(tool_call, ¤t_body, current_summary.as_deref(), &file.transcript);
// Handle version tool requests that need async database access
if let Some(version_request) = &execution_result.version_request {
let version_result = handle_version_request(
pool,
id,
version_request,
¤t_body,
current_summary.as_deref(),
file.version,
)
.await;
// Update execution result with actual version operation result
execution_result.result = version_result.result;
execution_result.parsed_data = version_result.data;
// Apply state changes from restore operation
if let Some(new_body) = version_result.new_body {
current_body = new_body;
// Mark that a restore happened - file was already saved
version_restored = true;
}
if let Some(new_summary) = version_result.new_summary {
current_summary = Some(new_summary);
}
}
// Apply state changes from regular tools
if let Some(new_body) = execution_result.new_body {
current_body = new_body;
// If this is a regular tool (not a version operation), track it
if execution_result.version_request.is_none() && version_restored {
has_changes_after_restore = true;
}
}
if let Some(new_summary) = execution_result.new_summary {
current_summary = Some(new_summary);
if execution_result.version_request.is_none() && version_restored {
has_changes_after_restore = true;
}
}
// Track consecutive failures for agentic behavior
if execution_result.result.success {
consecutive_failures = 0;
} else {
consecutive_failures += 1;
tracing::warn!(
tool = %tool_call.name,
consecutive_failures = consecutive_failures,
"Tool call failed"
);
}
// Check for pending user questions (pauses the conversation)
if let Some(questions) = execution_result.pending_questions {
tracing::info!(
question_count = questions.len(),
"LLM requesting user input, pausing conversation"
);
pending_questions = Some(questions);
// Track this tool call before breaking
all_tool_call_infos.push(ToolCallInfo {
name: tool_call.name.clone(),
result: execution_result.result,
});
break; // Exit inner loop
}
// Build tool result message content with enhanced context for agentic reasoning
let result_content = if let Some(parsed_data) = &execution_result.parsed_data {
// Include parsed data in the result for the LLM to use
serde_json::json!({
"success": execution_result.result.success,
"message": execution_result.result.message,
"data": parsed_data
})
.to_string()
} else if !execution_result.result.success {
// On failure, include hints for the LLM
let hint = if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
" [HINT: Multiple consecutive failures detected. Consider a different approach or verify your parameters.]"
} else {
""
};
serde_json::json!({
"success": false,
"message": format!("{}{}", execution_result.result.message, hint),
"currentBodyElementCount": current_body.len()
})
.to_string()
} else {
serde_json::json!({
"success": execution_result.result.success,
"message": execution_result.result.message
})
.to_string()
};
// Add tool result message
// Use the appropriate ID format for each provider
let tool_call_id = match &llm_client {
LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(),
LlmClient::Claude(_) => tool_call.id.clone(),
};
messages.push(Message {
role: "tool".to_string(),
content: Some(result_content),
tool_calls: None,
tool_call_id: Some(tool_call_id),
});
// Track for response
all_tool_call_infos.push(ToolCallInfo {
name: tool_call.name.clone(),
result: execution_result.result,
});
}
// If user questions are pending, pause the conversation
if pending_questions.is_some() {
final_response = result.content;
break;
}
// If finish reason indicates completion, exit loop
let finish_lower = result.finish_reason.to_lowercase();
if finish_lower == "stop" || finish_lower == "end_turn" {
final_response = result.content;
break;
}
}
// Save changes to database if any tools were executed
// Skip if a version restore already happened (file was already saved during restore)
// UNLESS there were additional modifications after the restore
if !all_tool_call_infos.is_empty() && (!version_restored || has_changes_after_restore) {
let update_req = crate::db::models::UpdateFileRequest {
name: None,
description: None,
transcript: None,
summary: current_summary.clone(),
body: Some(current_body.clone()),
version: None, // Internal update, skip version check
repo_file_path: None,
};
match repository::update_file(pool, id, update_req).await {
Ok(Some(updated_file)) => {
// Broadcast update notification for LLM changes
let mut updated_fields = vec!["body".to_string()];
if current_summary.is_some() {
updated_fields.push("summary".to_string());
}
state.broadcast_file_update(FileUpdateNotification {
file_id: id,
version: updated_file.version,
updated_fields,
updated_by: "llm".to_string(),
});
}
Ok(None) => {
// File was deleted during processing
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "File not found"
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to save file changes: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("Failed to save changes: {}", e)
})),
)
.into_response();
}
}
}
// Build response
let response_text = final_response.unwrap_or_else(|| {
if all_tool_call_infos.is_empty() {
"I couldn't understand your request. Please try rephrasing.".to_string()
} else {
format!(
"Done! Executed {} tool{}.",
all_tool_call_infos.len(),
if all_tool_call_infos.len() == 1 { "" } else { "s" }
)
}
});
(
StatusCode::OK,
Json(ChatResponse {
response: response_text,
tool_calls: all_tool_call_infos,
updated_body: current_body,
updated_summary: current_summary,
pending_questions,
}),
)
.into_response()
}
fn build_file_context(file: &crate::db::models::File) -> String {
let mut context = format!("File: {}\n", file.name);
if let Some(ref desc) = file.description {
context.push_str(&format!("Description: {}\n", desc));
}
if let Some(ref summary) = file.summary {
context.push_str(&format!("Summary: {}\n", summary));
}
// Include contract phase context if file belongs to a contract
if let Some(ref phase) = file.contract_phase {
context.push_str(&format!("\n## Contract Context\n"));
context.push_str(&format!("This file belongs to a contract in the '{}' phase.\n", phase));
context.push_str("You can use 'suggest_templates' to get phase-appropriate templates, ");
context.push_str("or 'apply_template' to apply a template structure.\n");
context.push_str(&format!(
"Templates for '{}' phase include: {}\n",
phase,
match phase.as_str() {
"research" => "research-notes, competitor-analysis, user-research",
"specify" => "requirements, user-stories, acceptance-criteria",
"plan" => "architecture, technical-design, task-breakdown",
"execute" => "dev-notes, test-plan, implementation-log",
"review" => "review-checklist, release-notes, retrospective",
_ => "(use suggest_templates to see available)",
}
));
}
context.push_str(&format!("\nTranscript entries: {}\n", file.transcript.len()));
context.push_str(&format!("Body elements: {}\n", file.body.len()));
// Add body overview
if !file.body.is_empty() {
context.push_str("\nCurrent body elements:\n");
for (i, element) in file.body.iter().enumerate() {
let desc = match element {
BodyElement::Heading { level, text } => format!("H{}: {}", level, text),
BodyElement::Paragraph { text } => {
let preview: String = text.chars().take(50).collect();
if text.chars().count() > 50 {
format!("Paragraph: {}...", preview)
} else {
format!("Paragraph: {}", preview)
}
}
BodyElement::Code { language, content } => {
let lang = language.as_deref().unwrap_or("plain");
let preview: String = content.chars().take(50).collect();
if content.chars().count() > 50 {
format!("Code ({}): {}...", lang, preview)
} else {
format!("Code ({}): {}", lang, preview)
}
}
BodyElement::List { ordered, items } => {
let list_type = if *ordered { "ordered" } else { "unordered" };
format!("List ({}): {} items", list_type, items.len())
}
BodyElement::Chart { chart_type, title, .. } => {
format!(
"Chart ({:?}){}",
chart_type,
title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default()
)
}
BodyElement::Image { alt, .. } => {
format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default())
}
BodyElement::Markdown { content } => {
let preview: String = content.chars().take(50).collect();
if content.chars().count() > 50 {
format!("Markdown: {}...", preview)
} else {
format!("Markdown: {}", preview)
}
}
};
context.push_str(&format!(" [{}] {}\n", i, desc));
}
}
// Add transcript preview if available
if !file.transcript.is_empty() {
context.push_str("\nTranscript preview (first 5 entries):\n");
for entry in file.transcript.iter().take(5) {
context.push_str(&format!(" - {}: {}\n", entry.speaker, entry.text));
}
if file.transcript.len() > 5 {
context.push_str(&format!(" ... and {} more entries\n", file.transcript.len() - 5));
}
}
context
}
/// Build context for a focused element
fn build_focused_element_context(body: &[BodyElement], focused_index: Option<usize>) -> String {
let Some(index) = focused_index else {
return String::new();
};
let Some(element) = body.get(index) else {
return format!(
"\n## Focused Element\nNote: User focused on element [{}] but it doesn't exist (document has {} elements).\n",
index,
body.len()
);
};
let (element_type, full_content) = match element {
BodyElement::Heading { level, text } => {
(format!("Heading (level {})", level), text.clone())
}
BodyElement::Paragraph { text } => {
("Paragraph".to_string(), text.clone())
}
BodyElement::Code { language, content } => {
let lang = language.as_deref().unwrap_or("plain");
(format!("Code ({})", lang), content.clone())
}
BodyElement::List { ordered, items } => {
let list_type = if *ordered { "Ordered list" } else { "Unordered list" };
let content = items.iter()
.enumerate()
.map(|(i, item)| format!("{}. {}", i + 1, item))
.collect::<Vec<_>>()
.join("\n");
(list_type.to_string(), content)
}
BodyElement::Chart { chart_type, title, .. } => {
let title_str = title.as_deref().unwrap_or("untitled");
(format!("Chart ({:?})", chart_type), title_str.to_string())
}
BodyElement::Image { alt, caption, .. } => {
let desc = alt.as_deref().or(caption.as_deref()).unwrap_or("no description");
("Image".to_string(), desc.to_string())
}
BodyElement::Markdown { content } => {
("Markdown".to_string(), content.clone())
}
};
format!(
r#"
## Focused Element
The user is focusing on element [{}]: {}
Full content of focused element:
---
{}
---
When the user's request is ambiguous about which element to modify, prioritize this focused element.
"#,
index, element_type, full_content
)
}
/// Result of handling a version tool request
struct VersionRequestResult {
result: ToolResult,
data: Option<serde_json::Value>,
new_body: Option<Vec<BodyElement>>,
new_summary: Option<String>,
}
/// Handle version tool requests that require async database access
async fn handle_version_request(
pool: &sqlx::PgPool,
file_id: Uuid,
request: &VersionToolRequest,
_current_body: &[BodyElement],
_current_summary: Option<&str>,
current_version: i32,
) -> VersionRequestResult {
match request {
VersionToolRequest::ListVersions => {
match repository::list_file_versions(pool, file_id).await {
Ok(versions) => {
let version_data: Vec<serde_json::Value> = versions
.iter()
.map(|v| {
serde_json::json!({
"version": v.version,
"source": v.source,
"createdAt": v.created_at.to_rfc3339(),
"changeDescription": v.change_description,
})
})
.collect();
VersionRequestResult {
result: ToolResult {
success: true,
message: format!("Found {} versions. Current version is {}.", versions.len(), current_version),
},
data: Some(serde_json::json!({
"currentVersion": current_version,
"versions": version_data,
})),
new_body: None,
new_summary: None,
}
}
Err(e) => VersionRequestResult {
result: ToolResult {
success: false,
message: format!("Failed to list versions: {}", e),
},
data: None,
new_body: None,
new_summary: None,
},
}
}
VersionToolRequest::ReadVersion { version } => {
match repository::get_file_version(pool, file_id, *version).await {
Ok(Some(ver)) => {
// Convert body elements to a readable format
let body_preview: Vec<String> = ver
.body
.iter()
.enumerate()
.map(|(i, element)| {
let desc = match element {
BodyElement::Heading { level, text } => format!("H{}: {}", level, text),
BodyElement::Paragraph { text } => {
let preview: String = text.chars().take(100).collect();
if text.chars().count() > 100 {
format!("Paragraph: {}...", preview)
} else {
format!("Paragraph: {}", preview)
}
}
BodyElement::Code { language, content } => {
let lang = language.as_deref().unwrap_or("plain");
let preview: String = content.chars().take(100).collect();
if content.chars().count() > 100 {
format!("Code ({}): {}...", lang, preview)
} else {
format!("Code ({}): {}", lang, preview)
}
}
BodyElement::List { ordered, items } => {
let list_type = if *ordered { "ordered" } else { "unordered" };
format!("List ({}): {} items", list_type, items.len())
}
BodyElement::Chart { chart_type, title, .. } => {
format!(
"Chart ({:?}){}",
chart_type,
title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default()
)
}
BodyElement::Image { alt, .. } => {
format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default())
}
BodyElement::Markdown { content } => {
let preview: String = content.chars().take(100).collect();
if content.chars().count() > 100 {
format!("Markdown: {}...", preview)
} else {
format!("Markdown: {}", preview)
}
}
};
format!("[{}] {}", i, desc)
})
.collect();
VersionRequestResult {
result: ToolResult {
success: true,
message: format!(
"Version {} from {} (source: {}). {} body elements.",
ver.version,
ver.created_at.format("%Y-%m-%d %H:%M"),
ver.source,
ver.body.len()
),
},
data: Some(serde_json::json!({
"version": ver.version,
"source": ver.source,
"createdAt": ver.created_at.to_rfc3339(),
"summary": ver.summary,
"bodyPreview": body_preview,
"changeDescription": ver.change_description,
})),
new_body: None,
new_summary: None,
}
}
Ok(None) => VersionRequestResult {
result: ToolResult {
success: false,
message: format!("Version {} not found", version),
},
data: None,
new_body: None,
new_summary: None,
},
Err(e) => VersionRequestResult {
result: ToolResult {
success: false,
message: format!("Failed to read version: {}", e),
},
data: None,
new_body: None,
new_summary: None,
},
}
}
VersionToolRequest::RestoreVersion { target_version, reason } => {
// Set change description if provided
if let Some(reason) = reason {
let _ = repository::set_change_description(pool, reason).await;
}
match repository::restore_file_version(pool, file_id, *target_version, current_version).await {
Ok(Some(restored_file)) => {
VersionRequestResult {
result: ToolResult {
success: true,
message: format!(
"Restored to version {}. New version is {}.",
target_version, restored_file.version
),
},
data: Some(serde_json::json!({
"previousVersion": current_version,
"restoredFromVersion": target_version,
"newVersion": restored_file.version,
})),
new_body: Some(restored_file.body),
new_summary: restored_file.summary,
}
}
Ok(None) => VersionRequestResult {
result: ToolResult {
success: false,
message: format!("Version {} not found", target_version),
},
data: None,
new_body: None,
new_summary: None,
},
Err(RepositoryError::VersionConflict { expected, actual }) => {
VersionRequestResult {
result: ToolResult {
success: false,
message: format!(
"Version conflict: expected {}, actual {}. Document was modified.",
expected, actual
),
},
data: None,
new_body: None,
new_summary: None,
}
}
Err(e) => VersionRequestResult {
result: ToolResult {
success: false,
message: format!("Failed to restore version: {}", e),
},
data: None,
new_body: None,
new_summary: None,
},
}
}
}
}
/// Estimate the token count of a message
fn estimate_message_tokens(message: &Message) -> usize {
let mut chars = 0;
// Count content characters
if let Some(ref content) = message.content {
chars += content.len();
}
// Count tool call characters (rough estimate)
if let Some(ref tool_calls) = message.tool_calls {
for tc in tool_calls {
chars += tc.function.name.len();
chars += tc.function.arguments.len();
}
}
// Count tool call ID
if let Some(ref id) = message.tool_call_id {
chars += id.len();
}
// Add overhead for role and structure
chars += message.role.len() + 20;
// Convert to tokens
chars / CHARS_PER_TOKEN
}
/// Estimate total token count of all messages
fn estimate_total_tokens(messages: &[Message]) -> usize {
messages.iter().map(estimate_message_tokens).sum()
}
/// Check if context is nearing the limit
fn is_context_near_limit(messages: &[Message], model: &LlmModel) -> bool {
let estimated_tokens = estimate_total_tokens(messages);
let limit = match model {
LlmModel::ClaudeSonnet | LlmModel::ClaudeOpus => CLAUDE_CONTEXT_LIMIT,
LlmModel::GroqKimi => GROQ_CONTEXT_LIMIT,
};
let threshold = (limit as f32 * CONTEXT_COMPACTION_THRESHOLD) as usize;
estimated_tokens >= threshold
}
/// Compact the conversation by summarizing older messages
/// Keeps: system message, last N user/assistant exchanges, and a summary of older content
fn compact_conversation(messages: &mut Vec<Message>, tool_call_history: &[ToolCallInfo]) {
// Keep at least system message + 4 recent messages (2 exchanges)
const MIN_MESSAGES_TO_KEEP: usize = 5;
if messages.len() <= MIN_MESSAGES_TO_KEEP {
return;
}
// Extract system message (always first)
let system_message = messages.remove(0);
// Calculate how many messages to summarize
// Keep the last ~1/3 of messages for recent context
let messages_to_keep = std::cmp::max(4, messages.len() / 3);
let messages_to_summarize = messages.len() - messages_to_keep;
if messages_to_summarize < 2 {
// Not enough to summarize, just put system message back
messages.insert(0, system_message);
return;
}
// Extract messages to summarize
let old_messages: Vec<Message> = messages.drain(..messages_to_summarize).collect();
// Build summary of old messages
let mut summary_parts: Vec<String> = Vec::new();
// Summarize user requests
let user_requests: Vec<&str> = old_messages
.iter()
.filter(|m| m.role == "user")
.filter_map(|m| m.content.as_deref())
.collect();
if !user_requests.is_empty() {
summary_parts.push(format!(
"Previous user requests: {}",
user_requests.join("; ")
));
}
// Summarize tool calls executed so far
if !tool_call_history.is_empty() {
let tool_summary: Vec<String> = tool_call_history
.iter()
.map(|tc| {
if tc.result.success {
format!("{}(ok)", tc.name)
} else {
format!("{}(failed: {})", tc.name, tc.result.message)
}
})
.collect();
summary_parts.push(format!(
"Tools executed: {}",
tool_summary.join(", ")
));
}
// Count assistant responses that were summarized
let assistant_responses = old_messages
.iter()
.filter(|m| m.role == "assistant" && m.content.is_some())
.count();
if assistant_responses > 0 {
summary_parts.push(format!(
"({} previous assistant responses omitted for brevity)",
assistant_responses
));
}
// Create compacted context message
let compacted_content = format!(
"[CONTEXT SUMMARY - Earlier conversation compacted to save tokens]\n{}",
summary_parts.join("\n")
);
// Rebuild messages: system + summary + remaining recent messages
let mut new_messages = vec![
system_message,
Message {
role: "user".to_string(),
content: Some(compacted_content),
tool_calls: None,
tool_call_id: None,
},
Message {
role: "assistant".to_string(),
content: Some("Understood. I have context from the previous conversation and will continue from here.".to_string()),
tool_calls: None,
tool_call_id: None,
},
];
new_messages.append(messages);
*messages = new_messages;
tracing::info!(
summarized_messages = messages_to_summarize,
remaining_messages = messages.len(),
"Compacted conversation to save context"
);
}