feat: Suppor mid-turn auto compressions so sufficiently large histories don't create deadlocks for max input tokens

This commit is contained in:
Dark-Alex-17 committed 2026-09-17 17:12:18 -06:00
1 parent 56ed4664cd
commit e002bd6727
9 files changed
+1199 -109

No files matched your search

+3
View File
@@ -71,6 +71,7 @@ pub struct AppConfig {
pub compression_threshold: usize,
pub compression_keep_last: usize,
pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
@@ -164,6 +165,7 @@ impl Default for AppConfig {
compression_threshold: 4000,
compression_keep_last: 0,
compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
@@ -270,6 +272,7 @@ impl AppConfig {
compression_threshold: config.compression_threshold,
compression_keep_last: config.compression_keep_last,
compression_model: config.compression_model,
compression_safety_valve: config.compression_safety_valve,
summarization_prompt: config.summarization_prompt,
summary_context_prompt: config.summary_context_prompt,
max_tool_result_chars: config.max_tool_result_chars,
+90
View File
@@ -68,6 +68,34 @@ impl Input {
})
}
/// An input carrying only `text`: no session history, no RAG, and no
/// function declarations. For auxiliary LLM calls (such as session
/// summarization) whose requests must not grow with the session.
pub fn detached_text(ctx: &RequestContext, text: &str) -> Self {
let mut role = Role::new("", "");
role.set_model(ctx.current_model().clone());
Self {
app_config: Arc::clone(&ctx.app.config),
stream_enabled: ctx.app.config.stream,
session: None,
rag: None,
functions: None,
text: text.to_string(),
raw: (text.to_string(), vec![]),
patched_text: None,
last_reply: None,
continue_output: None,
regenerate: false,
medias: Default::default(),
data_urls: Default::default(),
tool_calls: None,
role,
rag_name: None,
with_session: false,
with_agent: false,
}
}
pub async fn from_files(
ctx: &RequestContext,
raw_text: &str,
@@ -329,6 +357,14 @@ impl Input {
}
}
/// Re-captures `ctx.session` in place of the snapshot taken at
/// construction. Session compression mutates the ctx's session, not the
/// snapshot, so an input that outlives a compression must refresh before
/// building its next request or it re-sends the pre-compression history.
pub fn refresh_session(&mut self, ctx: &RequestContext) {
self.session = ctx.session.clone();
}
pub fn session_mut<'a>(&self, session: &'a mut Option<Session>) -> Option<&'a mut Session> {
if self.with_session {
session.as_mut()
@@ -660,6 +696,60 @@ mod tests {
assert!(!with_session);
}
#[test]
fn detached_text_input_carries_no_session_history_or_functions() {
let mut ctx = create_test_ctx();
ctx.session = Some(Session::default());
let earlier = Input::from_str(&ctx, "earlier question", None).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&earlier, "earlier answer")
.unwrap();
let detached = Input::detached_text(&ctx, "summarize the above");
let messages = detached.build_messages().unwrap();
assert_eq!(messages.len(), 1, "must contain only the detached text");
assert!(messages[0].role.is_user());
assert_eq!(messages[0].content.to_text(), "summarize the above");
assert!(detached.declared_function_names().is_empty());
assert!(!detached.with_session());
assert!(!detached.with_agent());
}
#[test]
fn refresh_session_rebuilds_from_compressed_session() {
let mut ctx = create_test_ctx();
ctx.session = Some(Session::default());
for i in 0..4 {
let question =
Input::from_str(&ctx, &format!("question {i}: {}", "x".repeat(500)), None).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&question, &"y".repeat(500))
.unwrap();
}
let mut input = Input::from_str(&ctx, "retry me", None).unwrap();
let stale = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
ctx.session.as_mut().unwrap().compress("recap".into(), 1);
assert_eq!(
serde_json::to_string(&input.build_messages().unwrap()).unwrap(),
stale,
"the snapshot captured at construction must be untouched by compression"
);
input.refresh_session(&ctx);
let refreshed = serde_json::to_string(&input.build_messages().unwrap()).unwrap();
assert!(
refreshed.len() < stale.len(),
"a refreshed input must build a strictly smaller request after compression"
);
}
#[test]
fn resolve_paths_detects_last_reply_syntax() {
let loaders = HashMap::new();
+4
View File
@@ -286,6 +286,7 @@ pub struct Config {
pub compression_threshold: usize,
pub compression_keep_last: usize,
pub compression_model: Option<String>,
pub compression_safety_valve: f32,
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub max_tool_result_chars: Option<usize>,
@@ -376,6 +377,7 @@ impl Default for Config {
compression_threshold: 4000,
compression_keep_last: 0,
compression_model: None,
compression_safety_valve: 0.8,
summarization_prompt: None,
summary_context_prompt: None,
max_tool_result_chars: None,
@@ -1379,6 +1381,7 @@ clients:
assert!(cfg.mcp_server_support);
assert_eq!(cfg.compression_threshold, 4000);
assert!(cfg.compression_model.is_none());
assert_eq!(cfg.compression_safety_valve, 0.8);
assert_eq!(cfg.rag_top_k, 5);
assert!(cfg.save_shell_history);
assert_eq!(cfg.keybindings, "emacs");
@@ -1419,6 +1422,7 @@ clients:
assert!(cfg.mapping_tools.is_empty());
assert!(cfg.document_loaders.is_empty());
assert_eq!(cfg.compression_threshold, 4000);
assert_eq!(cfg.compression_safety_valve, 0.8);
assert!(cfg.theme.is_none());
assert_eq!(cfg.clients.len(), 1);
}
+374 -32
View File
@@ -16,7 +16,9 @@ use super::{
list_agents_with_descriptions, memory, paths,
};
use super::{MessageContentToolCalls, prompts};
use crate::client::{Model, ModelType, TokenUsage, list_models};
use crate::client::{
Message, MessageContent, MessageRole, Model, ModelType, TokenUsage, list_models,
};
use crate::function::{
FunctionDeclaration, Functions, ToolCallTracker, ToolResult,
agents::AGENT_FUNCTION_PREFIX,
@@ -51,7 +53,7 @@ use super::memory::{
};
use crate::graph;
use crate::hooks::{self, HookEvent};
use anyhow::{Context, Error, Result, bail};
use anyhow::{Context, Error, Result, anyhow, bail};
use colored::Colorize;
use gman::providers::SupportedProvider;
use hooks::{McpServerHooks, RagSyncHooks};
@@ -68,7 +70,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::{env, fs};
use std::{env, fs, slice};
pub(crate) fn expand_enabled_mcp_server_ids(
app: &AppConfig,
@@ -316,6 +318,13 @@ pub struct RequestContext {
pub last_token_usage: Option<TokenUsage>,
pub last_cost: Option<f64>,
/// Prompt-side tokens from the most recent request that reported usage.
/// Unlike `last_token_usage`, this is never clobbered by requests that
/// report nothing (e.g. a stream rejected before `message_start`), so the
/// compression safety valve always sees the last real prompt
/// measurement.
pub last_prompt_token_usage: Option<usize>,
pub tool_scope: ToolScope,
pub declared_function_names: HashSet<String>,
@@ -385,6 +394,7 @@ impl RequestContext {
last_message: None,
last_token_usage: None,
last_cost: None,
last_prompt_token_usage: None,
tool_scope: ToolScope::default(),
declared_function_names: Default::default(),
node_job_scope: None,
@@ -457,6 +467,7 @@ impl RequestContext {
last_message: None,
last_token_usage: None,
last_cost: None,
last_prompt_token_usage: None,
tool_scope: ToolScope {
functions,
mcp_runtime,
@@ -522,6 +533,7 @@ impl RequestContext {
last_message: self.last_message.clone(),
last_token_usage: self.last_token_usage.clone(),
last_cost: self.last_cost,
last_prompt_token_usage: self.last_prompt_token_usage,
tool_scope: self.tool_scope.clone(),
declared_function_names: self.declared_function_names.clone(),
node_job_scope: None,
@@ -577,6 +589,7 @@ impl RequestContext {
last_message: None,
last_token_usage: None,
last_cost: None,
last_prompt_token_usage: None,
tool_scope: ToolScope {
functions: Functions::default(),
mcp_runtime: McpRuntime::default(),
@@ -2294,6 +2307,14 @@ impl RequestContext {
session.accumulate_cost(cost);
}
}
// Partial usage from an aborted stream lands here on purpose:
// it is a genuine API-reported prompt measurement, not noise.
// Only usage-less recordings (failed requests) leave the
// previous measurement in place.
let prompt_tokens = usage.total_prompt_tokens();
if prompt_tokens > 0 {
self.last_prompt_token_usage = Some(prompt_tokens as usize);
}
}
self.last_token_usage = usage;
self.last_cost = cost;
@@ -5030,7 +5051,7 @@ impl RequestContext {
} else {
role_name = self.role.as_ref().map(|v| v.name().to_string());
}
let name = role_name.ok_or_else(|| anyhow::anyhow!("No role"))?;
let name = role_name.ok_or_else(|| anyhow!("No role"))?;
self.upsert_role(app, &name)?;
self.use_role(app, &name, abort_signal).await
}
@@ -5243,14 +5264,31 @@ impl RequestContext {
}
pub async fn compress_session(&mut self) -> Result<()> {
match self.session.as_ref() {
async fn timed_fetch(input: Input) -> Result<String> {
tokio::time::timeout(Duration::from_secs(120), input.fetch_chat_text())
.await
.map_err(|_| anyhow!("Compression LLM call timed out after 120 s"))?
}
self.compress_session_impl(timed_fetch).await
}
/// The summarization flow with the LLM call injectable, so tests can
/// capture and answer each chunk request without a network round trip.
async fn compress_session_impl<F, Fut>(&mut self, fetch: F) -> Result<()>
where
F: Fn(Input) -> Fut,
Fut: Future<Output = Result<String>>,
{
let session_window = match self.session.as_ref() {
Some(session) => {
if !session.has_user_messages() {
bail!("No need to compress since there are no messages in the session")
}
session.model().max_input_tokens()
}
None => bail!("No session"),
}
};
let prompt = self
.app
@@ -5259,44 +5297,79 @@ impl RequestContext {
.clone()
.unwrap_or_else(|| SUMMARIZATION_PROMPT.into());
async fn timed_fetch(input: &Input) -> Result<String> {
tokio::time::timeout(Duration::from_secs(120), input.fetch_chat_text())
.await
.map_err(|_| anyhow::anyhow!("Compression LLM call timed out after 120 s"))?
}
let mut input = Input::from_str(self, &prompt, None)?;
let compression_model_id = self
let compression_model = match self
.compression_model()
.filter(|id| *id != self.current_model().id());
let summary = match compression_model_id {
.filter(|id| *id != self.current_model().id())
{
Some(model_id) => {
match Model::retrieve_model(self.app.config.as_ref(), &model_id, ModelType::Chat) {
Ok(model) => {
input.set_role_model(model);
match timed_fetch(&input).await {
Ok(summary) => summary,
Err(err) => {
eprintln!(
"Warning: compression model '{model_id}' failed: {err}; falling back to the current model '{}' for session compression",
self.current_model().id()
);
let input = Input::from_str(self, &prompt, None)?;
timed_fetch(&input).await?
}
}
}
Ok(model) => Some(model),
Err(err) => {
eprintln!(
"Warning: compression model '{model_id}' could not be used ({err}); falling back to the current model '{}' for session compression",
self.current_model().id()
);
timed_fetch(&input).await?
None
}
}
}
None => timed_fetch(&input).await?,
None => None,
};
// Cap each chunk well below the summarizer's window so every request
// (prior summary + chunk + prompt) fits, even for sessions that have
// grown past the window itself.
let window = compression_model
.as_ref()
.and_then(|model| model.max_input_tokens())
.or(session_window)
.unwrap_or(SUMMARIZATION_WINDOW_FALLBACK_TOKENS);
let chunk_budget = (window as f32 * SUMMARIZATION_CHUNK_BUDGET_RATIO) as usize;
let keep_last = self.compression_keep_last();
let chunks: Vec<String> = self
.session
.as_ref()
.map(|session| {
// `Session::compress` archives every message when `keep_last`
// covers the whole list, so summarize them all in that case.
let mut foldable = session.foldable_messages(keep_last);
if foldable.is_empty() {
foldable = session.foldable_messages(0);
}
slice_summarization_chunks(session.model(), foldable, chunk_budget)
.into_iter()
.map(render_summarization_chunk)
.collect()
})
.unwrap_or_default();
// Fold forward, oldest chunk first: each call carries the summary of
// everything before it, and the summarization prompt instructs the
// model to merge that prior summary into the new one.
let mut summary = String::new();
for chunk in &chunks {
let request = compose_summarization_request(&prompt, &summary, chunk);
summary = match compression_model.as_ref() {
Some(model) => {
let mut input = Input::detached_text(self, &request);
input.set_role_model(model.clone());
match fetch(input).await {
Ok(summary) => summary,
Err(err) => {
eprintln!(
"Warning: compression model '{}' failed: {err}; falling back to the current model '{}' for session compression",
model.id(),
self.current_model().id()
);
fetch(Input::detached_text(self, &request)).await?
}
}
}
None => fetch(Input::detached_text(self, &request)).await?,
};
}
let summary_context_prompt = self
.app
.config
@@ -5541,6 +5614,73 @@ fn fork_base_name(name: &str) -> &str {
name
}
/// Assumed summarizer window when neither the compression model nor the
/// session model declares `max_input_tokens`.
const SUMMARIZATION_WINDOW_FALLBACK_TOKENS: usize = 200_000;
/// Share of the summarizer's window a single history chunk may occupy; the
/// rest is headroom for the summarization prompt, the folded-forward prior
/// summary, and the response.
const SUMMARIZATION_CHUNK_BUDGET_RATIO: f32 = 0.6;
/// Splits `messages` into consecutive runs whose estimated tokens each stay
/// within `budget`. A message that alone exceeds the budget forms its own
/// chunk: messages are the smallest unit summarization can fold.
fn slice_summarization_chunks<'a>(
model: &Model,
messages: &'a [Message],
budget: usize,
) -> Vec<&'a [Message]> {
let mut chunks = vec![];
let mut start = 0;
let mut tokens = 0;
for (i, message) in messages.iter().enumerate() {
let message_tokens = model.total_tokens(slice::from_ref(message));
if i > start && tokens + message_tokens > budget {
chunks.push(&messages[start..i]);
start = i;
tokens = 0;
}
tokens += message_tokens;
}
if start < messages.len() {
chunks.push(&messages[start..]);
}
chunks
}
/// Renders messages as a role-prefixed transcript for the summarizer. Tool
/// transcripts are serialized whole rather than dropped: their results often
/// carry the facts the summary must preserve.
fn render_summarization_chunk(messages: &[Message]) -> String {
messages
.iter()
.map(|message| {
let role = match message.role {
MessageRole::System => "SYSTEM",
MessageRole::Assistant => "ASSISTANT",
MessageRole::User => "USER",
MessageRole::Tool => "TOOL",
};
let content = match &message.content {
MessageContent::ToolCalls(tool_calls) => {
serde_json::to_string(tool_calls).unwrap_or_else(|_| tool_calls.text.clone())
}
content => content.to_text(),
};
format!("{role}: {content}")
})
.collect::<Vec<_>>()
.join("\n\n")
}
fn compose_summarization_request(prompt: &str, prior_summary: &str, chunk: &str) -> String {
if prior_summary.is_empty() {
format!("{chunk}\n\n{prompt}")
} else {
format!("{SUMMARY_CONTEXT_PROMPT}{prior_summary}\n\n{chunk}\n\n{prompt}")
}
}
#[cfg(test)]
mod tests {
use super::super::mcp_factory::McpFactory;
@@ -5755,6 +5895,30 @@ mod tests {
assert!(!vars.contains_key("total_cost"));
}
#[test]
fn record_token_usage_keeps_last_prompt_tokens_across_failed_requests() {
let mut ctx = create_test_ctx();
ctx.record_token_usage(
Some(TokenUsage {
input_tokens: Some(100),
output_tokens: Some(5),
cache_creation_input_tokens: Some(40),
cache_read_input_tokens: Some(60),
}),
&priced_model(),
);
assert_eq!(ctx.last_prompt_token_usage, Some(200));
// A request that fails before the provider reports usage records
// `None`: the display field is clobbered by design, but the safety
// valve's measurement must survive.
ctx.record_token_usage(None, &priced_model());
assert!(ctx.last_token_usage.is_none());
assert_eq!(ctx.last_prompt_token_usage, Some(200));
}
#[test]
fn generate_prompt_context_omits_costs_that_render_as_zero() {
let mut ctx = create_test_ctx();
@@ -6028,6 +6192,184 @@ mod tests {
);
}
fn windowed_model(max_input_tokens: usize) -> Model {
let mut data = ModelData::new("test-window");
data.max_input_tokens = Some(max_input_tokens);
Model::from_config("openai", &[data]).remove(0)
}
fn text_message(role: MessageRole, text: &str) -> Message {
Message::new(role, MessageContent::Text(text.to_string()))
}
#[test]
fn summarization_chunks_respect_budget_and_preserve_order() {
let model = Model::default();
// 1000 ascii chars => ~250 estimated tokens per message.
let messages: Vec<Message> = (0..10)
.map(|i| {
let role = if i % 2 == 0 {
MessageRole::User
} else {
MessageRole::Assistant
};
text_message(role, &format!("msg-{i:02} {}", "x".repeat(993)))
})
.collect();
let budget = 600;
let chunks = slice_summarization_chunks(&model, &messages, budget);
assert!(chunks.len() > 1, "a history over budget must be split");
for chunk in &chunks {
assert!(
model.total_tokens(chunk) <= budget,
"each chunk must fit the budget"
);
}
let flattened: Vec<&Message> = chunks.iter().flat_map(|chunk| chunk.iter()).collect();
assert_eq!(
flattened.len(),
messages.len(),
"chunks must cover every message"
);
for (original, chunked) in messages.iter().zip(flattened) {
assert_eq!(original.content.to_text(), chunked.content.to_text());
}
assert_eq!(
slice_summarization_chunks(&model, &messages, 10_000).len(),
1,
"a history within budget must stay a single chunk"
);
}
#[test]
fn summarization_chunk_slicing_isolates_oversized_message() {
let model = Model::default();
let messages = vec![
text_message(MessageRole::User, "small question"),
text_message(MessageRole::Assistant, &"y".repeat(8000)),
text_message(MessageRole::User, "another small question"),
];
let chunks = slice_summarization_chunks(&model, &messages, 100);
assert_eq!(chunks.len(), 3);
assert_eq!(
chunks[1].len(),
1,
"an over-budget message forms its own chunk"
);
}
#[test]
fn render_summarization_chunk_keeps_tool_transcripts() {
let tool_calls = MessageContentToolCalls::new(
vec![ToolResult::new(
ToolCall::new(
"fs_cat".to_string(),
json!({"path": "notes.txt"}),
Some("call-1".to_string()),
),
json!("the file contents"),
)],
"reading the file".to_string(),
);
let messages = vec![
text_message(MessageRole::User, "read notes.txt"),
Message::new(MessageRole::Tool, MessageContent::ToolCalls(tool_calls)),
];
let rendered = render_summarization_chunk(&messages);
assert!(rendered.contains("USER: read notes.txt"));
assert!(rendered.contains("TOOL: "));
assert!(rendered.contains("fs_cat"));
assert!(rendered.contains("the file contents"));
}
#[test]
fn compress_session_folds_forward_across_chunks() {
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
compression_keep_last: 2,
..AppConfig::default()
});
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
let mut session = Session::default();
// A 2_000-token window gives a 1_200-token chunk budget; the session
// built below estimates past the window itself.
session.set_model(windowed_model(2_000));
ctx.session = Some(session);
for i in 0..10 {
let text = format!("msg-{i:02} {}", "x".repeat(793));
let input = Input::from_str(&ctx, &text, Some(Role::new("", ""))).unwrap();
ctx.session
.as_mut()
.unwrap()
.add_message(&input, &format!("reply-{i:02}"))
.unwrap();
}
assert!(
ctx.session.as_ref().unwrap().tokens() > 2_000,
"the session must not fit the summarizer window in one request"
);
let requests = std::cell::RefCell::new(Vec::new());
let requests_ref = &requests;
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(ctx.compress_session_impl(move |input: Input| async move {
// The real request pipeline, guard_max_input_tokens included.
let data = input.prepare_completion_data(input.role().model(), false)?;
requests_ref
.borrow_mut()
.push((input.text(), data.messages, data.functions));
Ok(format!("folded-summary-{}", requests_ref.borrow().len()))
}))
.unwrap();
let requests = requests.into_inner();
assert_eq!(requests.len(), 2, "the foldable middle needs two chunks");
let (first_text, first_messages, first_functions) = &requests[0];
assert!(first_text.contains("msg-00"));
assert!(!first_text.contains(SUMMARY_CONTEXT_PROMPT));
assert_eq!(first_messages.len(), 1, "detached: no session history");
assert!(first_messages[0].role.is_user());
assert!(
first_functions.is_none(),
"detached: no function declarations"
);
let (second_text, second_messages, second_functions) = &requests[1];
assert!(second_text.contains(SUMMARY_CONTEXT_PROMPT));
assert!(second_text.contains("folded-summary-1"));
assert!(second_text.contains("msg-05"));
assert!(
!second_text.contains("msg-00"),
"the first chunk reaches the second call only through its summary"
);
assert!(
!second_text.contains("msg-09"),
"the keep_last tail is never summarized"
);
assert_eq!(second_messages.len(), 1, "detached: no session history");
assert!(second_functions.is_none());
let session = ctx.session.as_ref().unwrap();
assert_eq!(session.messages().len(), 3, "summary + keep_last tail");
assert!(session.messages()[0].role.is_system());
assert!(
session.messages()[0]
.content
.to_text()
.contains("folded-summary-2"),
"the recap must carry the last folded summary"
);
}
#[test]
fn should_inject_skill_instructions_requires_function_calling() {
let app = AppConfig {
+118 -6
View File
@@ -719,17 +719,50 @@ impl Session {
self.reclaimable_tokens(keep_last) >= (threshold / COMPRESSION_MIN_RECLAIM_DIVISOR).max(1)
}
/// Estimated tokens of the messages that compression would actually fold
/// away: everything except the leading system message (whose content is
/// carried into the summary message) and the `keep_last` tail (kept verbatim).
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
/// Whether the prompt is close enough to the model's hard input limit
/// that compression must run regardless of how `compression_threshold`
/// is configured. Sizes the prompt as the larger of the API-reported
/// measurement from the last request that reported usage and the local
/// estimate, because the estimate can run well below the provider's
/// real count.
pub fn safety_valve_triggered(
&self,
valve: f32,
keep_last: usize,
last_prompt_tokens: Option<usize>,
) -> bool {
if self.compressing || !valve.is_finite() || valve <= 0.0 {
return false;
}
let Some(max_input_tokens) = self.model().max_input_tokens() else {
return false;
};
// With nothing to fold away, compressing is a no-op; firing anyway
// would spin compress attempts while a single in-flight turn holds
// all the tokens.
if self.reclaimable_tokens(keep_last) == 0 {
return false;
}
last_prompt_tokens.unwrap_or(0).max(self.tokens())
>= (valve * max_input_tokens as f32) as usize
}
/// The messages that compression would actually fold away: everything
/// except the leading system message (whose content is carried into the
/// summary message) and the `keep_last` tail (kept verbatim).
pub fn foldable_messages(&self, keep_last: usize) -> &[Message] {
let start = usize::from(
self.messages
.first()
.is_some_and(|v| v.role == MessageRole::System),
);
let end = self.messages.len().saturating_sub(keep_last).max(start);
self.model().total_tokens(&self.messages[start..end])
&self.messages[start..end]
}
/// Estimated tokens of the messages compression would fold away.
fn reclaimable_tokens(&self, keep_last: usize) -> usize {
self.model().total_tokens(self.foldable_messages(keep_last))
}
pub fn compressing(&self) -> bool {
@@ -1109,7 +1142,7 @@ impl AutoName {
mod tests {
use super::*;
use crate::client::{
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, TokenUsage,
Message, MessageContent, MessageContentToolCalls, MessageRole, Model, ModelData, TokenUsage,
};
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
use crate::function::{Functions, ToolCall, ToolResult};
@@ -1578,6 +1611,85 @@ mod tests {
assert!(session.needs_compression(100, 1));
}
/// A session with a system message, a large user message, and a small
/// assistant reply, on a model with the given input limit. With
/// `keep_last = 1` the user message is reclaimable; with `keep_last = 2`
/// nothing is.
fn valve_session(max_input_tokens: Option<usize>) -> Session {
let mut session = Session::default();
let mut data = ModelData::new("valve-model");
data.max_input_tokens = max_input_tokens;
session.set_model(Model::from_config("provider", &[data]).remove(0));
session.messages.push(Message::new(
MessageRole::System,
MessageContent::Text("x".repeat(400)),
));
session.messages.push(Message::new(
MessageRole::User,
MessageContent::Text("y".repeat(4000)),
));
session.messages.push(Message::new(
MessageRole::Assistant,
MessageContent::Text("ok".to_string()),
));
session.update_tokens();
session
}
#[test]
fn session_safety_valve_fires_from_estimate() {
let session = valve_session(Some(1000));
assert!(session.tokens() >= 800);
assert!(session.safety_valve_triggered(0.8, 1, None));
}
#[test]
fn session_safety_valve_fires_from_real_usage_over_estimate() {
let session = valve_session(Some(1_000_000));
assert!(!session.safety_valve_triggered(0.8, 1, None));
assert!(!session.safety_valve_triggered(0.8, 1, Some(700_000)));
assert!(session.safety_valve_triggered(0.8, 1, Some(800_000)));
}
#[test]
fn session_safety_valve_disabled_when_zero() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(0.0, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_disabled_when_nan() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(f32::NAN, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_respects_compressing() {
let mut session = valve_session(Some(1000));
session.set_compressing(true);
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_requires_reclaimable_tokens() {
let session = valve_session(Some(1000));
assert!(!session.safety_valve_triggered(0.8, 2, Some(1_000_000)));
assert!(session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_safety_valve_requires_model_max_input_tokens() {
let session = valve_session(None);
assert!(!session.safety_valve_triggered(0.8, 1, Some(1_000_000)));
}
#[test]
fn session_set_compressing_flag() {
let mut session = Session::default();