feat(loop): add deterministic context compaction to Controller and Kai thread

This commit is contained in:
2026-10-11 10:19:36 -07:00
parent 5d8652d06d
commit 903ba0c1de
3 changed files with 108 additions and 0 deletions
+66
View File
@@ -622,6 +622,64 @@ impl Thread {
}
}
/// Compacts transcript history, pruning stale tool outputs while keeping error diagnostics verbatim.
pub async fn compact_messages(
self: &Arc<Self>,
_turn: &str,
mut messages: Vec<Value>,
) -> Option<Vec<Value>> {
if messages.len() <= 4 {
return None;
}
let cutoff = messages.len().saturating_sub(4);
let mut changed = false;
for (i, msg) in messages.iter_mut().enumerate() {
if i > 0 && i < cutoff {
if let Some(role) = msg.get("role").and_then(|r| r.as_str()) {
if role == "tool" {
if let Some(content) = msg.get("content").and_then(|c| c.as_str()) {
if content.len() > 1000 {
let error_lines: Vec<&str> = content
.lines()
.filter(|l| {
let low = l.to_lowercase();
low.contains("error:")
|| low.contains("failed")
|| low.contains("panic")
|| low.contains("exception")
|| low.contains("cannot find")
})
.take(10)
.collect();
let compacted = if !error_lines.is_empty() {
format!(
"[Diagnostics retained verbatim]:\n{}\n[... {} chars omitted ...]",
error_lines.join("\n"),
content.len()
)
} else {
let head_len = content.char_indices().nth(300).map_or(content.len(), |(i, _)| i);
format!(
"{}\n[... {} chars omitted ...]",
&content[..head_len],
content.len()
)
};
msg["content"] = Value::String(compacted);
changed = true;
}
}
}
}
}
}
if changed {
Some(messages)
} else {
None
}
}
fn turn_started(&self, turn: &str, start: [i64; 4]) {
let mut s = self.state();
s.turn = turn.to_string();
@@ -744,6 +802,14 @@ impl Controller for Shared {
) -> Answer<'a, BTreeMap<String, String>> {
Box::pin(self.0.route(turn, request))
}
fn compact<'a>(
&'a self,
turn: &'a str,
messages: Vec<Value>,
) -> Answer<'a, Option<Vec<Value>>> {
Box::pin(self.0.compact_messages(turn, messages))
}
}
/// The contributors that feed the controller what the loop does.
+22
View File
@@ -559,3 +559,25 @@ async fn a_server_that_answers_without_distributions_is_kai_not_answering() {
std::thread::sleep(std::time::Duration::from_millis(200));
assert!(!home.path().join("kai/decisions.jsonl").exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn compact_messages_preserves_error_diagnostics_verbatim() {
let (_home, _server, kai) = kai("", risky).await;
let (thread, _) = thread(&kai);
let long_tool_output = format!("info: line 1\nerror: undefined symbol foo\ninfo: line 3\n{}", "a".repeat(1500));
let msgs = vec![
serde_json::json!({"role": "user", "content": "run task"}),
serde_json::json!({"role": "tool", "content": long_tool_output}),
serde_json::json!({"role": "assistant", "content": "checking"}),
serde_json::json!({"role": "tool", "content": "ok"}),
serde_json::json!({"role": "assistant", "content": "next action"}),
serde_json::json!({"role": "tool", "content": "status done"}),
serde_json::json!({"role": "user", "content": "finalize"}),
];
let compacted = thread.compact_messages("t1", msgs).await.expect("compacted");
let tool_msg = &compacted[1];
let content = tool_msg["content"].as_str().unwrap();
assert!(content.contains("[Diagnostics retained verbatim]:"));
assert!(content.contains("error: undefined symbol foo"));
assert!(content.contains("chars omitted"));
}
+20
View File
@@ -74,6 +74,15 @@ pub trait Controller: Send + Sync {
/// Routing hints for `turn`'s model requests.
fn route<'a>(&'a self, turn: &'a str, request: Request)
-> Answer<'a, BTreeMap<String, String>>;
/// Compact transcript history, pruning stale tool outputs while preserving error diagnostics verbatim.
fn compact<'a>(
&'a self,
_turn: &'a str,
_messages: Vec<serde_json::Value>,
) -> Answer<'a, Option<Vec<serde_json::Value>>> {
Box::pin(async { None })
}
}
/// The controller installed on a thread.
@@ -102,6 +111,10 @@ impl Handle {
pub async fn route(&self, turn: &str, request: Request) -> BTreeMap<String, String> {
self.0.route(turn, request).await
}
pub async fn compact(&self, turn: &str, messages: Vec<serde_json::Value>) -> Option<Vec<serde_json::Value>> {
self.0.compact(turn, messages).await
}
}
/// A step's shortlist: the tools that stay in direct view.
@@ -155,4 +168,11 @@ mod tests {
assert_eq!(verdict, policy);
}
}
#[tokio::test]
async fn a_controller_default_compact_returns_none() {
let handle = Handle(Arc::new(Loose));
let res = handle.compact("t", vec![]).await;
assert_eq!(res, None);
}
}