huskies: merge 1186 story compact chat command: distill session context deterministically, then reset with a seed

This commit is contained in:
Huskies Agent
2026-07-17 12:05:04 +00:00
parent b241661941
commit 043c77f077
29 changed files with 1051 additions and 6 deletions
@@ -120,6 +120,16 @@ pub struct BotContext {
/// Optional model override from bot.toml. Passed as `--model` to the
/// `claude` CLI when set.
pub model: Option<String>,
/// Maximum size in bytes of the digest the `compact` command writes as a
/// seed file. From `bot.toml`'s `compact_seed_max_bytes`.
pub compact_seed_max_bytes: usize,
/// `cache_read_input_tokens` threshold above which the bot suggests
/// running `compact` after a turn. From `bot.toml`'s
/// `cache_read_suggest_threshold`.
pub cache_read_suggest_threshold: u64,
/// Minimum seconds between repeated `compact` suggestions for the same
/// room. From `bot.toml`'s `compact_suggest_cooldown_secs`.
pub compact_suggest_cooldown_secs: i64,
}
impl BotContext {
@@ -343,6 +353,9 @@ mod tests {
gateway_port: None,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
model: None,
compact_seed_max_bytes: 8_000,
cache_read_suggest_threshold: 50_000,
compact_suggest_cooldown_secs: 3_600,
}
}
@@ -36,6 +36,17 @@ pub struct RoomConversation {
pub session_id: Option<String>,
/// Rolling conversation entries (used for turn counting and persistence).
pub entries: Vec<ConversationEntry>,
/// A distilled digest produced by the `compact` command, waiting to be
/// injected as background context into the next spawned session's
/// prompt. Cleared immediately after the first turn that uses it, so it
/// is never re-injected.
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_seed: Option<String>,
/// Timestamp (ms since Unix epoch) of the last time this room was sent a
/// "consider running `compact`" suggestion, used to rate-limit repeat
/// suggestions.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_compact_suggested_at_ms: Option<i64>,
}
/// Per-room conversation state, keyed by room ID (serialised as string).
@@ -31,6 +31,23 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
};
// Drain any pending `compact` seed for this room so it is injected into
// the prompt exactly once, then persist the cleared state immediately —
// a crash mid-turn must not cause it to be re-injected on the next try.
let pending_seed: Option<String> = {
let mut guard = ctx.history.lock().await;
let conv = guard.entry(room_id.clone()).or_default();
let seed = conv.pending_seed.take();
if seed.is_some() {
save_history(&ctx.services.project_root, &guard);
}
seed
};
let seed_prefix = pending_seed
.as_deref()
.map(crate::chat::compact::frame_seed_for_prompt)
.unwrap_or_default();
// Pull new pipeline-transition events from the CRDT event log for this
// persona and atomically advance the high-water marks so the same events
// are not re-injected on the next turn. All transports share the same
@@ -49,7 +66,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
String::new()
};
let prompt = format!(
"{event_log_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
"{event_log_ctx}{seed_prefix}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
format_user_prompt(&sender, &user_message)
);
@@ -127,10 +144,11 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
let remaining = buffer.lock().unwrap().trim().to_string();
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
let (assistant_reply, new_session_id) = match result {
let (assistant_reply, new_session_id, turn_usage) = match result {
Ok(ClaudeCodeResult {
messages,
session_id,
usage,
}) => {
let reply = if !remaining.is_empty() {
let _ = msg_tx.send(remaining.clone());
@@ -153,7 +171,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
remaining
};
slog!("[matrix-bot] session_id from chat_stream: {:?}", session_id);
(reply, session_id)
(reply, session_id, usage)
}
Err(e) => {
slog!("[matrix-bot] LLM error: {e}");
@@ -163,7 +181,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
format!("Error processing your request: {e}")
};
let _ = msg_tx.send(err_msg.clone());
(err_msg, None)
(err_msg, None, None)
}
};
@@ -174,9 +192,10 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
// Record this exchange in the per-room conversation history and persist
// the session ID so the next turn resumes with structured API messages.
let mut compact_suggestion: Option<String> = None;
if !assistant_reply.starts_with("Error processing") {
let mut guard = ctx.history.lock().await;
let conv = guard.entry(room_id).or_default();
let conv = guard.entry(room_id.clone()).or_default();
// Store the session ID so the next turn uses --resume.
slog!(
@@ -208,6 +227,27 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
conv.entries.drain(..excess);
}
// When this turn's cache_read usage crosses the configured threshold,
// queue a rate-limited suggestion to run `compact`. Rate-limited via
// `last_compact_suggested_at_ms` so a busy room isn't nagged every turn.
if let Some(usage) = &turn_usage
&& usage.cache_read_input_tokens > ctx.cache_read_suggest_threshold
{
let now_ms = chrono::Utc::now().timestamp_millis();
let cooldown_ms = ctx.compact_suggest_cooldown_secs.saturating_mul(1_000);
let due = conv
.last_compact_suggested_at_ms
.is_none_or(|last| now_ms - last >= cooldown_ms);
if due {
conv.last_compact_suggested_at_ms = Some(now_ms);
compact_suggestion = Some(format!(
"This turn read {} cache tokens. Consider running `compact` to distill the \
session and reduce context size.",
usage.cache_read_input_tokens
));
}
}
// Persist to disk so history survives server restarts.
save_history(&ctx.services.project_root, &guard);
} else {
@@ -222,6 +262,14 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
save_history(&ctx.services.project_root, &guard);
}
}
if let Some(suggestion) = compact_suggestion {
let html = markdown_to_html(&suggestion);
let _ = ctx
.transport
.send_message(&room_id_str, &suggestion, &html)
.await;
}
}
// ---------------------------------------------------------------------------
@@ -1023,6 +1023,36 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
return;
}
// Check for the compact command, which requires async access to the
// shared conversation history and cannot be handled by the sync command
// registry.
if super::super::super::compact::extract_compact_command(
&user_message,
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
)
.is_some()
{
slog!("[matrix-bot] Handling compact command from {sender}");
let response = super::super::super::compact::handle_compact(
&incoming_room_id,
&ctx.history,
&ctx.services.project_root,
ctx.compact_seed_max_bytes,
)
.await;
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
return;
}
// In gateway mode, intercept "rebuild gateway" and route it through the
// detached trampoline so the process swap survives any bash-tool kill cascade.
if ctx.gateway_active_project.is_some()
@@ -334,6 +334,9 @@ pub async fn run_bot(
gateway_port,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
model: config.model.clone(),
compact_seed_max_bytes: config.compact_seed_max_bytes,
cache_read_suggest_threshold: config.cache_read_suggest_threshold,
compact_suggest_cooldown_secs: config.compact_suggest_cooldown_secs,
};
slog!(