Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion crates/aion-agent/src/commands/compact_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ mod tests {
};
let prompt_usage = PromptUsage::default();
let context_tools = Vec::new();
let config = aion_config::compact::CompactConfig::default();
let config = aion_config::compact::CompactConfig {
keep_recent_tokens: 0,
..Default::default()
};
let mut ctx = CommandContext {
messages: &mut messages,
compact_state: &mut state,
Expand Down
173 changes: 157 additions & 16 deletions crates/aion-agent/src/compact/auto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use aion_types::llm::{LlmEvent, LlmRequest};
use aion_types::message::{ContentBlock, Message, Role, TokenUsage};
use tokio::sync::mpsc;

use super::estimate::estimate_messages_tokens;
use super::prompt::{
COMPACT_MAX_OUTPUT_TOKENS, COMPACT_SYSTEM_PROMPT, build_compact_prompt, build_summary_content,
format_compact_summary,
Expand All @@ -30,9 +31,11 @@ pub const BOUNDARY_PREFIX: &str = "[Conversation compacted]";
#[derive(Debug, Clone)]
pub struct CompactResult {
/// Post-compact messages that replace the original conversation.
/// Contains a boundary marker and a summary message.
/// Contains a boundary marker, a summary message, and optionally a
/// verbatim tail of the most recent turns.
pub messages: Vec<Message>,
/// How many original messages were summarized.
/// How many original messages were summarized (the verbatim tail is
/// excluded from this count).
pub messages_summarized: usize,
/// Best-known context token count before compaction.
pub pre_compact_tokens: u64,
Expand Down Expand Up @@ -99,11 +102,18 @@ pub async fn autocompact(
}

let pre_compact_tokens = state.last_input_tokens;
let messages_summarized = messages.len();

// Build messages for the compact LLM call: conversation + summary prompt
let prompt = build_compact_prompt();
let mut conv_messages = messages.to_vec();
// Build messages for the compact LLM call: conversation + summary prompt.
// The conversation is trimmed up front: this request re-sends the whole
// history plus a fixed 20K completion budget, so a conversation that merely
// sits near the window produces a request the provider rejects. Trimming
// here is what makes compaction work against OpenAI-compatible servers,
// which answer a plain 400 instead of a recognizable prompt-too-long error.
let (mut conv_messages, trimmed) = fit_to_window(messages.to_vec(), config.context_window);
let mut prompt = build_compact_prompt();
if trimmed {
prompt.push_str(TRUNCATED_NOTICE);
}
conv_messages.push(Message::new(Role::User, vec![ContentBlock::Text { text: prompt }]));

let mut ptl_attempts = 0u32;
Expand Down Expand Up @@ -166,6 +176,18 @@ pub async fn autocompact(
// Format and build post-compact messages
let formatted = format_compact_summary(&summary_text);
let summary_content = build_summary_content(&formatted, true);
let summary_msg = Message::new(Role::User, vec![ContentBlock::Text { text: summary_content }]);

// Keep the most recent turns verbatim after the summary so fine-grained
// recent context is not lost to summarization. The tail is bounded by the
// post-compact input budget and `keep_recent_tokens`.
let post_budget = (config.context_window as u64).saturating_sub(config.output_reserve as u64);
let fixed_tokens =
estimate_messages_tokens(std::slice::from_ref(&summary_msg)).saturating_add(COMPACT_PROMPT_RESERVE);
let tail_budget = (config.keep_recent_tokens as u64).min(post_budget.saturating_sub(fixed_tokens));
let tail_start = select_recent_tail(messages, tail_budget);
let tail = &messages[tail_start..];
let messages_summarized = messages.len().saturating_sub(tail.len());

let metadata = CompactMetadata {
trigger: CompactTrigger::Auto,
Expand All @@ -180,19 +202,108 @@ pub async fn autocompact(

let boundary_msg = Message::new(Role::User, vec![ContentBlock::Text { text: boundary_text }]);

let summary_msg = Message::new(Role::User, vec![ContentBlock::Text { text: summary_content }]);
let mut post_messages = Vec::with_capacity(tail.len() + 2);
post_messages.push(boundary_msg);
post_messages.push(summary_msg);
post_messages.extend_from_slice(tail);

state.record_success();

Ok(CompactResult {
messages: vec![boundary_msg, summary_msg],
messages: post_messages,
messages_summarized,
pre_compact_tokens,
})
}

/// Index of the first message kept verbatim as the post-compact tail.
///
/// Walks backwards from the newest message while the accumulated estimate fits
/// `max_tokens`, then moves the cut forward to a genuine user turn so a
/// tool-call / tool-result pair is never split across the compaction boundary.
/// Returns `messages.len()` (empty tail) when no verbatim tail fits.
fn select_recent_tail(messages: &[Message], max_tokens: u64) -> usize {
if max_tokens == 0 || messages.is_empty() {
return messages.len();
}

let mut used = 0_u64;
let mut start = messages.len();
while start > 0 {
let candidate = estimate_messages_tokens(std::slice::from_ref(&messages[start - 1]));
if used.saturating_add(candidate) > max_tokens {
break;
}
used = used.saturating_add(candidate);
start -= 1;
}

while start < messages.len() && !is_turn_start(&messages[start]) {
start += 1;
}
start
}

/// Whether a message starts a real user turn.
///
/// Tool results are stored in `Role::User` messages, so those are not turn
/// boundaries: keeping them without their originating assistant `ToolUse` would
/// produce an orphan tool result.
fn is_turn_start(message: &Message) -> bool {
if message.role != Role::User {
return false;
}
for block in &message.content {
if matches!(block, ContentBlock::ToolResult { .. }) {
return false;
}
}
true
}

// ── Helpers ─────────────────────────────────────────────────────────────────

/// Appended to the summary prompt when the oldest messages had to be dropped to
/// fit the request, so the summary states what it could not see.
const TRUNCATED_NOTICE: &str = concat!(
"\n\nNOTE: the earliest messages were dropped to fit the model context window. ",
"Summarize what remains and say that earlier turns were omitted.",
);

/// Room reserved for the summary prompt text itself (tokens).
const COMPACT_PROMPT_RESERVE: u64 = 1_500;

/// Trim the oldest messages until the summary request fits the model window.
///
/// Returns the (possibly shortened) conversation plus whether anything was
/// dropped. The budget is `context_window - fixed completion budget - prompt
/// reserve`; the completion budget is not configurable in the engine, so it has
/// to be subtracted here rather than left to the provider.
fn fit_to_window(mut messages: Vec<Message>, context_window: usize) -> (Vec<Message>, bool) {
let budget = (context_window as u64)
.saturating_sub(COMPACT_MAX_OUTPUT_TOKENS as u64)
.saturating_sub(COMPACT_PROMPT_RESERVE);
let mut trimmed = false;
// `truncate_for_retry` always drops at least one message, so the loop
// terminates; the counter only guards against a future change that stops
// shrinking.
for _ in 0..256 {
if estimate_messages_tokens(&messages) <= budget {
break;
}
match truncate_for_retry(&messages) {
Some(next) => {
messages = next;
trimmed = true;
}
// A single message that already exceeds the budget: send it and let
// the provider decide rather than dropping the whole conversation.
None => break,
}
}
(messages, trimmed)
}

/// Collect all text from a streaming LLM response.
async fn collect_stream_text(mut rx: mpsc::Receiver<LlmEvent>) -> Result<(String, TokenUsage), CompactError> {
let mut text = String::new();
Expand All @@ -211,6 +322,24 @@ async fn collect_stream_text(mut rx: mpsc::Receiver<LlmEvent>) -> Result<(String
Err(CompactError::EmptyResponse)
}

/// Message prepended when the oldest messages are dropped, so the model knows
/// earlier turns were omitted.
const TRUNCATE_PLACEHOLDER: &str = "[earlier conversation truncated for compaction retry]";

fn is_truncate_placeholder(message: &Message) -> bool {
if message.role != Role::User {
return false;
}
for block in &message.content {
if let ContentBlock::Text { text } = block
&& text.as_str() == TRUNCATE_PLACEHOLDER
{
return true;
}
}
false
}

/// Truncate the oldest ~20% of messages for PTL retry.
///
/// Returns `None` if there are too few messages to truncate meaningfully.
Expand All @@ -219,22 +348,34 @@ fn truncate_for_retry(messages: &[Message]) -> Option<Vec<Message>> {
return None;
}

let drop_count = (messages.len() / 5).max(1);
if drop_count >= messages.len() {
// Keep any leading placeholder out of the droppable set. Otherwise an
// automated trimmer (e.g. `fit_to_window`) that drops a single message per
// pass can delete only the placeholder and re-insert it on the next pass,
// looping forever without ever removing real content.
let (prefix, rest) = match messages.first() {
Some(first) if is_truncate_placeholder(first) => (Some(first), &messages[1..]),
_ => (None, messages),
};

let drop_count = (rest.len() / 5).max(1);
if drop_count >= rest.len() {
return None;
}

let remaining = &messages[drop_count..];
let remaining = &rest[drop_count..];
let mut result = Vec::with_capacity(remaining.len() + 1);

// Ensure the first message is User role for API compatibility
if remaining.first().map(|m| m.role) != Some(Role::User) {
result.push(Message::new(
// Ensure the first message is User role for API compatibility, reusing the
// existing placeholder instead of stacking a second one.
match prefix {
Some(placeholder) => result.push(placeholder.clone()),
None if remaining.first().map(|m| m.role) != Some(Role::User) => result.push(Message::new(
Role::User,
vec![ContentBlock::Text {
text: "[earlier conversation truncated for compaction retry]".to_string(),
text: TRUNCATE_PLACEHOLDER.to_string(),
}],
));
)),
_ => {}
}

result.extend_from_slice(remaining);
Expand Down
48 changes: 48 additions & 0 deletions crates/aion-agent/src/compact/auto_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,52 @@ mod tests {
);
assert!(extract_compact_metadata(&msg).is_none());
}

// ── fit_to_window ───────────────────────────────────────────────────────

/// `chars` long text message (4 chars ≈ 1 token).
fn big_message(role: Role, chars: usize) -> Message {
Message::new(
role,
vec![ContentBlock::Text {
text: "x".repeat(chars),
}],
)
}

#[test]
fn history_that_already_fits_is_left_untouched() {
let messages = vec![big_message(Role::User, 4_000), big_message(Role::Assistant, 4_000)];

let (fitted, trimmed) = fit_to_window(messages.clone(), 200_000);

assert!(!trimmed);
assert_eq!(fitted.len(), messages.len());
}

#[test]
fn oversized_history_is_trimmed_below_window_minus_completion_budget() {
// 20 messages × ~25K tokens = ~500K, window 131K: the summary request
// would ask for 500K + 20K completion and be rejected by the provider.
let mut messages = Vec::new();
for index in 0..20 {
let role = if index % 2 == 0 { Role::User } else { Role::Assistant };
messages.push(big_message(role, 100_000));
}

let (fitted, trimmed) = fit_to_window(messages, 131_072);

assert!(trimmed, "an oversized history must be trimmed");
assert!(!fitted.is_empty(), "the newest messages must survive");
let estimate = crate::compact::estimate::estimate_messages_tokens(&fitted);
assert!(
estimate + COMPACT_MAX_OUTPUT_TOKENS as u64 + COMPACT_PROMPT_RESERVE <= 131_072,
"trimmed history still asks for {estimate} tokens"
);
// Trimming only from the front keeps the most recent turn intact.
assert!(matches!(
fitted.last().map(|message| message.role),
Some(Role::Assistant)
));
}
}
37 changes: 36 additions & 1 deletion crates/aion-agent/src/compact/estimate.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,42 @@
use aion_types::message::{ContentBlock, ImageUrl};
use aion_types::message::{ContentBlock, ImageUrl, Message};

const CHARS_PER_TOKEN_TEXT: usize = 4;

/// Rough token estimate for a whole conversation.
///
/// Autocompact re-sends the conversation as the summary prompt, so it needs to
/// know how much of the history fits into the window *before* the request goes
/// out — waiting for a provider rejection is not a reliable signal (an
/// OpenAI-compatible server answers a plain 400 for an oversized prompt).
pub(crate) fn estimate_messages_tokens(messages: &[Message]) -> u64 {
messages
.iter()
.flat_map(|message| message.content.iter())
.map(estimate_block_tokens)
.sum()
}

fn estimate_block_tokens(block: &ContentBlock) -> u64 {
match block {
ContentBlock::Text { text } => estimate_text_tokens(text),
ContentBlock::Thinking { thinking, .. } => estimate_text_tokens(thinking),
ContentBlock::ToolResult { content, .. } => estimate_text_tokens(content),
ContentBlock::ToolUse { input, .. } => estimate_json_tokens(input),
ContentBlock::Image { image_url } => estimate_image_tokens(image_url),
ContentBlock::ProviderItem { item, .. } => estimate_json_tokens(item),
}
}

fn estimate_text_tokens(text: &str) -> u64 {
(text.len() / CHARS_PER_TOKEN_TEXT) as u64
}

fn estimate_json_tokens(value: &serde_json::Value) -> u64 {
serde_json::to_string(value)
.map(|json| estimate_text_tokens(&json))
.unwrap_or(0)
}

/// Estimate one final tool result that will be added after the provider's
/// exact usage measurement.
pub(crate) fn estimate_tokens_from_tool_result(block: &ContentBlock) -> u64 {
Expand Down
10 changes: 10 additions & 0 deletions crates/aion-agent/src/context_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ pub enum ContextUsageSource {
LocalProjected,
}

impl ContextUsageSource {
/// Stable, protocol-facing identifier for the usage source.
pub fn as_str(&self) -> &'static str {
match self {
ContextUsageSource::ProviderExact => "provider_exact",
ContextUsageSource::LocalProjected => "local_projected",
}
}
}

/// Session-scoped context accounting persisted alongside conversation history.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
Expand Down
Loading