mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:10:11 +00:00
* fix: strip reasoning from LLM responses and persist assistant messages reliably - Filter out `type: "reasoning"` output items from NEAR AI Responses API parsing so chain-of-thought never reaches the UI (nearai.rs) - Rewrite clean_response with regex-based tag stripping that is code-aware (preserves tags inside fenced blocks and inline backticks), supports 9+ tag names (think, thought, reasoning, reflection, etc.), handles <final> extraction, pipe-delimited tags, and case/whitespace tolerance (reasoning.rs) - Add Reasoning::complete() helper so all non-agentic LLM call sites (summarize, suggest, heartbeat, compaction) get automatic response cleaning; thread SafetyLayer through to those callers - Change persist_turn from fire-and-forget tokio::spawn to awaited async so both user and assistant messages are written before returning, preventing data loss on shutdown/restart - Pass input_count through seed_response_chain so response chaining delta calculation is accurate after thread hydration on restart - Make NearAiResponse.usage optional and preserve response_id in alt response path for chaining continuity - Persist session token to DB during onboarding wizard so runtime loads it without legacy-key fallback; suppress spurious warning on fresh installs - Fix dev tool double-registration when builder already registers them - Load dotenv/ironclaw env for doctor and status subcommands - Reduce startup log noise (demote info→debug for skills, remove redundant info lines) Co-Authored-By: Claude Opus 4.6 <[email protected]> * Nudge to not loop over tools continuesly * refactor: remove Responses API, consolidate NEAR AI to Chat Completions only The Responses API provider (nearai.rs, 1278 lines) added significant complexity (response chaining state machine, delta message calculation, previous_response_id persistence) for marginal benefit. This consolidates to the Chat Completions API only, upgrading NearAiChatProvider with dual auth (session token + API key) and 401 retry for session token renewal. - Delete src/llm/nearai.rs (Responses API provider) - Upgrade nearai_chat.rs with SessionManager, dual auth, flexible list_models - Remove response_id from CompletionResponse and ToolCompletionResponse - Remove seed_response_chain/get_response_chain_id from LlmProvider trait - Remove response chain persistence from agent (thread_ops, session) - Remove NearAiApiMode enum and NEARAI_API_MODE config - Clean up all wrapper providers (retry, circuit_breaker, failover, cache) - Update documentation (CLAUDE.md, .env.example) Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: runtime log level control via gateway UI and URL parameter Add server-side log level switching using tracing_subscriber::reload::Layer so the EnvFilter can be swapped at runtime without restarting. Expose via GET/PUT /api/logs/level endpoints, a "Server: LEVEL" dropdown in the logs toolbar, and a ?log_level=debug URL parameter for one-click activation. Also applies cargo fmt to pre-existing files (llm/, tests/). Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
346 lines
10 KiB
Rust
346 lines
10 KiB
Rust
//! Context compaction for preserving and summarizing conversation history.
|
|
//!
|
|
//! When the context window approaches its limit, compaction:
|
|
//! 1. Summarizes old turns
|
|
//! 2. Writes the summary to the workspace daily log
|
|
//! 3. Trims the context to keep only recent turns
|
|
|
|
use std::sync::Arc;
|
|
|
|
use chrono::Utc;
|
|
|
|
use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown};
|
|
use crate::agent::session::Thread;
|
|
use crate::error::Error;
|
|
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
|
use crate::safety::SafetyLayer;
|
|
use crate::workspace::Workspace;
|
|
|
|
/// Result of a compaction operation.
|
|
#[derive(Debug)]
|
|
pub struct CompactionResult {
|
|
/// Number of turns removed.
|
|
pub turns_removed: usize,
|
|
/// Tokens before compaction.
|
|
pub tokens_before: usize,
|
|
/// Tokens after compaction.
|
|
pub tokens_after: usize,
|
|
/// Whether a summary was written to workspace.
|
|
pub summary_written: bool,
|
|
/// The generated summary (if any).
|
|
pub summary: Option<String>,
|
|
}
|
|
|
|
/// Compacts conversation context to stay within limits.
|
|
pub struct ContextCompactor {
|
|
llm: Arc<dyn LlmProvider>,
|
|
safety: Arc<SafetyLayer>,
|
|
}
|
|
|
|
impl ContextCompactor {
|
|
/// Create a new context compactor.
|
|
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
|
|
Self { llm, safety }
|
|
}
|
|
|
|
/// Compact a thread's context using the given strategy.
|
|
pub async fn compact(
|
|
&self,
|
|
thread: &mut Thread,
|
|
strategy: CompactionStrategy,
|
|
workspace: Option<&Workspace>,
|
|
) -> Result<CompactionResult, Error> {
|
|
let messages = thread.messages();
|
|
let tokens_before = ContextBreakdown::analyze(&messages).total_tokens;
|
|
|
|
let result = match strategy {
|
|
CompactionStrategy::Summarize { keep_recent } => {
|
|
self.compact_with_summary(thread, keep_recent, workspace)
|
|
.await?
|
|
}
|
|
CompactionStrategy::Truncate { keep_recent } => {
|
|
self.compact_truncate(thread, keep_recent)
|
|
}
|
|
CompactionStrategy::MoveToWorkspace => {
|
|
self.compact_to_workspace(thread, workspace).await?
|
|
}
|
|
};
|
|
|
|
let messages_after = thread.messages();
|
|
let tokens_after = ContextBreakdown::analyze(&messages_after).total_tokens;
|
|
|
|
Ok(CompactionResult {
|
|
turns_removed: result.turns_removed,
|
|
tokens_before,
|
|
tokens_after,
|
|
summary_written: result.summary_written,
|
|
summary: result.summary,
|
|
})
|
|
}
|
|
|
|
/// Compact by summarizing old turns.
|
|
async fn compact_with_summary(
|
|
&self,
|
|
thread: &mut Thread,
|
|
keep_recent: usize,
|
|
workspace: Option<&Workspace>,
|
|
) -> Result<CompactionPartial, Error> {
|
|
if thread.turns.len() <= keep_recent {
|
|
return Ok(CompactionPartial::empty());
|
|
}
|
|
|
|
// Get turns to summarize
|
|
let turns_to_remove = thread.turns.len() - keep_recent;
|
|
let old_turns = &thread.turns[..turns_to_remove];
|
|
|
|
// Build messages for summarization
|
|
let mut to_summarize = Vec::new();
|
|
for turn in old_turns {
|
|
to_summarize.push(ChatMessage::user(&turn.user_input));
|
|
if let Some(ref response) = turn.response {
|
|
to_summarize.push(ChatMessage::assistant(response));
|
|
}
|
|
}
|
|
|
|
// Generate summary
|
|
let summary = self.generate_summary(&to_summarize).await?;
|
|
|
|
// Write to workspace if available
|
|
let summary_written = if let Some(ws) = workspace {
|
|
match self.write_summary_to_workspace(ws, &summary).await {
|
|
Ok(()) => true,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Compaction summary write failed (turns will still be truncated): {}",
|
|
e
|
|
);
|
|
false
|
|
}
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Truncate thread
|
|
thread.truncate_turns(keep_recent);
|
|
|
|
Ok(CompactionPartial {
|
|
turns_removed: turns_to_remove,
|
|
summary_written,
|
|
summary: Some(summary),
|
|
})
|
|
}
|
|
|
|
/// Compact by simple truncation (no summary).
|
|
fn compact_truncate(&self, thread: &mut Thread, keep_recent: usize) -> CompactionPartial {
|
|
let turns_before = thread.turns.len();
|
|
thread.truncate_turns(keep_recent);
|
|
let turns_removed = turns_before - thread.turns.len();
|
|
|
|
CompactionPartial {
|
|
turns_removed,
|
|
summary_written: false,
|
|
summary: None,
|
|
}
|
|
}
|
|
|
|
/// Move context to workspace without summarization.
|
|
async fn compact_to_workspace(
|
|
&self,
|
|
thread: &mut Thread,
|
|
workspace: Option<&Workspace>,
|
|
) -> Result<CompactionPartial, Error> {
|
|
let Some(ws) = workspace else {
|
|
// Fall back to truncation if no workspace
|
|
return Ok(self.compact_truncate(thread, 5));
|
|
};
|
|
|
|
// Keep more turns when moving to workspace (we have a backup)
|
|
let keep_recent = 10;
|
|
if thread.turns.len() <= keep_recent {
|
|
return Ok(CompactionPartial::empty());
|
|
}
|
|
|
|
let turns_to_remove = thread.turns.len() - keep_recent;
|
|
let old_turns = &thread.turns[..turns_to_remove];
|
|
|
|
// Format turns for storage
|
|
let content = format_turns_for_storage(old_turns);
|
|
|
|
// Write to workspace
|
|
let written = match self.write_context_to_workspace(ws, &content).await {
|
|
Ok(()) => true,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Compaction context write failed (turns will still be truncated): {}",
|
|
e
|
|
);
|
|
false
|
|
}
|
|
};
|
|
|
|
// Truncate
|
|
thread.truncate_turns(keep_recent);
|
|
|
|
Ok(CompactionPartial {
|
|
turns_removed: turns_to_remove,
|
|
summary_written: written,
|
|
summary: None,
|
|
})
|
|
}
|
|
|
|
/// Generate a summary of messages using the LLM.
|
|
async fn generate_summary(&self, messages: &[ChatMessage]) -> Result<String, Error> {
|
|
let prompt = ChatMessage::system(
|
|
r#"Summarize the following conversation concisely. Focus on:
|
|
- Key decisions made
|
|
- Important information exchanged
|
|
- Actions taken
|
|
- Outcomes achieved
|
|
|
|
Be brief but capture all important details. Use bullet points."#,
|
|
);
|
|
|
|
let mut request_messages = vec![prompt];
|
|
|
|
// Add a user message with the conversation to summarize
|
|
let formatted = messages
|
|
.iter()
|
|
.map(|m| {
|
|
let role_str = match m.role {
|
|
crate::llm::Role::User => "User",
|
|
crate::llm::Role::Assistant => "Assistant",
|
|
crate::llm::Role::System => "System",
|
|
crate::llm::Role::Tool => {
|
|
return format!(
|
|
"Tool {}: {}",
|
|
m.name.as_deref().unwrap_or("unknown"),
|
|
m.content
|
|
);
|
|
}
|
|
};
|
|
format!("{}: {}", role_str, m.content)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n");
|
|
|
|
request_messages.push(ChatMessage::user(format!(
|
|
"Please summarize this conversation:\n\n{}",
|
|
formatted
|
|
)));
|
|
|
|
let request = CompletionRequest::new(request_messages)
|
|
.with_max_tokens(1024)
|
|
.with_temperature(0.3);
|
|
|
|
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
|
let (text, _) = reasoning.complete(request).await?;
|
|
Ok(text)
|
|
}
|
|
|
|
/// Write a summary to the workspace daily log.
|
|
async fn write_summary_to_workspace(
|
|
&self,
|
|
workspace: &Workspace,
|
|
summary: &str,
|
|
) -> Result<(), Error> {
|
|
let date = Utc::now().format("%Y-%m-%d");
|
|
let entry = format!(
|
|
"\n## Context Summary ({})\n\n{}\n",
|
|
Utc::now().format("%H:%M UTC"),
|
|
summary
|
|
);
|
|
|
|
workspace
|
|
.append(&format!("daily/{}.md", date), &entry)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Write full context to workspace for archival.
|
|
async fn write_context_to_workspace(
|
|
&self,
|
|
workspace: &Workspace,
|
|
content: &str,
|
|
) -> Result<(), Error> {
|
|
let date = Utc::now().format("%Y-%m-%d");
|
|
let entry = format!(
|
|
"\n## Archived Context ({})\n\n{}\n",
|
|
Utc::now().format("%H:%M UTC"),
|
|
content
|
|
);
|
|
|
|
workspace
|
|
.append(&format!("daily/{}.md", date), &entry)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Partial result during compaction (internal).
|
|
struct CompactionPartial {
|
|
turns_removed: usize,
|
|
summary_written: bool,
|
|
summary: Option<String>,
|
|
}
|
|
|
|
impl CompactionPartial {
|
|
fn empty() -> Self {
|
|
Self {
|
|
turns_removed: 0,
|
|
summary_written: false,
|
|
summary: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Format turns for storage in workspace.
|
|
fn format_turns_for_storage(turns: &[crate::agent::session::Turn]) -> String {
|
|
turns
|
|
.iter()
|
|
.map(|turn| {
|
|
let mut s = format!("**Turn {}**\n", turn.turn_number + 1);
|
|
s.push_str(&format!("User: {}\n", turn.user_input));
|
|
if let Some(ref response) = turn.response {
|
|
s.push_str(&format!("Agent: {}\n", response));
|
|
}
|
|
if !turn.tool_calls.is_empty() {
|
|
s.push_str("Tools: ");
|
|
let tools: Vec<_> = turn.tool_calls.iter().map(|t| t.name.as_str()).collect();
|
|
s.push_str(&tools.join(", "));
|
|
s.push('\n');
|
|
}
|
|
s
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::agent::session::Thread;
|
|
use uuid::Uuid;
|
|
|
|
#[test]
|
|
fn test_format_turns() {
|
|
let mut thread = Thread::new(Uuid::new_v4());
|
|
thread.start_turn("Hello");
|
|
thread.complete_turn("Hi there");
|
|
thread.start_turn("How are you?");
|
|
thread.complete_turn("I'm good!");
|
|
|
|
let formatted = format_turns_for_storage(&thread.turns);
|
|
assert!(formatted.contains("Turn 1"));
|
|
assert!(formatted.contains("Hello"));
|
|
assert!(formatted.contains("Turn 2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_compaction_partial_empty() {
|
|
let partial = CompactionPartial::empty();
|
|
assert_eq!(partial.turns_removed, 0);
|
|
assert!(!partial.summary_written);
|
|
}
|
|
}
|