2026-07-16 04:59:11 +07:00
|
|
|
//! Budget-based message shaping: compacts long conversation histories so
|
|
|
|
|
//! they fit within the provider's context window before being sent to
|
|
|
|
|
//! the LLM API. Ported from the former `runtime::shortsend` — behavior
|
|
|
|
|
//! is unchanged, only its token-counting now goes through
|
|
|
|
|
//! `context::tokens` instead of an inline heuristic.
|
2026-07-17 09:40:18 +07:00
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
|
|
2026-07-16 04:59:11 +07:00
|
|
|
use super::tokens::count_tokens;
|
|
|
|
|
use crate::dto::chat::message::ChatMessage;
|
|
|
|
|
|
|
|
|
|
/// Decide whether the message list should be shaped (compacted) before
|
|
|
|
|
/// sending to the LLM.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: trigger based on token estimate. If `token_estimate` exceeds
|
|
|
|
|
/// the threshold, we shape. When `prev_shaped` is true, the threshold is
|
|
|
|
|
/// raised (95%) to avoid fluttering — compaction only re-triggers when
|
|
|
|
|
/// the context is genuinely full again. When `prev_shaped` is false, the
|
|
|
|
|
/// threshold is lower (85%) so compaction starts proactively.
|
|
|
|
|
///
|
|
|
|
|
/// Why: hysteresis prevents repeated compaction on every turn when the
|
|
|
|
|
/// token count hovers near the boundary.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `true` if shaping should be applied.
|
|
|
|
|
pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool {
|
|
|
|
|
let threshold = if prev_shaped {
|
|
|
|
|
(max_wire_tokens as f32 * 0.95) as usize
|
|
|
|
|
} else {
|
|
|
|
|
(max_wire_tokens as f32 * 0.85) as usize
|
|
|
|
|
};
|
|
|
|
|
token_estimate >= threshold
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compact a long message list by dropping middle messages and inserting
|
|
|
|
|
/// a summary placeholder.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: if the estimated token count is within budget and not forced,
|
|
|
|
|
/// return messages unchanged -> otherwise keep the system message and
|
|
|
|
|
/// the most recent messages that fit a 70%-of-budget target, with a
|
|
|
|
|
/// `[prior conversation compacted]` (or LLM-generated summary, if
|
|
|
|
|
/// `client` is `Some`) system message in between.
|
|
|
|
|
///
|
2026-07-17 09:40:18 +07:00
|
|
|
/// The LLM summarization checks the abort flag before calling the LLM,
|
|
|
|
|
/// so a user-requested abort is respected promptly. The turn loop already
|
|
|
|
|
/// runs on a background thread (spawned in `spawn_turn`), so the blocking
|
|
|
|
|
/// summarization call does not freeze the UI.
|
|
|
|
|
///
|
2026-07-16 04:59:11 +07:00
|
|
|
/// Why: keeps context-size overhead roughly constant regardless of
|
|
|
|
|
/// session length.
|
|
|
|
|
///
|
|
|
|
|
/// Return: the shaped message list, or `messages` unchanged if shaping
|
|
|
|
|
/// wasn't needed.
|
|
|
|
|
pub fn shape_messages(
|
|
|
|
|
messages: &[ChatMessage],
|
|
|
|
|
token_count: usize,
|
|
|
|
|
max_wire_tokens: usize,
|
|
|
|
|
force: bool,
|
|
|
|
|
client: Option<&crate::service::provider::LlmClient>,
|
2026-07-17 09:40:18 +07:00
|
|
|
abort_flag: Option<&AtomicBool>,
|
2026-07-16 04:59:11 +07:00
|
|
|
) -> Vec<ChatMessage> {
|
|
|
|
|
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
|
|
|
|
|
return messages.to_vec();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let target_tokens = (max_wire_tokens as f32 * 0.70) as usize;
|
|
|
|
|
let mut current_tokens = 0;
|
|
|
|
|
let mut keep_recent = Vec::new();
|
|
|
|
|
let mut dropped_msgs = Vec::new();
|
|
|
|
|
|
|
|
|
|
let mut msgs_to_eval = messages.to_vec();
|
|
|
|
|
let first = if msgs_to_eval.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(msgs_to_eval.remove(0))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for m in msgs_to_eval.into_iter().rev() {
|
|
|
|
|
let text = m.content.as_deref().unwrap_or("");
|
|
|
|
|
let msg_tokens = count_tokens(text);
|
|
|
|
|
|
|
|
|
|
if current_tokens + msg_tokens <= target_tokens {
|
|
|
|
|
current_tokens += msg_tokens;
|
|
|
|
|
keep_recent.push(m);
|
|
|
|
|
} else {
|
|
|
|
|
dropped_msgs.push(m);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dropped_msgs.reverse();
|
|
|
|
|
|
|
|
|
|
let mut result = Vec::new();
|
|
|
|
|
if let Some(f) = first {
|
|
|
|
|
result.push(f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !dropped_msgs.is_empty() {
|
|
|
|
|
let mut summary_text = "[prior conversation compacted]".to_string();
|
|
|
|
|
|
|
|
|
|
if let Some(llm) = client {
|
2026-07-17 09:40:18 +07:00
|
|
|
// Check abort before starting the blocking LLM summarization call.
|
|
|
|
|
// The turn loop already runs on a background thread (spawned in
|
|
|
|
|
// `spawn_turn`), so this blocking call does not freeze the UI.
|
|
|
|
|
let aborted = abort_flag.is_some_and(|f| f.load(Ordering::SeqCst));
|
|
|
|
|
if !aborted {
|
|
|
|
|
let prompt = format!(
|
|
|
|
|
"You are a context-preservation summarizer. The following conversation \
|
|
|
|
|
history is being dropped to free up context window space. \
|
|
|
|
|
Produce a structured summary that preserves the information the AI \
|
|
|
|
|
agent needs to continue working seamlessly.\n\n\
|
|
|
|
|
Structure your summary into these sections:\n\
|
|
|
|
|
1. **Goals & Objectives** — what the user asked for, what tasks remain\n\
|
|
|
|
|
2. **Key Decisions** — architectural choices, design decisions, approach changes\n\
|
|
|
|
|
3. **Files Modified/Created** — paths and brief description of changes\n\
|
|
|
|
|
4. **Findings & State** — important discoveries, test results, current state\n\
|
|
|
|
|
5. **Open Items** — unresolved issues, pending tasks, next steps\n\n\
|
|
|
|
|
Be concise but thorough. Preserve file paths, error messages, and \
|
|
|
|
|
specific details the agent needs to continue.\n\n\
|
|
|
|
|
History to summarize:\n{}",
|
|
|
|
|
dropped_msgs.iter()
|
|
|
|
|
.map(|m| {
|
|
|
|
|
let role_label = match m.role {
|
|
|
|
|
crate::dto::chat::message::Role::User => "User",
|
|
|
|
|
crate::dto::chat::message::Role::Assistant => "Assistant",
|
|
|
|
|
crate::dto::chat::message::Role::System => "System",
|
|
|
|
|
crate::dto::chat::message::Role::Tool => "Tool",
|
|
|
|
|
};
|
|
|
|
|
let has_tool_calls = m.tool_calls.is_some()
|
|
|
|
|
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty());
|
|
|
|
|
let mut entry = format!("[{role_label}]: {}", m.content.as_deref().unwrap_or(""));
|
|
|
|
|
if has_tool_calls {
|
|
|
|
|
if let Some(calls) = &m.tool_calls {
|
|
|
|
|
let names: Vec<&str> = calls.iter().map(|c| c.function.name.as_str()).collect();
|
|
|
|
|
entry.push_str(&format!("\n [tool calls: {}]", names.join(", ")));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
entry
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n\n---\n\n")
|
|
|
|
|
);
|
2026-07-16 04:59:11 +07:00
|
|
|
|
2026-07-17 09:40:18 +07:00
|
|
|
let req_msgs = vec![ChatMessage::user(prompt)];
|
|
|
|
|
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
|
|
|
|
|
Ok(resp) => {
|
|
|
|
|
if let Some(content) = resp.0.content {
|
|
|
|
|
summary_text =
|
|
|
|
|
format!("[Summary of compacted prior conversation:\n{content}\n]");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"[context::shaping] LLM summarization failed: {}. \
|
|
|
|
|
Falling back to static placeholder.",
|
|
|
|
|
e,
|
|
|
|
|
);
|
2026-07-16 04:59:11 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.push(ChatMessage::system(summary_text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.extend(keep_recent.into_iter().rev());
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::dto::chat::message::ChatMessage;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn should_shape_triggers_at_85_percent_when_not_previously_shaped() {
|
|
|
|
|
assert!(should_shape(850, 1000, false));
|
|
|
|
|
assert!(!should_shape(849, 1000, false));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn should_shape_uses_95_percent_threshold_once_already_shaped() {
|
2026-07-16 07:42:03 +07:00
|
|
|
assert!(
|
|
|
|
|
!should_shape(900, 1000, true),
|
|
|
|
|
"below 95% and already shaped: no re-trigger yet"
|
|
|
|
|
);
|
2026-07-16 04:59:11 +07:00
|
|
|
assert!(should_shape(950, 1000, true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn shape_messages_is_a_noop_under_budget_and_not_forced() {
|
|
|
|
|
let messages = vec![
|
|
|
|
|
ChatMessage::system("sys"),
|
|
|
|
|
ChatMessage::user("hi"),
|
|
|
|
|
ChatMessage::assistant(Some("hello".to_string())),
|
|
|
|
|
];
|
2026-07-17 09:40:18 +07:00
|
|
|
let result = shape_messages(&messages, 10, 1000, false, None, None);
|
2026-07-16 04:59:11 +07:00
|
|
|
assert_eq!(result.len(), messages.len());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a message whose real BPE token count is large enough that 20
|
|
|
|
|
/// of them (~49 tokens each, ~980 total — verified empirically with
|
|
|
|
|
/// `context::tokens::count_tokens`) comfortably exceed
|
|
|
|
|
/// `shape_messages`'s 70%-of-1000 = 700 token target, guaranteeing
|
|
|
|
|
/// several get dropped. A short fixture like `format!("message {i}")`
|
|
|
|
|
/// (~8 tokens each, ~160 total for 20) stays entirely under budget
|
|
|
|
|
/// with real BPE counting and would make these tests pass vacuously
|
|
|
|
|
/// (nothing ever gets dropped, so "must survive shaping" and "falls
|
|
|
|
|
/// back to placeholder" hold trivially without exercising the actual
|
|
|
|
|
/// drop logic) — this was a real bug caught during Task 5's first
|
|
|
|
|
/// implementation attempt.
|
|
|
|
|
fn padded_message(i: usize) -> String {
|
|
|
|
|
format!(
|
|
|
|
|
"message number {i} with some padding text {}",
|
|
|
|
|
"additional padding content to increase token count substantially ".repeat(5),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn shape_messages_always_preserves_the_first_system_message() {
|
|
|
|
|
let mut messages = vec![ChatMessage::system("system prompt")];
|
|
|
|
|
for i in 0..20 {
|
|
|
|
|
messages.push(ChatMessage::user(padded_message(i)));
|
|
|
|
|
}
|
2026-07-17 09:40:18 +07:00
|
|
|
let result = shape_messages(&messages, 100_000, 1000, true, None, None);
|
2026-07-16 04:59:11 +07:00
|
|
|
assert_eq!(result[0].content.as_deref(), Some("system prompt"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn shape_messages_without_a_client_falls_back_to_placeholder_summary() {
|
|
|
|
|
let mut messages = vec![ChatMessage::system("system prompt")];
|
|
|
|
|
for i in 0..20 {
|
|
|
|
|
messages.push(ChatMessage::user(padded_message(i)));
|
|
|
|
|
}
|
2026-07-17 09:40:18 +07:00
|
|
|
let result = shape_messages(&messages, 100_000, 1000, true, None, None);
|
2026-07-16 07:42:03 +07:00
|
|
|
let has_placeholder = result
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|m| m.content.as_deref() == Some("[prior conversation compacted]"));
|
2026-07-16 04:59:11 +07:00
|
|
|
assert!(has_placeholder);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn shape_messages_keeps_most_recent_messages_over_older_ones() {
|
|
|
|
|
let mut messages = vec![ChatMessage::system("system prompt")];
|
|
|
|
|
for i in 0..20 {
|
|
|
|
|
messages.push(ChatMessage::user(padded_message(i)));
|
|
|
|
|
}
|
2026-07-17 09:40:18 +07:00
|
|
|
let result = shape_messages(&messages, 100_000, 1000, true, None, None);
|
2026-07-16 04:59:11 +07:00
|
|
|
let last_content = messages.last().unwrap().content.clone();
|
2026-07-16 07:42:03 +07:00
|
|
|
assert!(
|
|
|
|
|
result.iter().any(|m| m.content == last_content),
|
|
|
|
|
"most recent message must survive shaping"
|
|
|
|
|
);
|
2026-07-16 04:59:11 +07:00
|
|
|
}
|
2026-07-17 09:40:18 +07:00
|
|
|
}
|