Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4bad1de
docs: improve self-upgrade/model-switching sections + multi-session b…
chinkan Jun 17, 2026
0c51d9c
feat: add provider and fallback config types
chinkan Jun 22, 2026
e6276dc
feat: add Provider trait, ProviderRegistry, OpenRouter/OpenAICompatib…
chinkan Jun 22, 2026
94c5935
refactor: wire ProviderRegistry into LlmClient, Agent, and main.rs wi…
chinkan Jun 22, 2026
8c268d8
feat: redesign /models with multi-step provider selection and model d…
chinkan Jun 22, 2026
2f7f4cc
docs: add [[provider]] and [fallback] examples to config.example.toml
chinkan Jun 22, 2026
42aba59
feat: redesign /models with multi-step provider selection and model d…
chinkan Jun 22, 2026
e33419d
docs: add multi-provider design spec and implementation plan
chinkan Jun 23, 2026
c88b636
feat: add fuzzy model matching via provider list_models in /models se…
chinkan Jun 25, 2026
241fbb8
fmt: fix chained method call formatting
chinkan Jun 25, 2026
05996f3
Add context_window field to ProviderSection and ProviderConfig
chinkan Jun 29, 2026
958a48e
feat: add ChatMessage::has_tool_calls() helper
chinkan Jun 29, 2026
b3d7c04
feat: rewrite agent_prompt with 4-tier compaction (Tiers 1-2 sync)
chinkan Jun 29, 2026
451fc0c
feat: wire Tier 3 auto-compact and Tier 4 reactive compact into agent…
chinkan Jun 29, 2026
2da3e20
fix: use model-specific context_window in Tiers 1-2, not default
chinkan Jun 29, 2026
5534480
fix: correct buffer capacity in auto_compact_conversation, add named …
chinkan Jun 29, 2026
3b98c04
fix: end orphaned LangSmith llm_run on 413 recovery
chinkan Jun 29, 2026
d6be082
refactor: extract summarize_and_replace helper, document REACTIVE_PCT…
chinkan Jun 29, 2026
4a3c888
Merge branch 'main' into feat/redesign-prompt-compaction
chinkan Jun 30, 2026
6128af7
fmt: remove extra blank line before default_context_window
chinkan Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 284 additions & 17 deletions src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use teloxide::types::{ChatId, InputFile};
use teloxide::Bot;

use crate::agent_prompt::{
estimate_prompt_bytes, prepare_messages_for_llm, recovery_nudge_for, PreparedPrompt,
build_compact_boundary_marker, build_compact_summary_prompt, estimate_prompt_bytes,
prepare_messages_for_llm, recovery_nudge_for, should_auto_compact, ConversationMeta,
PreparedPrompt, PRESERVED_TOOL_GROUPS,
};
use crate::config::Config;
use crate::langsmith::LangSmithClient;
Expand Down Expand Up @@ -593,6 +595,9 @@ impl Agent {
};
messages.push(user_msg);

// Compaction state for this conversation session (persists across iterations)
let mut conv_meta = ConversationMeta::new();

