use std::sync::Arc;
use serde_json::Value;
use crate::core::tool_calls::normalize_message_tool_calls;
use crate::tape::entries::{TapeEntry, TapeEntryKind};
use crate::tape::query::TapeQuery;
#[derive(Debug, Clone, Default)]
pub enum AnchorSelector {
#[default]
LastAnchor,
Named(String),
None,
}
pub type SelectFn = Arc<dyn Fn(&[TapeEntry], &TapeContext) -> Vec<Value> + Send + Sync>;
#[derive(Clone)]
pub struct TapeContext {
pub anchor: AnchorSelector,
pub select: Option<SelectFn>,
pub state: Value,
}
impl Default for TapeContext {
fn default() -> Self {
Self {
anchor: AnchorSelector::default(),
select: None,
state: Value::Object(serde_json::Map::new()),
}
}
}
impl std::fmt::Debug for TapeContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TapeContext")
.field("anchor", &self.anchor)
.field("select", &self.select.as_ref().map(|_| "<fn>"))
.field("state", &self.state)
.finish()
}
}
impl TapeContext {
pub fn new() -> Self {
Self::default()
}
pub fn build_query(&self, query: TapeQuery) -> TapeQuery {
match &self.anchor {
AnchorSelector::None => query,
AnchorSelector::LastAnchor => query.last_anchor(),
AnchorSelector::Named(name) => query.after_anchor(name.clone()),
}
}
}
pub fn build_messages(entries: &[TapeEntry], context: &TapeContext) -> Vec<Value> {
if let Some(ref select) = context.select {
return select(entries, context);
}
default_messages(entries)
}
fn default_messages(entries: &[TapeEntry]) -> Vec<Value> {
entries
.iter()
.filter(|e| e.kind == TapeEntryKind::Message && e.payload.is_object())
.map(|e| normalize_message_tool_calls(&e.payload))
.collect()
}
const MAX_TOTAL_CONTEXT_CHARS: usize = 400_000;
const MAX_TOTAL_CONTEXT_CHARS_CJK: usize = 200_000;
const CJK_RATIO_THRESHOLD: f64 = 0.30;
const AGGRESSIVE_TRIM_KEEP_ROUNDS: usize = 2;
fn compute_char_threshold(messages: &[Value], context_window: Option<usize>) -> usize {
let is_cjk_heavy = cjk_content_ratio(messages) > CJK_RATIO_THRESHOLD;
match context_window {
Some(cw) => {
if is_cjk_heavy {
(cw as f64 * 1.5) as usize
} else {
cw * 4
}
}
None => {
if is_cjk_heavy {
MAX_TOTAL_CONTEXT_CHARS_CJK
} else {
MAX_TOTAL_CONTEXT_CHARS
}
}
}
}
pub fn apply_context_budget(messages: &mut Vec<Value>, context_window: Option<usize>) {
let total_chars: usize = messages.iter().map(content_char_count).sum();
let threshold = compute_char_threshold(messages, context_window);
if total_chars > threshold {
let before_count = messages.len();
aggressive_trim(messages);
let dropped = before_count.saturating_sub(messages.len());
if dropped > 0 {
tracing::warn!(
dropped_messages = dropped,
total_chars = total_chars,
threshold = threshold,
"context budget exceeded, trimmed conversation history"
);
}
}
}
fn cjk_content_ratio(messages: &[Value]) -> f64 {
let mut total = 0usize;
let mut cjk = 0usize;
for msg in messages {
if let Some(text) = msg.get("content").and_then(|c| c.as_str()) {
for c in text.chars() {
total += 1;
if is_cjk(c) {
cjk += 1;
}
}
}
}
if total == 0 {
0.0
} else {
cjk as f64 / total as f64
}
}
fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0x20000..=0x2A6DF | 0x3000..=0x303F | 0xFF00..=0xFFEF | 0x3040..=0x309F | 0x30A0..=0x30FF | 0xAC00..=0xD7AF )
}
fn msg_role(msg: &Value) -> &str {
msg.get("role").and_then(|r| r.as_str()).unwrap_or("")
}
fn content_char_count(msg: &Value) -> usize {
msg.get("content")
.and_then(|c| c.as_str())
.map_or(0, str::len)
}
fn find_trim_boundary(msgs: &[Value], rounds: usize) -> usize {
let mut seen = 0;
for (i, m) in msgs.iter().enumerate().rev() {
if msg_role(m) == "user" {
seen += 1;
if seen >= rounds {
return i;
}
}
}
0
}
const TRIM_NOTICE: &str = "[Earlier tool interactions trimmed to fit context window. Use tape.search to review full history.]";
fn aggressive_trim(messages: &mut Vec<Value>) {
let (system, conversation): (Vec<_>, Vec<_>) =
messages.drain(..).partition(|m| msg_role(m) == "system");
let keep_from = find_trim_boundary(&conversation, AGGRESSIVE_TRIM_KEEP_ROUNDS);
let mut recent: Vec<Value> = conversation.into_iter().skip(keep_from).collect();
messages.extend(system);
if keep_from > 0 {
inject_trim_notice(&mut recent, messages);
}
messages.extend(recent);
}
fn inject_trim_notice(recent: &mut [Value], before: &mut Vec<Value>) {
if msg_role(&recent[0]) == "assistant" {
let existing = recent[0]
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("");
recent[0]["content"] = Value::String(format!("{TRIM_NOTICE}\n\n{existing}"));
} else {
before.push(serde_json::json!({"role": "assistant", "content": TRIM_NOTICE}));
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn msg(role: &str, text: &str) -> Value {
json!({"role": role, "content": text})
}
fn assert_no_consecutive_roles(messages: &[Value]) {
for w in messages.windows(2) {
let (a, b) = (msg_role(&w[0]), msg_role(&w[1]));
assert!(
a != b || a == "system",
"consecutive '{a}': {:?} / {:?}",
w[0],
w[1]
);
}
}
#[test]
fn inject_trim_notice_prepends_to_leading_assistant() {
let mut recent = vec![msg("assistant", "hello"), msg("user", "q")];
let mut before = vec![msg("system", "sys")];
inject_trim_notice(&mut recent, &mut before);
assert_eq!(before.len(), 1); let content = recent[0]["content"].as_str().unwrap();
assert!(content.starts_with(TRIM_NOTICE));
assert!(content.contains("hello"));
}
#[test]
fn inject_trim_notice_adds_message_before_user() {
let mut recent = vec![msg("user", "q"), msg("assistant", "a")];
let mut before = vec![msg("system", "sys")];
inject_trim_notice(&mut recent, &mut before);
assert_eq!(before.len(), 2);
assert_eq!(msg_role(&before[1]), "assistant");
assert!(before[1]["content"].as_str().unwrap().contains("trimmed"));
}
#[test]
fn aggressive_trim_injects_notice_before_user() {
let mut msgs = vec![msg("system", "sys")];
for i in 0..4 {
msgs.push(msg("user", &format!("q{i}")));
msgs.push(msg("assistant", &format!("a{i}")));
}
aggressive_trim(&mut msgs);
assert_no_consecutive_roles(&msgs);
assert_eq!(msg_role(&msgs[1]), "assistant");
assert!(msgs[1]["content"].as_str().unwrap().contains("trimmed"));
assert_eq!(msg_role(&msgs[2]), "user");
}
}