klieo-core 3.4.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::{summarize_history, SummarizeOptions};

/// 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.
/// - 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>,
) -> 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(),
        }));
    }

    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 `[summary,
/// ...last `opts.keep_recent_messages` verbatim]`. The verbatim tail is
/// computed from a fresh load using the same split formula
/// [`summarize_history`] used internally — 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(), opts.trigger_token_budget.saturating_mul(2))
        .await?;
    let split = history.len().saturating_sub(opts.keep_recent_messages);
    let tail = &history[split..];

    let mut rewritten = Vec::with_capacity(tail.len() + 1);
    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;