// Gather all tool definitions
let mut all_tools: Vec<ToolDefinition> = tools::builtin_tool_definitions();
all_tools.extend(self.mcp.tool_definitions());
Expand Down Expand Up @@ -630,6 +635,13 @@ impl Agent {
self.soul_updated
.store(false, std::sync::atomic::Ordering::Relaxed);

// Resolve context_window once before the loop (can't .await inside the loop)
let context_window = {
let model = self.current_model.read().await;
let (provider, _) = self.registry.resolve_model(&model);
provider.config().context_window
};

for iteration in 0..max_iterations {
debug!(
"Trying iteration {}: messages length: {}",
Expand All @@ -641,8 +653,56 @@ impl Agent {
let mut retry_count = 0u32;
let response: ChatMessage;

// Prepare prompt with optional compaction (invariant across retries)
let base_prompt = prepare_messages_for_llm(&messages);
// Tier 3: auto-compact (async, LLM call) — before Tiers 1-2
conv_meta.current_turn = iteration as usize;
if should_auto_compact(&messages, &conv_meta, context_window) {
// Capture pre-compact metrics
let messages_before = messages.clone();
let messages_before_bytes = estimate_prompt_bytes(&messages_before);

if let Ok(compacted) =
Self::auto_compact_conversation(&self.llm, &messages, context_window).await
{
conv_meta.last_compact_turn = iteration as usize;
messages = compacted;
info!(
"Tier 3 auto-compact applied: {} messages reduced",
messages.len(),
);

// LangSmith logging for Tier 3
let compacted_bytes = estimate_prompt_bytes(&messages);
let compact_run_id = uuid::Uuid::new_v4().to_string();
self.langsmith.start_run(crate::langsmith::RunParams {
id: compact_run_id.clone(),
name: "auto_compact".to_string(),
run_type: crate::langsmith::RunType::Chain,
parent_run_id: Some(chain_run_id.clone()),
inputs: serde_json::json!({
"tier": 3,
"pre_bytes": messages_before_bytes,
"post_bytes": compacted_bytes,
"pre_count": messages_before.len(),
"post_count": messages.len(),
}),
session_name: ls_project.clone(),
start_time: Self::now_iso8601_static(),
});
self.langsmith.end_run(crate::langsmith::EndRunParams {
id: compact_run_id,
outputs: Some(serde_json::json!({
"tier": 3,
"delta_bytes": (messages_before_bytes as i64 - compacted_bytes as i64).max(0),
"delta_messages": messages_before.len() as i64 - messages.len() as i64,
})),
error: None,
end_time: Self::now_iso8601_static(),
});
}
}

// Tiers 1-2: sync compaction
let base_prompt = prepare_messages_for_llm(&messages, context_window);

loop {
// Clone the base prompt for this retry attempt
Expand Down Expand Up @@ -741,25 +801,99 @@ impl Agent {
};

// Handle LLM transport/API errors
let mut recovered_from_413 = false;

let completion = match completion_result {
Ok(c) => c,
Err(e) => {
self.langsmith.end_run(crate::langsmith::EndRunParams {
id: llm_run_id,
outputs: None,
error: Some(format!("{:#}", e)),
end_time: Self::now_iso8601_static(),
});
self.langsmith.end_run(crate::langsmith::EndRunParams {
id: chain_run_id,
outputs: None,
error: Some(format!("{:#}", e)),
end_time: Self::now_iso8601_static(),
});
return Err(e);
let err_str = format!("{:#}", e);
let is_413 = err_str.contains("413")
|| err_str.to_lowercase().contains("prompt too long")
|| err_str.to_lowercase().contains("context length")
|| err_str.to_lowercase().contains("maximum context");
if is_413 && !conv_meta.has_attempted_reactive_compact {
conv_meta.has_attempted_reactive_compact = true;
let messages_before_compact = messages.len();
match Self::reactive_compact(&self.llm, &messages, context_window).await
{
Ok(compacted) => {
let compacted_len = compacted.len();
messages = compacted;
recovered_from_413 = true;

// LangSmith logging for Tier 4
let compact_run_id = uuid::Uuid::new_v4().to_string();
self.langsmith.start_run(crate::langsmith::RunParams {
id: compact_run_id.clone(),
name: "reactive_compact".to_string(),
run_type: crate::langsmith::RunType::Chain,
parent_run_id: Some(chain_run_id.clone()),
inputs: serde_json::json!({
"tier": 4,
"reason": err_str,
"pre_count": messages_before_compact,
}),
session_name: ls_project.clone(),
start_time: Self::now_iso8601_static(),
});
self.langsmith.end_run(crate::langsmith::EndRunParams {
id: compact_run_id,
outputs: Some(serde_json::json!({
"tier": 4,
"post_count": compacted_len,
})),
error: None,
end_time: Self::now_iso8601_static(),
});
}
Err(compact_err) => {
warn!("Reactive compact failed: {}", compact_err);
}
}
}
if !recovered_from_413 {
self.langsmith.end_run(crate::langsmith::EndRunParams {
id: llm_run_id,
outputs: None,
error: Some(err_str.clone()),
end_time: Self::now_iso8601_static(),
});
self.langsmith.end_run(crate::langsmith::EndRunParams {
id: chain_run_id,
outputs: None,
error: Some(err_str),
end_time: Self::now_iso8601_static(),
});
return Err(e);
}
// recovered_from_413 is true but the compiler can't see this;
// return a dummy completion (never used because of continue below)
crate::llm::ChatCompletion {
message: ChatMessage {
role: String::new(),
content: None,
tool_calls: None,
tool_call_id: None,
},
finish_reason: None,
model: String::new(),
}
}
};

if recovered_from_413 {
// End the leaked llm_run before continuing
self.langsmith.end_run(crate::langsmith::EndRunParams {
id: llm_run_id,
outputs: None,
error: Some(
"413 context exceeded — recovered via Tier 4 compact".to_string(),
),
end_time: Self::now_iso8601_static(),
});
continue;
}

// Check if response is empty (no content and no tool calls)
if is_empty_assistant_response(&completion.message) {
warn!(
Expand Down Expand Up @@ -1188,6 +1322,134 @@ impl Agent {
Ok("I've reached the maximum number of tool call iterations. Please try rephrasing your request.".to_string())
}

/// Tier 3: Auto-compact via LLM summarization.
///
/// `_context_window` is reserved for future threshold-based
/// split-point calculation — the summarization LLM handles
/// split decisions internally.
async fn auto_compact_conversation(
llm: &LlmClient,
messages: &[ChatMessage],
_context_window: usize,
) -> Result<Vec<ChatMessage>> {
let tool_groups = crate::agent_prompt::find_tool_groups(messages);

let preserve_count = PRESERVED_TOOL_GROUPS.min(tool_groups.len());
let preserved_groups_start = tool_groups.len().saturating_sub(preserve_count);

let summary_end = if preserved_groups_start > 0 {
let last_summary = &tool_groups[preserved_groups_start - 1];
*last_summary
.tool_result_indices
.last()
.unwrap_or(&last_summary.assistant_idx)
+ 1
} else {
return Ok(messages.to_vec());
};

let to_summarize = &messages[..summary_end];
let preserved = &messages[summary_end..];

Self::summarize_and_replace(
llm,
to_summarize,
preserved,
"Auto-compact",
"★ COMPACT SUMMARY ★",
)
.await
}

/// Tier 4: Reactive compact — emergency 413 recovery.
///
/// `_context_window` is reserved for future threshold-based
/// split-point calculation — the summarization LLM handles
/// split decisions internally.
async fn reactive_compact(
llm: &LlmClient,
messages: &[ChatMessage],
_context_window: usize,
) -> Result<Vec<ChatMessage>> {
const PRESERVE_COUNT: usize = 4;
if messages.len() <= PRESERVE_COUNT {
anyhow::bail!("Too few messages for reactive compact");
}

let split = messages.len().saturating_sub(PRESERVE_COUNT);
let to_summarize = &messages[..split];
let preserved = &messages[split..];

Self::summarize_and_replace(
llm,
to_summarize,
preserved,
"Reactive compact",
"★ COMPACT SUMMARY (EMERGENCY) ★",
)
.await
}

/// Shared helper for Tiers 3 and 4: send messages to LLM for
/// summarization, then assemble the compacted result.
async fn summarize_and_replace(
llm: &LlmClient,
to_summarize: &[ChatMessage],
preserved: &[ChatMessage],
error_label: &str,
summary_label: &str,
) -> Result<Vec<ChatMessage>> {
let summary_prompt = build_compact_summary_prompt();
let mut compact_msgs = Vec::with_capacity(to_summarize.len() + 2);
compact_msgs.push(summary_prompt);
compact_msgs.push(ChatMessage {
role: "user".to_string(),
content: Some(MessageContent::Text(format!(
"Summarize the following conversation portion ({} messages):",
to_summarize.len(),
))),
tool_calls: None,
tool_call_id: None,
});
compact_msgs.extend(to_summarize.iter().cloned());

let summary_response = match llm.chat(&compact_msgs, &[]).await {
Ok(c) => c,
Err(e) => anyhow::bail!("{} LLM call failed: {}", error_label, e),
};

let summary_text = summary_response
.content
.as_ref()
.map(|c| c.as_text())
.unwrap_or_default();

if summary_text.is_empty() {
anyhow::bail!("{} returned empty summary", error_label);
}

let boundary = build_compact_boundary_marker(to_summarize.len(), 1);
let summary_msg = ChatMessage {
role: "system".to_string(),
content: Some(MessageContent::Text(format!(
"{}\n\n{}",
summary_label, summary_text
))),
tool_calls: None,
tool_call_id: None,
};

let mut result: Vec<ChatMessage> = Vec::with_capacity(3 + preserved.len());
result.push(boundary);
result.push(summary_msg);
result.extend(preserved.iter().cloned());

let nudge = recovery_nudge_for(&result);
result.push(nudge);

Ok(result)
}

/// Re-register all active scheduled tasks from the DB into the scheduler.
/// Called once at startup after the agent is constructed.
pub async fn restore_scheduled_tasks(&self) {
Expand Down Expand Up @@ -1918,7 +2180,12 @@ impl Agent {
let response: ChatMessage;

// Prepare prompt with optional compaction (invariant across retries)
let base_prompt = prepare_messages_for_llm(messages);
let context_window = {
let (provider, _) = self.registry.resolve_model(model);
provider.config().context_window
};
// TODO: consider adding Tier 3/4 to subagent loops in a follow-up
let base_prompt = prepare_messages_for_llm(messages, context_window);

loop {
let mut prompt_prepared = base_prompt.clone();
Expand Down
Loading
Loading