use super::tokens::count_tokens;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BudgetAction {
PassThrough,
Truncated {
original_tokens: usize,
delivered_tokens: usize,
},
}
pub fn enforce_turn_budget(
text: &str,
verbatim: bool,
recovery: Option<&str>,
tool: &str,
) -> String {
let cfg = crate::core::config::Config::load_arc();
let expand = tool == "ctx_expand";
let limit = if verbatim || expand {
cfg.turn_fresh_limit_verbatim_effective()
} else {
cfg.turn_fresh_limit_effective()
};
if limit == 0 {
return text.to_string();
}
let no_recovery = recovery.is_none_or(|line| line.trim().is_empty());
let recovery = if expand && no_recovery {
Some(EXPAND_NARROW_LINE)
} else {
recovery
};
let pointer = if !expand && no_recovery && count_tokens(text) > limit {
crate::core::recovery::archive_original(tool, tool, text)
} else {
None
};
let with_pointer;
let (text, recovery) = match pointer.as_deref() {
Some(line) => {
with_pointer = format!("{text}\n{line}");
(with_pointer.as_str(), Some(line))
}
None => (text, recovery),
};
let (budgeted, action) = apply_turn_budget(text, limit, recovery);
if let BudgetAction::Truncated {
original_tokens,
delivered_tokens,
} = action
{
tracing::debug!(
"budget: truncated {original_tokens} → {delivered_tokens} tokens (limit {limit})"
);
}
budgeted
}
const EXPAND_NARROW_LINE: &str =
"[narrow the same ctx_expand id with start_line+end_line, head, tail or search]";
pub(crate) fn apply_turn_budget(
text: &str,
fresh_limit: usize,
recovery: Option<&str>,
) -> (String, BudgetAction) {
if fresh_limit == 0 {
return (text.to_string(), BudgetAction::PassThrough);
}
let token_count = count_tokens(text);
if token_count <= fresh_limit {
return (text.to_string(), BudgetAction::PassThrough);
}
let recovery = recovery.filter(|r| !r.trim().is_empty());
let footer = recovery
.map(|r| format!("\n{}", r.trim_end()))
.unwrap_or_default();
let footer_tokens = count_tokens(&footer);
let budget_for_body = fresh_limit.saturating_sub(footer_tokens);
let provisional_hint = truncation_hint(budget_for_body, token_count, text, recovery.is_some());
let first_content_limit = budget_for_body.saturating_sub(count_tokens(&provisional_hint));
let first_truncated = truncate_to_token_budget(text, first_content_limit);
let first_delivered_tokens = count_tokens(&first_truncated);
let hint = truncation_hint(
first_delivered_tokens,
token_count,
text,
recovery.is_some(),
);
let content_limit = budget_for_body.saturating_sub(count_tokens(&hint));
let truncated = truncate_to_token_budget(text, content_limit);
let delivered_tokens = count_tokens(&truncated);
let hint = truncation_hint(delivered_tokens, token_count, text, recovery.is_some());
let result = format!("{truncated}{hint}{footer}");
let result = if count_tokens(&result) <= fresh_limit {
result
} else {
let notice_and_footer = format!("{hint}{footer}");
if count_tokens(¬ice_and_footer) <= fresh_limit {
notice_and_footer
} else if !footer.is_empty() && count_tokens(&footer) <= fresh_limit {
footer.clone()
} else {
truncate_to_token_budget(¬ice_and_footer, fresh_limit)
}
};
(
result,
BudgetAction::Truncated {
original_tokens: token_count,
delivered_tokens,
},
)
}
fn truncation_hint(
delivered_tokens: usize,
token_count: usize,
source: &str,
has_recovery_line: bool,
) -> String {
let recovery = if has_recovery_line {
"the full output is archived — see the next line"
} else if source.lines().nth(1).is_some() {
"use ctx_read with lines= parameter to see specific sections"
} else {
"single line — lines= cannot narrow it; use ctx_read(mode=\"raw\") for the full content"
};
format!("\n[… truncated at ~{delivered_tokens} of {token_count} tokens — {recovery}]")
}
pub(crate) fn truncate_to_token_budget(text: &str, limit: usize) -> String {
if limit == 0 {
return String::new();
}
let joined = text
.lines()
.skip_while(|line| line.is_empty())
.collect::<Vec<_>>()
.join("\n");
let mut ends: Vec<usize> = joined.match_indices('\n').map(|(i, _)| i).collect();
ends.push(joined.len());
let prefix = |lines: usize| {
if lines == 0 {
""
} else {
&joined[..ends[lines - 1]]
}
};
let (mut low, mut high) = (0usize, if text.is_empty() { 0 } else { ends.len() });
while low < high {
let mid = usize::midpoint(low + 1, high);
if count_tokens(prefix(mid)) <= limit {
low = mid;
} else {
high = mid - 1;
}
}
let result = prefix(low).to_string();
if result.is_empty() && !text.is_empty() {
return truncate_chars_to_token_budget(text, limit);
}
result
}
fn truncate_chars_to_token_budget(text: &str, limit: usize) -> String {
let chars: Vec<char> = text.chars().collect();
let (mut low, mut high) = (0usize, chars.len());
while low < high {
let mid = usize::midpoint(low + 1, high);
let candidate: String = chars[..mid].iter().collect();
if count_tokens(&candidate) <= limit {
low = mid;
} else {
high = mid - 1;
}
}
chars[..low].iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn linear_reference(text: &str, limit: usize) -> String {
if limit == 0 {
return String::new();
}
let mut result = String::new();
for line in text.lines() {
let previous_len = result.len();
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
if count_tokens(&result) > limit {
result.truncate(previous_len);
break;
}
}
if result.is_empty() && !text.is_empty() {
return truncate_chars_to_token_budget(text, limit);
}
result
}
#[test]
fn line_truncation_matches_the_linear_reference() {
let mut log = String::new();
for i in 0..300 {
std::fmt::Write::write_fmt(
&mut log,
format_args!("line {i}: value={} status=ok 🙂\n", i * 7),
)
.unwrap();
}
let inputs = [
String::new(),
"\n".to_string(),
"\n\n\nleading blanks\nthen text\n".to_string(),
"a\n\n\nb\r\nc\r\n".to_string(),
"one single long line without any break ".repeat(40),
log,
];
for text in &inputs {
for limit in [0, 1, 2, 5, 17, 64, 255, 1_000, 100_000] {
assert_eq!(
truncate_to_token_budget(text, limit),
linear_reference(text, limit),
"limit {limit} on {:?}",
text.get(..40).unwrap_or(text)
);
}
}
}
#[test]
fn passthrough_when_within_budget() {
let text = "small content";
let (result, action) = apply_turn_budget(text, 1000, None);
assert_eq!(result, text);
assert_eq!(action, BudgetAction::PassThrough);
}
#[test]
fn passthrough_when_budget_is_zero() {
let text = "any content at all";
let (result, action) = apply_turn_budget(text, 0, None);
assert_eq!(result, text);
assert_eq!(action, BudgetAction::PassThrough);
}
#[test]
fn truncation_keeps_the_complete_response_within_the_budget() {
let text = (0..2_000)
.map(|i| format!("fn output_{i}() {{ println!(\"{i}\"); }}"))
.collect::<Vec<_>>()
.join("\n");
let limit = 256;
let (result, action) = apply_turn_budget(&text, limit, None);
assert!(matches!(action, BudgetAction::Truncated { .. }));
assert!(count_tokens(&result) <= limit);
assert!(result.contains("ctx_read with lines="));
}
#[test]
fn truncates_large_content() {
let lines: Vec<String> = (0..200)
.map(|i| format!("fn function_{i}() {{ let x = {i}; }}"))
.collect();
let text = lines.join("\n");
let (result, action) = apply_turn_budget(&text, 100, None);
assert!(result.contains("[… truncated"));
assert!(result.contains("use ctx_read with lines="));
match action {
BudgetAction::Truncated {
original_tokens,
delivered_tokens,
} => {
assert!(
delivered_tokens <= 120,
"delivered {delivered_tokens} > ~120"
);
assert!(original_tokens > delivered_tokens);
}
BudgetAction::PassThrough => panic!("should have truncated"),
}
}
#[test]
fn truncation_preserves_complete_lines() {
let text = "line one\nline two\nline three\nline four\nline five";
let (result, _) = apply_turn_budget(text, 5, None);
let body = result.split("\n[… truncated").next().unwrap();
assert!(
!body.ends_with(char::is_whitespace),
"truncated body should end with a complete line"
);
}
}
#[cfg(test)]
mod gh1665 {
use super::*;
fn one_line(bytes: usize) -> String {
let mut s = String::from("{");
while s.len() < bytes {
s.push_str("\"key\":\"vvvvvvvvvvvvvvvvvvvv\",");
}
s.push('}');
s
}
#[test]
fn a_single_line_payload_still_delivers_content() {
let text = one_line(20_000);
assert_eq!(text.lines().count(), 1, "precondition: one line");
let (out, action) = apply_turn_budget(&text, 500, None);
assert!(
matches!(action, BudgetAction::Truncated { .. }),
"precondition: over budget"
);
let BudgetAction::Truncated {
delivered_tokens, ..
} = action
else {
unreachable!()
};
assert!(
delivered_tokens > 0,
"must not deliver zero content: {out:?}"
);
assert!(
out.len() > 200,
"the payload, not just the notice: {} bytes",
out.len()
);
assert!(
out.starts_with('{'),
"content comes first: {:?}",
&out[..40]
);
}
#[test]
fn the_hint_names_a_recovery_that_exists() {
let (single, _) = apply_turn_budget(&one_line(20_000), 500, None);
assert!(single.contains("mode=\"raw\""), "{single:?}");
let multi = (0..4000)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (multi_out, _) = apply_turn_budget(&multi, 500, None);
assert!(multi_out.contains("lines="), "multi-line keeps lines=");
}
#[test]
fn multi_line_text_still_breaks_on_line_boundaries() {
let text = (0..4000)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (out, _) = apply_turn_budget(&text, 500, None);
let body = out.split("\n[…").next().unwrap();
assert!(body.ends_with(|c: char| c.is_ascii_digit()), "{body:?}");
}
}
#[cfg(test)]
mod gh1660 {
use super::*;
fn long_body() -> String {
(0..4_000)
.map(|i| format!("line {i} of shell output"))
.collect::<Vec<_>>()
.join("\n")
}
const RECOVERY: &str =
"[Archived: 51200 chars (12800 tok). ctx_expand(\"c3ef0ac76145035d\") or read /tmp/a.txt]";
#[test]
fn the_recovery_line_survives_the_cut() {
let text = format!("{}\n{RECOVERY}", long_body());
let (out, action) = apply_turn_budget(&text, 500, Some(RECOVERY));
assert!(
matches!(action, BudgetAction::Truncated { .. }),
"precondition: over budget"
);
let (unaware, _) = apply_turn_budget(&text, 500, None);
assert!(
!unaware.contains("c3ef0ac76145035d"),
"precondition: this is what the reporter saw"
);
assert!(
out.contains("c3ef0ac76145035d"),
"the archive handle must survive truncation: {out}"
);
assert!(
out.trim_end().ends_with(RECOVERY.trim_end()),
"and stay the last line: {out}"
);
assert!(
count_tokens(&out) <= 500,
"the budget is still a hard cap: {} tokens",
count_tokens(&out)
);
}
#[test]
fn the_notice_does_not_name_a_route_that_cannot_work() {
let text = format!("{}\n{RECOVERY}", long_body());
let (out, _) = apply_turn_budget(&text, 500, Some(RECOVERY));
assert!(
!out.contains("ctx_read with lines="),
"must not point at a file tool for stream output: {out}"
);
assert!(out.contains("archived"), "{out}");
}
#[test]
fn a_cut_without_recovery_names_the_archived_original() {
let _data = crate::core::data_dir::isolated_data_dir();
let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
crate::test_env::set_var("LEAN_CTX_TURN_FRESH_LIMIT", "500");
let text = long_body();
let first = enforce_turn_budget(&text, false, None, "ctx_tree");
let second = enforce_turn_budget(&text, false, None, "ctx_tree");
let small = enforce_turn_budget("short", false, None, "ctx_tree");
crate::test_env::remove_var("LEAN_CTX_TURN_FRESH_LIMIT");
assert_eq!(first, second, "byte-stable");
assert_eq!(small, "short", "no cut, no pointer");
let pointer = first.lines().last().unwrap();
let id = pointer
.strip_prefix("[Original available via ctx_expand id=")
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or_else(|| panic!("pointer is the last line: {first}"));
assert!(first.contains("the full output is archived"), "{first}");
assert!(count_tokens(&first) <= 500);
let archived = crate::core::archive::retrieve(id).expect("resolves");
assert!(
archived.starts_with(&text),
"the whole original is archived"
);
}
#[test]
fn a_cut_ctx_expand_reply_is_never_archived_again() {
let _data = crate::core::data_dir::isolated_data_dir();
let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
let _limit = crate::test_env::EnvVarGuard::set("LEAN_CTX_TURN_FRESH_LIMIT", "300");
let _verbatim =
crate::test_env::EnvVarGuard::set("LEAN_CTX_TURN_FRESH_LIMIT_VERBATIM", "600");
let text = long_body();
let out = enforce_turn_budget(&text, false, None, "ctx_expand");
assert!(
!out.contains("[Original available via ctx_expand id="),
"no nested archive: {out}"
);
assert!(
out.trim_end().ends_with(EXPAND_NARROW_LINE),
"names the way to narrow the same archive: {out}"
);
let tokens = count_tokens(&out);
assert!(
tokens > 300 && tokens <= 600,
"verbatim budget: {tokens} tokens"
);
}
#[test]
fn without_a_recovery_line_the_file_hint_stays() {
let (out, _) = apply_turn_budget(&long_body(), 500, None);
assert!(out.contains("ctx_read with lines="), "{out}");
}
#[test]
fn a_tiny_budget_spends_itself_on_the_way_back() {
let text = format!("{}\n{RECOVERY}", long_body());
let (out, _) = apply_turn_budget(&text, 40, Some(RECOVERY));
assert!(
out.contains("c3ef0ac76145035d"),
"handle must outlive the body: {out}"
);
assert!(count_tokens(&out) <= 40, "{} tokens", count_tokens(&out));
}
}