klieo-core 3.15.0

Core traits + runtime for the klieo agent framework.
Documentation
//! Auto-wires [`summarize_history`] into the runtime step loop (Item C).
//!
//! [`summarize_history`] existed as a caller-invoked helper that never
//! mutated short-term memory — callers had to remember to fold its
//! result into a future `system_prompt` themselves. [`maybe_compact`]
//! closes that gap: called once per step, before the request is built,
//! it summarizes the older portion of the thread's history (once
//! [`RunOptions::compaction`](super::RunOptions::compaction)'s
//! `trigger_token_budget` is crossed) and rewrites short-term memory to
//! `[summary message, ...last `keep_recent_messages` verbatim]`.
//!
//! # Provenance (CRUCIBLE-CRITICAL)
//!
//! Compaction only ever shortens the *working* short-term context that
//! gets fed to the next LLM call. It never touches `EpisodicMemory`:
//!
//! - Every message that ever reaches short-term memory on the main
//!   agent path is first recorded to the audit trail by
//!   `record_and_dispatch` (`Episode::LlmCall` /
//!   `Episode::ToolCall`), *before* that same call site appends the
//!   message to short-term memory. Episodic recording happens upstream
//!   of, and independently from, whatever short-term truncation
//!   [`maybe_compact`] performs on a later step.
//! - [`crate::memory::EpisodicMemory`] exposes only `record` and
//!   `replay` — no delete or mutate method exists on the trait, so
//!   nothing downstream of it (including this module) can alter or
//!   remove a previously recorded episode. The audit trail is
//!   structurally append-only.
//! - The summarizer LLM call itself is not an unaudited side channel:
//!   [`summarize_history`] records an `Episode::SummaryCheckpoint` for
//!   every summarization it performs, so the compaction step is
//!   audited the same as any other LLM call.
//!
//! Net effect: short-term memory is lossy by design (that's the whole
//! point of compaction), but the episodic run history is not — it
//! always holds one entry per real step, regardless of how many times
//! [`maybe_compact`] rewrites the working context.

use crate::agent::AgentContext;
use crate::error::{ConfigError, Error};
use crate::ids::ThreadId;
use crate::llm::{Message, Role};
use crate::summarize::{
    summarisable_range, summarize_history, visible_window_tokens, SummarizeOptions,
    WINDOW_MULTIPLE_OF_TRIGGER,
};

/// Prefix marking a short-term message as a compaction summary rather
/// than a verbatim turn, so a human skimming short-term content can
/// tell the two apart.
const SUMMARY_MESSAGE_PREFIX: &str = "[compacted summary of earlier turns]\n";

/// Run one auto-compaction check for `thread`. Called once per step,
/// before the request is built, by both the blocking and streaming
/// drivers.
///
/// - `compaction: None` (the compliance carve-out, set via
///   [`super::RunOptions::without_compaction`]) is a no-op.
/// - `Some(opts)` with `opts.trigger_token_budget == 0` is rejected
///   with `Error::Config` — a zero budget would make
///   [`summarize_history`]'s trigger check vacuous (`tokens < 0` is
///   never true), so every step would re-summarize the same content
///   instead of never triggering. This mirrors klieo's existing
///   reject-zero-duration convention for other budget/interval
///   configs rather than allowing that runaway behaviour.
/// - `Some(opts)` whose window
///   ([`visible_window_tokens`], i.e. `trigger_token_budget` ×
///   [`WINDOW_MULTIPLE_OF_TRIGGER`]) is below `max_history_tokens` is
///   also rejected with `Error::Config`. That pair lets the request and
///   resume paths restore a thread larger than [`summarize_history`]
///   can load, so its oldest messages would never be summarized — and
///   [`rewrite_short_term_with_summary`] would then clear them anyway.
///   Rejecting the pair makes that unreachable by configuration rather
///   than merely documented. Direct [`summarize_history`] callers are
///   not covered: choosing your own budget is choosing your own
///   coverage.
/// - Otherwise, delegates to [`summarize_history`]; when it returns a
///   summary, rewrites short-term memory to keep the working context
///   bounded.
pub(crate) async fn maybe_compact(
    ctx: &AgentContext,
    thread: &ThreadId,
    compaction: Option<&SummarizeOptions>,
    max_history_tokens: usize,
) -> Result<(), Error> {
    let Some(opts) = compaction else {
        return Ok(());
    };
    if opts.trigger_token_budget == 0 {
        return Err(Error::Config(ConfigError::InvalidValue {
            key: "compaction.trigger_token_budget".into(),
            reason: "must be non-zero when compaction is enabled; disable via \
                     RunOptions::without_compaction() instead of setting a zero budget"
                .into(),
        }));
    }
    let window = visible_window_tokens(opts);
    if window < max_history_tokens {
        return Err(Error::Config(ConfigError::InvalidValue {
            key: "compaction.trigger_token_budget".into(),
            reason: format!(
                "trigger_token_budget * {WINDOW_MULTIPLE_OF_TRIGGER} = {window} is below \
                 max_history_tokens = {max_history_tokens}, so a thread the request and resume \
                 paths can restore would exceed the window summarize_history can see; history \
                 past the window is never summarised and would then be discarded by compaction. \
                 Raise compaction.trigger_token_budget to at least \
                 {min_trigger}, or lower max_history_tokens via \
                 RunOptions::with_max_history_tokens()",
                min_trigger = max_history_tokens.div_ceil(WINDOW_MULTIPLE_OF_TRIGGER),
            ),
        }));
    }

    match summarize_history(ctx, thread.clone(), opts.clone()).await? {
        Some(summary) => rewrite_short_term_with_summary(ctx, thread, opts, summary).await,
        None => Ok(()),
    }
}

/// Replace `thread`'s short-term history with
/// `[...leading brief, summary, ...last `opts.keep_recent_messages` verbatim]`.
///
/// The brief at the head survives because [`summarize_history`] never handed
/// it to the summarizer — see [`crate::summarize::KEEP_LEADING_MESSAGES`].
/// Both ends use [`summarisable_range`] so the set this rewrite keeps and the
/// set the summary covers cannot drift apart.
///
/// The verbatim tail is computed from a fresh load — safe because nothing
/// writes to `thread` between that call and this one on the same step.
async fn rewrite_short_term_with_summary(
    ctx: &AgentContext,
    thread: &ThreadId,
    opts: &SummarizeOptions,
    summary: String,
) -> Result<(), Error> {
    let history = ctx
        .short_term
        .load(thread.clone(), visible_window_tokens(opts))
        .await?;
    let (lead, split) = summarisable_range(history.len(), opts);
    let brief = &history[..lead];
    let tail = &history[split..];

    tracing::warn!(
        thread = %thread,
        summarised_messages = split - lead,
        kept_leading = lead,
        kept_recent = tail.len(),
        "compacted thread history; turns between the brief and the recent tail are now a summary"
    );

    let mut rewritten = Vec::with_capacity(brief.len() + tail.len() + 1);
    rewritten.extend_from_slice(brief);
    rewritten.push(Message {
        role: Role::System,
        content: format!("{SUMMARY_MESSAGE_PREFIX}{summary}"),
        tool_calls: vec![],
        tool_call_id: None,
    });
    rewritten.extend_from_slice(tail);

    ctx.short_term.clear(thread.clone()).await?;
    ctx.short_term
        .append_batch(thread.clone(), rewritten)
        .await?;
    Ok(())
}

#[cfg(test)]
#[path = "compaction_tests.rs"]
mod tests;