use std::sync::atomic::{AtomicUsize, Ordering};
const DEFAULT_READ_LIMIT: usize = 2_000;
pub(super) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
const DEFAULT_OUTPUT_HEAD_TOKENS: usize = 1_500;
pub(super) const DEFAULT_OUTPUT_CAP_CHARS_PER_TOKEN: usize = 3;
static MAX_OUTPUT_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_MAX_OUTPUT_TOKENS);
static OUTPUT_HEAD_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_OUTPUT_HEAD_TOKENS);
static OUTPUT_CAP_CHARS_PER_TOKEN: AtomicUsize =
AtomicUsize::new(DEFAULT_OUTPUT_CAP_CHARS_PER_TOKEN);
pub fn set_max_output_tokens(max_tokens: usize) {
MAX_OUTPUT_TOKENS.store(max_tokens, Ordering::Relaxed);
}
pub fn set_output_head_tokens(head_tokens: usize) {
OUTPUT_HEAD_TOKENS.store(head_tokens, Ordering::Relaxed);
}
pub(super) fn max_output_tokens() -> usize {
MAX_OUTPUT_TOKENS.load(Ordering::Relaxed)
}
pub(super) fn output_head_tokens() -> usize {
OUTPUT_HEAD_TOKENS.load(Ordering::Relaxed)
}
pub fn set_output_cap_chars_per_token(chars_per_token: usize) {
OUTPUT_CAP_CHARS_PER_TOKEN.store(chars_per_token, Ordering::Relaxed);
}
pub(super) fn output_cap_chars_per_token() -> usize {
OUTPUT_CAP_CHARS_PER_TOKEN.load(Ordering::Relaxed)
}
pub(super) fn cap_estimator() -> crate::tokens::TokenEstimation {
crate::tokens::TokenEstimation::new(output_cap_chars_per_token())
}
pub(super) fn should_spill_full_output(
out_bytes: usize,
out_chars: usize,
max_tokens: usize,
tool_offload: bool,
) -> bool {
if max_tokens == 0 || !tool_offload {
return false;
}
let over_model_budget = cap_estimator().tokens_for_chars(out_bytes) > max_tokens;
let over_spill_budget = out_chars > crate::agentic::spill::TOOL_RESULT_SPILL_CAP;
over_model_budget || over_spill_budget
}
pub(super) fn cap_model_output(text: &str, max_tokens: usize) -> String {
cap_model_output_with_handle(text, max_tokens, output_head_tokens(), None)
}
pub(super) fn cap_model_output_with_handle(
text: &str,
max_tokens: usize,
head_tokens: usize,
spill_id: Option<&str>,
) -> String {
if max_tokens == 0 {
return text.to_string();
}
let est = cap_estimator();
if est.tokens_for_chars(text.len()) <= max_tokens {
return text.to_string();
}
let max_chars = est.chars_for_tokens(max_tokens);
let head_tokens = head_tokens.min(max_tokens);
let head_chars = est.chars_for_tokens(head_tokens).min(max_chars);
let tail_chars = max_chars.saturating_sub(head_chars);
let total_chars = text.chars().count();
let shown_chars = head_chars.saturating_add(tail_chars).min(total_chars);
let elided = total_chars.saturating_sub(shown_chars);
let head = take_chars(text, head_chars);
let tail = take_tail_chars(text, tail_chars);
let marker = match spill_id {
Some(id) => format!(
"[… {elided} chars elided (head+tail shown). Full output: \
memory_fetch(\"spill:{id}\"); search it with \
memory_fetch(\"spill:{id}\", grep=\"<pattern>\") …]"
),
None => format!(
"[… {elided} chars elided (head+tail shown; ~{max_tokens} token budget). \
Narrow the command or use a more specific grep/filter if needed …]"
),
};
format!("{head}\n\n{marker}\n\n{tail}")
}
fn take_chars(text: &str, max_chars: usize) -> String {
text.chars().take(max_chars).collect()
}
fn take_tail_chars(text: &str, max_chars: usize) -> String {
let mut chars: Vec<char> = text.chars().rev().take(max_chars).collect();
chars.reverse();
chars.into_iter().collect()
}
pub(super) fn paginate_read(
contents: &str,
offset: Option<usize>,
limit: Option<usize>,
max_output_tokens: usize,
) -> String {
let max_chars = if max_output_tokens == 0 {
usize::MAX
} else {
cap_estimator().chars_for_tokens(max_output_tokens)
};
let total = contents.lines().count();
let start = offset.filter(|&o| o > 0).unwrap_or(1); let limit = limit.filter(|&l| l > 0).unwrap_or(DEFAULT_READ_LIMIT);
if start == 1 && limit >= total && contents.len() <= max_chars {
return contents.to_string();
}
let start0 = start - 1;
if start0 >= total {
return format!("(offset {start} is past end of file — {total} lines total)");
}
let window: Vec<&str> = contents.lines().skip(start0).take(limit).collect();
let end = start0 + window.len(); let mut body = window.join("\n");
let char_capped = body.len() > max_chars;
if char_capped {
let mut cut = max_chars;
while cut > 0 && !body.is_char_boundary(cut) {
cut -= 1;
}
body.truncate(cut);
}
let footer = if char_capped {
Some(format!(
"payload truncated to {max_chars} chars (~{max_output_tokens} tokens) from line \
{start}; call read_file with a higher offset (and/or smaller limit) to continue"
))
} else if end < total {
Some(format!(
"showing lines {start}-{end} of {total}; \
call read_file with offset={} to continue",
end + 1
))
} else {
None
};
match footer {
Some(f) => format!("{body}\n\n[{f}]"),
None => body,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spill_gate_uses_the_conservative_cap_ratio_not_the_estimate() {
let out_bytes = 3_500;
let out_chars = 3_500; assert!(
out_chars < crate::agentic::spill::TOOL_RESULT_SPILL_CAP,
"isolate over_model_budget: stay under the raw spill cap"
);
assert!(should_spill_full_output(out_bytes, out_chars, 1_000, true));
assert!(crate::tokens::TokenEstimation::default().tokens_for_chars(out_bytes) <= 1_000);
assert!(cap_estimator().tokens_for_chars(out_bytes) > 1_000);
}
#[test]
fn spill_gate_off_when_no_offload_or_no_budget() {
assert!(!should_spill_full_output(
1_000_000, 1_000_000, 10_000, false
));
assert!(!should_spill_full_output(1_000_000, 1_000_000, 0, true));
}
#[test]
fn spill_gate_fires_on_raw_size_even_when_under_token_budget() {
let big = crate::agentic::spill::TOOL_RESULT_SPILL_CAP + 1;
assert!(should_spill_full_output(big, big, usize::MAX, true));
}
}