docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
@@ -1,7 +1,6 @@
//! Compacted-history assembly (kigi's rebuild structure, generic).
//!
//! Moved from `kigi-chat-state::compaction_utils::build_compacted_history` and
//! made generic over a write-side item factory so any harness can assemble
//! Generic over a write-side item factory so any harness can assemble
//! the canonical post-compaction history:
//!
//! ```text
@@ -25,7 +24,6 @@ use super::summary::{format_compact_summary_content, wrap_user_query};
/// - Providing the `user_message_prefix` (e.g. `<user_info>` block).
/// - Extracting `last_user_query` / `recent_messages` from its own state.
pub struct CompactedHistoryParts<T> {
/// The original system message from the conversation.
pub system_message: T,
/// The user-info / project-layout prefix (not wrapped in `<user_query>`).
pub user_message_prefix: String,
@@ -36,7 +34,6 @@ pub struct CompactedHistoryParts<T> {
pub last_user_query: Option<String>,
/// Messages retained verbatim from after the last real user turn.
pub recent_messages: Vec<T>,
/// The LLM-generated compaction summary text.
pub compaction_summary: String,
/// An optional pre-rendered `<system-reminder>` block to append after the
/// summary. `None` means no state reminder is appended.
@@ -75,7 +72,6 @@ pub fn assemble_compacted_history<T: CompactionItemFactory>(
compacted.push(T::new_project_instructions(reminder.clone()));
}
// Last user query wrapped in <user_query> tags for consistency.
if let Some(ref last_query) = parts.last_user_query {
compacted.push(T::new_user(wrap_user_query(last_query.as_str())));
}
@@ -88,7 +84,6 @@ pub fn assemble_compacted_history<T: CompactionItemFactory>(
}
let summary_item = T::new_user_meta(formatted_summary);
// Recent messages come first, then the summary.
for msg in parts.recent_messages {
compacted.push(msg);
}
@@ -8,12 +8,12 @@
//! build prompt → sample (retry + classify) → clean → assemble
//! ```
//!
//! Per-harness concerns stay in the product host (for example `kigi-shell`): the triggers, the
//! conversation *gathering / sanitization* that produces `llm_turns`, the
//! verbatim→fitted→lossy input ladder, the live LLM transport (the
//! [`CompactionSampler`] impl), persistence/replay, and the rendering of
//! `system_reminder`. This function takes those as inputs and returns the
//! rebuilt history; it never commits or persists.
//! Per-harness concerns stay in the product host (for example `kigi-shell`):
//! the triggers, the conversation gathering/sanitization that produces
//! `llm_turns`, the verbatim→fitted→lossy input ladder, the live LLM transport
//! (the [`CompactionSampler`] impl), persistence/replay, and the rendering of
//! `system_reminder`. This module takes those as inputs and returns the rebuilt
//! history; it never commits or persists.
use std::time::{Duration, Instant};
@@ -30,37 +30,33 @@ use super::prompt::build_summary_prompt;
use super::sample::{SampleRetryError, SampledSummary, sample_summary_with_retries};
/// Everything the assembler needs that the harness extracts from its own
/// state (separate from the conversation that gets summarized).
/// state (separate from the conversation that gets summarized). Every field is
/// carried through verbatim; nothing here is re-derived.
pub struct FullReplaceContext<T> {
/// The original system message, carried over verbatim.
pub system_message: T,
/// The user-info / project-layout prefix (no `<user_query>` tags).
pub user_message_prefix: String,
/// Pre-rendered AGENTS.md block to re-inject, if any.
pub agents_md_reminder: Option<String>,
/// The last real user query (raw), kept verbatim post-compaction.
/// Raw, not wrapped in `<user_query>` tags yet.
pub last_user_query: Option<String>,
/// Working tail retained verbatim (tool/subagent results from the current
/// turn). kigi keeps this; pass empty to drop it.
/// Working tail (tool/subagent results from the current turn). Pass empty
/// to drop it.
pub recent_messages: Vec<T>,
/// Pre-rendered `<system-reminder>` (edited files, running tasks,
/// subagents, MCP, …). The harness builds this; we only carry it.
/// subagents, MCP, …).
pub system_reminder: Option<String>,
/// Optional transcript-pointer block appended to the summary.
/// Transcript-pointer block appended to the summary.
pub transcript_hint: Option<String>,
}
/// Outcome of a failed full-replace pass.
#[derive(Debug)]
pub enum FullReplaceError {
/// No turns were supplied to summarize.
NothingToCompact,
/// The model returned no usable summary text after all attempts.
EmptyResponse,
/// The sampler failed deterministically (re-sending can't help), or all
/// transient retries were exhausted.
Sampler {
/// The rendered upstream error.
message: String,
/// Whether re-sending the *same* input cannot help. The product host
/// uses this to decide whether to suppress auto-compaction.
@@ -84,46 +80,40 @@ impl std::fmt::Display for FullReplaceError {
impl std::error::Error for FullReplaceError {}
/// A successful full-replace pass.
pub struct FullReplaceOutput<T> {
/// The rebuilt, compacted history (`[SP, UP', AGENTS_MD?, UQ_last?,
/// recent…, summary, reminder?]`).
/// Shape: `[SP, UP', AGENTS_MD?, UQ_last?, recent…, summary, reminder?]`.
pub history: Vec<T>,
/// The **raw** model summary (pre-clean), so the product host can persist
/// it (request artifact, compaction segment) exactly as the model emitted
/// it. The cleaned form is already embedded in `history` by the assembler.
pub summary: String,
/// Total sample attempts made (first try + retries).
/// First try + retries.
pub attempts: u32,
}
/// A successful full-replace **sampling** pass (summary only, no assembly).
///
/// Returned by [`sample_full_replace_summary`] for harnesses (kigi's
/// shell) that drive the input ladder and assemble the history themselves —
/// they build the assembly inputs (state-context system-reminder, AGENTS.md,
/// plan-mode) *after* the LLM call, so they cannot use the bundled
/// Summary-only result of [`sample_full_replace_summary`], for harnesses
/// (kigi's shell) that drive the input ladder and assemble the history
/// themselves — they build the assembly inputs (state-context system-reminder,
/// AGENTS.md, plan-mode) *after* the LLM call, so they cannot use the bundled
/// [`apply_full_replace_compaction`].
pub struct FullReplaceSummary {
/// The **raw** model summary (pre-clean).
pub summary: String,
/// Total sample attempts made (first try + retries).
/// First try + retries.
pub attempts: u32,
}
/// Run kigi's full-replace compaction pass and return the rebuilt
/// history. Pure orchestration: no triggers, no persistence, no commit.
///
/// - `llm_turns` — the (harness-prepared/sanitized) conversation the model
/// summarizes. Empty ⇒ [`FullReplaceError::NothingToCompact`].
/// - `user_context` — optional `/compact <text>` context spliced into the prompt.
/// - `ctx` — the assembly inputs the harness extracted from its state.
/// - `observer` — per-attempt + terminal telemetry seam (pass `&()` for none).
/// Empty `llm_turns` ⇒ [`FullReplaceError::NothingToCompact`]. `user_context`
/// is the optional `/compact <text>` context spliced into the prompt; pass
/// `&()` as `observer` for no telemetry.
///
/// The **input ladder** (verbatim → fitted → lossy) stays in the product host: on a
/// context-length overflow this returns
/// [`FullReplaceError::Sampler`] with `context_overflow = true`, and the
/// harness rebuilds a smaller input and calls this pass again.
/// The **input ladder** (verbatim → fitted → lossy) stays in the product host:
/// on a context-length overflow this returns [`FullReplaceError::Sampler`] with
/// `context_overflow = true`, and the harness rebuilds a smaller input and
/// calls this pass again.
pub async fn apply_full_replace_compaction<T, S, O>(
sampler: &S,
llm_turns: &[T],
@@ -147,9 +137,8 @@ where
"[FullReplaceCompaction] sampled summary; assembling history"
);
// Clean (inside the assembler via `format_compact_summary_content`) and
// rebuild the compacted history. `compaction_summary` is the raw model
// output; the assembler strips scratchpad / control tokens.
// `compaction_summary` goes in raw; the assembler strips scratchpad /
// control tokens via `format_compact_summary_content`.
let parts = CompactedHistoryParts {
system_message: ctx.system_message,
user_message_prefix: ctx.user_message_prefix,
@@ -170,12 +159,8 @@ where
/// Run only the **sampling** half of the full-replace pass: build the prompt,
/// sample with bounded retries (transient + degenerate), classify failures,
/// and report every attempt through `observer`. Returns the raw summary; the
/// caller assembles the history (and owns the input ladder).
///
/// This is the seam kigi's shell uses: it drives the verbatim → fitted →
/// lossy input ladder around this call (stepping on a
/// [`FullReplaceError::Sampler`] with `context_overflow = true`) and assembles
/// the compacted history afterward from inputs it gathers post-sampling.
/// caller assembles the history and owns the input ladder, stepping it on a
/// [`FullReplaceError::Sampler`] with `context_overflow = true`.
pub async fn sample_full_replace_summary<T, S, O>(
sampler: &S,
llm_turns: &[T],
@@ -365,10 +350,7 @@ mod tests {
)
}
/// Golden end-to-end test: a realistic conversation + a mock sampler that
/// returns a structured summary must produce kigi's exact compacted
/// history shape, with the LLM output cleaned and the agent-state reminder
/// carried through as the final item.
/// Golden test pinning kigi's exact compacted history shape end to end.
#[tokio::test]
async fn full_replace_produces_kigi_history_shape() {
let llm_turns = vec![
@@ -408,7 +390,7 @@ mod tests {
MockItem::Tail("tool: read_file(auth.rs) -> ...".into())
);
// Summary carrier: cleaned (no <analysis>/<summary> tags), with preamble.
// Summary carrier: cleaned, with preamble.
let MockItem::UserMeta(summary) = &out[5] else {
panic!("expected UserMeta summary at [5], got {:?}", out[5]);
};
@@ -421,7 +403,6 @@ mod tests {
assert!(!summary.contains("<summary>"), "live tag leaked: {summary}");
assert!(!summary.contains("thinking about it"));
// Agent-state reminder carried through verbatim as the final item.
assert_eq!(
out[6],
MockItem::SystemReminder(
@@ -553,8 +534,6 @@ mod tests {
assert_eq!(sampler.call_count(), 1, "overflow must not retry");
}
/// The observer sees one terminal `on_success` and the right per-attempt
/// outcomes (a degenerate retry then a success).
#[tokio::test]
async fn observer_receives_attempt_and_success_callbacks() {
use std::sync::Mutex;
@@ -1,32 +1,26 @@
//! kigi compaction configuration.
//!
//! Holds the [`FullReplaceConfig`] tunables struct (mirroring
//! Tunables and defaults only; trigger *wiring* (pre-sampling checks,
//! preflight overflow, model-switch, suppression) stays per-host. Mirrors
//! [`IntraCompactionConfig`](crate::intra_compaction::IntraCompactionConfig) /
//! [`InterCompactionConfig`](crate::inter_compaction::InterCompactionConfig),
//! which also live in their module's `config.rs`) plus the shared default
//! values. Trigger *wiring* (pre-sampling checks, preflight overflow,
//! model-switch, suppression) stays per-host.
//! which also live in their module's `config.rs`.
/// Default auto-compact threshold (% of context window) when no other source
/// (env var, user config, remote per-model/global flags) sets it. Shared by
/// kigi and Kigi chat (~85% trigger on both sides).
/// Applies only when no other source (env var, user config, remote
/// per-model/global flags) sets it. Shared by kigi and Kigi chat.
pub const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT: u8 = 85;
/// Minimum character count for a cleaned summary seed.
///
/// kigi retries when the cleaned summary is shorter than this — the
/// smallest healthy prod summary observed was ~3,242 chars; anything under
/// 500 is treated as degenerate and retried like a transient failure.
/// A cleaned summary shorter than this is treated as degenerate and retried
/// like a transient failure. The smallest healthy prod summary observed was
/// ~3,242 chars, so 500 is a wide margin below anything legitimate.
pub const MIN_SUMMARY_SEED_CHARS: usize = 500;
/// Tunables for the full-replace pass.
#[derive(Debug, Clone)]
pub struct FullReplaceConfig {
/// Total LLM attempts (first try + retries) on transient failures.
/// First try + retries, counted together.
pub max_attempts: u32,
/// Delay between transient retries.
pub retry_delay_secs: u64,
/// End-to-end timeout for each compaction LLM call.
/// Applies per attempt, not to the whole retry loop.
pub sampling_timeout_secs: u64,
}
@@ -1,8 +1,8 @@
//! Deterministic-vs-transient failure classification for compaction
//! LLM calls.
//!
//! The *policy* lives here (shared across harnesses); the per-harness error
//! types and their wrapping (e.g. kigi's `SamplingError` →
//! The *policy* lives here, shared across harnesses; per-harness error types
//! and their wrapping (e.g. kigi's `SamplingError` →
//! `CompactFailure(acp::Error)`) stay in thin host wrappers that delegate the
//! status/message decisions to these functions.
@@ -17,15 +17,16 @@ pub enum FailureKind {
}
impl FailureKind {
/// `true` for [`FailureKind::Deterministic`].
pub fn is_deterministic(self) -> bool {
matches!(self, Self::Deterministic)
}
}
/// True when an error message indicates a context-window overflow. Backends report
/// this inconsistently with no stable error code, so we match the message text; it's
/// deterministic (re-sending the same payload always fails), so callers must not retry.
/// True when an error message indicates a context-window overflow.
///
/// Backends report this inconsistently with no stable error code, so the match
/// is on message text. Re-sending the same payload always fails, so callers
/// must not retry.
pub fn is_context_length_error(message: &str) -> bool {
let m = message.to_ascii_lowercase();
m.contains("too long for this model")
@@ -38,10 +39,8 @@ pub fn is_context_length_error(message: &str) -> bool {
/// Classify an HTTP API failure (status + message) for the compaction retry
/// loop.
///
/// 4xx responses other than 408 (timeout) and 429 (rate limit) are
/// deterministic; a context-length overflow message is deterministic
/// regardless of status (backends sometimes dress it as a synthesized 500).
/// Everything else (5xx, 408, 429) is transient.
/// A context-length overflow message is deterministic regardless of status,
/// because backends sometimes dress it as a synthesized 500.
pub fn classify_http_status(status: u16, message: &str) -> FailureKind {
if is_context_length_error(message)
|| ((400..500).contains(&status) && status != 408 && status != 429)
@@ -55,15 +54,11 @@ pub fn classify_http_status(status: u16, message: &str) -> FailureKind {
/// Classify a provider-style stream error event (`ResponseError` /
/// `ResponseFailed.error`) for the compaction retry loop.
///
/// `code` is the structured `code` field on the event (typically a numeric
/// HTTP status as a string, but some providers also use error-type strings like
/// `"invalid_request_error"`). `message` is the human-readable detail.
///
/// Numeric codes are classified by HTTP-status range. The
/// `invalid_request_error` marker, which can appear in either field, always
/// maps to `Deterministic` (schema violations cannot be fixed by re-sending
/// the same payload). The check order is semantic — marker, then numeric
/// code, then context-length message, then default-to-transient.
/// `code` is the structured `code` field on the event: typically a numeric HTTP
/// status as a string, but some providers send error-type strings like
/// `"invalid_request_error"` instead, and that marker can also arrive buried in
/// `message` alone. It always means a schema violation, which re-sending the
/// same payload cannot fix.
pub fn classify_stream_event_error(code: Option<&str>, message: &str) -> FailureKind {
if matches!(code, Some("invalid_request_error")) || message.contains("invalid_request_error") {
return FailureKind::Deterministic;
@@ -9,17 +9,6 @@
//! [`intra_compaction`](crate::intra_compaction) (tail-keep, per-step) and
//! [`inter_compaction`](crate::inter_compaction) (chunked, between-turn).
//!
//! Layout (mirroring
//! [`intra_compaction`](crate::intra_compaction) /
//! [`inter_compaction`](crate::inter_compaction)):
//!
//! - **Policy & content**: [`prompt`] (summarization prompt), [`summary`]
//! (summary cleaning + carrier), [`failure`] (deterministic-vs-transient
//! classification), [`config`] (tunables + trigger/seed defaults).
//! - **Algorithm**: [`assemble`] (full-replace history rebuild).
//! - **Orchestration**: [`compact`]
//! (`build prompt → sample → clean → assemble`).
//!
//! Host-specific concerns (triggers, transport, persistence/replay, state
//! commit, metrics observer) stay in the product host (for example
//! `kigi-shell`).
@@ -9,8 +9,7 @@
//! / [`InterCompactionObserver`](crate::inter_compaction::InterCompactionObserver).
//!
//! Emission points are part of the behavior contract: the kigi observer
//! preserves the pre-migration `CompactionAttempt`/`CompactionRetryDegraded`
//! semantics byte-for-byte.
//! preserves `CompactionAttempt` / `CompactionRetryDegraded` semantics.
use std::time::Duration;
@@ -34,14 +33,13 @@ pub enum FullReplaceAttemptOutcome<'a> {
/// The cleaned summary seed was too short to carry the conversation's task
/// state; retried like a transient failure.
Degenerate {
/// Raw model summary text (still captured for offline inspection).
/// Captured for offline inspection even when too short to keep.
summary: &'a str,
/// Whether the orchestrator will retry after this attempt.
will_retry: bool,
},
/// The sampler returned an error.
Failure {
/// Rendered error message.
message: &'a str,
/// Whether re-sending the *same* input cannot help (auth / schema /
/// size). Transient failures (timeout / stream blip / 5xx) are `false`.
@@ -4,8 +4,6 @@
//! full-replace pass ([`sample_full_replace_summary`](super::sample_full_replace_summary))
//! and Kigi chat's intra `Shared` summarizer
//! ([`apply_intra_compaction`](crate::intra_compaction::apply_intra_compaction)).
//! Centralising it here removes the two near-identical copies that previously
//! lived in `code_compaction::compact` and `intra_compaction::compact`.
//!
//! Classification is uniform:
//! - a usable, non-degenerate response wins immediately;
@@ -38,7 +36,7 @@ use super::summary::is_degenerate_summary;
/// plus the total number of attempts made (first try + retries).
#[derive(Debug)]
pub struct SampledSummary {
/// Raw model summary text, exactly as emitted. Callers clean it as needed.
/// Exactly as emitted; callers clean before use.
pub summary: String,
/// Total sample attempts made (1-based).
pub attempts: u32,
@@ -58,14 +56,12 @@ pub enum SampleRetryError {
/// input cannot help — auth / schema / context overflow), or transient but
/// retries were exhausted.
Failure {
/// Rendered upstream error message.
message: String,
/// Whether re-sending the same input cannot help.
deterministic: bool,
/// Whether the failure was a context-length overflow (a deterministic
/// signal the kigi host uses to step down its input size).
context_overflow: bool,
/// Total attempts made.
attempts: u32,
},
}
@@ -344,7 +340,7 @@ mod tests {
#[tokio::test]
async fn empty_then_degenerate_exhausts_to_empty() {
let short = "<summary>\n1. Primary Request: q\n</summary>"; // degenerate
let short = "<summary>\n1. Primary Request: q\n</summary>";
let sampler = MockSampler::scripted(vec![Ok(String::new()), Ok(short.into())]);
let err = run(&sampler, 2).await.expect_err("should fail");
assert!(matches!(err, SampleRetryError::Empty { attempts: 2 }));
@@ -1,7 +1,5 @@
//! Summary output cleaning and carrier formatting.
//!
//! Moved verbatim from `kigi-chat-state`'s `compaction_utils`. Covers:
//!
//! - cleaning the compaction model's raw output ([`format_compact_summary`]),
//! - the kigi continuation carrier ([`format_compact_summary_content`]),
//! - the canonical `<user_query>` wrapping ([`wrap_user_query`]).
@@ -19,15 +17,15 @@
pub fn format_compact_summary(summary: &str) -> String {
let mut result = summary.to_string();
// 1. Remove leading <analysis>…</analysis> drafting block(s). A block is
// only stripped when it is a genuinely LEADING scratchpad: top-level
// (before any <summary>) or immediately after the <summary> open modulo
// whitespace (nested). An <analysis> quoted mid-body — after real
// sections, e.g. a section-6 instruction echo — is NOT leading and is
// left for step 3 to neutralize, so neither a balanced body quote
// spanning sections nor an unclosed one ever deletes real content. The
// loop peels successive leading blocks should the model emit more than
// one.
// Strip leading <analysis>…</analysis> drafting blocks only when they are
// genuinely LEADING scratchpad: top-level (before any <summary>) or
// immediately after the <summary> open modulo whitespace (nested). An
// <analysis> quoted mid-body — after real sections, e.g. a section-6
// instruction echo — is NOT leading and is left for
// `neutralize_compaction_control_tokens` to defuse, so neither a balanced
// body quote spanning sections nor an unclosed one ever deletes real
// content. The loop peels successive leading blocks if the model emits more
// than one.
while let Some(start) = result.find("<analysis>") {
let is_leading = match result.find("<summary>") {
Some(sp) => start < sp || result[sp + "<summary>".len()..start].trim().is_empty(),
@@ -53,12 +51,13 @@ pub fn format_compact_summary(summary: &str) -> String {
}
}
// 2. Convert the outer <summary>…</summary> to "Summary:\n{inner}", keeping
// any text outside the wrapper. `rfind` matches the outer close, so a
// literal "</summary>" echoed in the body does not truncate the summary;
// `end > start` guards a malformed "</summary> … <summary>" order. Leading
// scratchpad inside the block is peeled (see `strip_leading_scratchpad`);
// a body echo that quotes the instruction is left for step 3 to defuse.
// Convert outer <summary>…</summary> to "Summary:\n{inner}", keeping text
// outside the wrapper. `rfind` matches the outer close so a literal
// "</summary>" echoed in the body does not truncate; `end > start` guards a
// malformed "</summary> … <summary>" order. Leading scratchpad inside the
// block is peeled (see `strip_leading_scratchpad`); a body echo that quotes
// the instruction is left for `neutralize_compaction_control_tokens` to
// defuse.
if let Some(start) = result.find("<summary>")
&& let Some(end) = result.rfind("</summary>")
&& end > start
@@ -69,8 +68,8 @@ pub fn format_compact_summary(summary: &str) -> String {
result = format!("{before}Summary:\n{inner}{after}");
}
// 3. Defuse any compaction-control tokens still echoed inside the body so the
// seed can't prime the next turn to re-emit a <summary> block.
// Defuse any compaction-control tokens still echoed inside the body so the
// seed can't prime the next turn to re-emit a <summary> block.
result = neutralize_compaction_control_tokens(&result);
// Collapse excessive blank lines (3+ newlines → 2)
@@ -84,13 +83,14 @@ pub fn format_compact_summary(summary: &str) -> String {
/// Peel leading drafting scratchpad off an extracted `<summary>` block.
///
/// A markdown "**Analysis**"-style header has no opening `<analysis>` tag for
/// step 1 to catch; it ends at an orphan `</analysis>`. Everything up to and
/// including the *last* `</analysis>` is dropped, so a scratchpad that itself
/// quotes `</analysis>` mid-reasoning is still removed whole. The peel is
/// skipped when the block already starts with a numbered section — including a
/// markdown-decorated one like `## 1.` or `**1.**` — so a `</analysis>` merely
/// echoed inside a real section never truncates the summary. Any leftover
/// leading `<summary>` wrapper is then unwrapped.
/// the leading-block peel in [`format_compact_summary`] to catch; it ends at an
/// orphan `</analysis>`. Everything up to and including the *last*
/// `</analysis>` is dropped, so a scratchpad that itself quotes `</analysis>`
/// mid-reasoning is still removed whole. The peel is skipped when the block
/// already starts with a numbered section — including a markdown-decorated one
/// like `## 1.` or `**1.**` — so a `</analysis>` merely echoed inside a real
/// section never truncates the summary. Any leftover leading `<summary>`
/// wrapper is then unwrapped.
fn strip_leading_scratchpad(inner: &str) -> String {
let mut s = inner.trim();
let lead = s.trim_start_matches(['#', '*', '-', '>', ' ', '\t']);
@@ -1,7 +1,7 @@
//! Item filtering and user-query extraction for history compaction —
//! generic over [`CompactionItem`] / [`CompactionItemBuilder`].
//!
//! Behavior is byte-for-byte identical for Kigi chat (`T = Arc<KigiTurn>`).
//! Behavior matches Kigi chat (`T = Arc<KigiTurn>`).
use tracing::info;
@@ -48,10 +48,9 @@ pub fn filter_turns_for_inter_compaction<T: CompactionItemBuilder>(turns: &[T])
turns
.iter()
.filter_map(|turn| match turn.role() {
// Drop tool and system items.
CompactionRole::Tool | CompactionRole::System => None,
// Keep prior compaction summaries; drop all other developer items.
// Keep prior compaction summaries; drop other developer items.
CompactionRole::Developer => {
if turn.is_compaction_summary() {
Some(turn.clone())
@@ -60,10 +59,8 @@ pub fn filter_turns_for_inter_compaction<T: CompactionItemBuilder>(turns: &[T])
}
}
// Keep user items.
CompactionRole::User => Some(turn.clone()),
// Filter assistant item contents.
CompactionRole::Assistant => turn.strip_tool_content(),
})
.collect()
@@ -152,7 +149,8 @@ pub fn truncate_middle(msg: &str, max_chars: usize) -> Option<String> {
return None;
}
let front_len = max_chars / 2;
let back_len = max_chars - front_len; // handles odd max_chars
// handles odd max_chars
let back_len = max_chars - front_len;
let front: String = msg.chars().take(front_len).collect();
let back: String = msg.chars().skip(char_count - back_len).collect();
Some(format!("{}...[truncated]...{}", front, back))
@@ -291,10 +289,8 @@ pub fn separate_prior_user_queries<T: CompactionItemBuilder>(
None => prior_user_queries = Some(user_sec),
}
}
// Matches inter's previous inline behavior (`if !rest.is_empty()`):
// a prior compaction item whose entire content was the
// `<kigi_user_queries>` block (and therefore stripped to an empty
// `rest`) contributes nothing for the LLM and is dropped here.
// Prior compaction whose content was only `<kigi_user_queries>`
// (empty `rest` after strip) contributes nothing for the LLM.
if !rest.is_empty() {
turns_for_llm.push(T::compaction_summary_item(rest));
}
@@ -1,5 +1,5 @@
//! Conversation history compaction — shared selection/assembly logic for compacting
//! prior conversation turns into a summary.
//! Shared selection/assembly logic for compacting prior conversation turns
//! into a summary.
//!
//! Everything here is generic over [`CompactionItem`](crate::CompactionItem)
//! / [`CompactionItemBuilder`](crate::CompactionItemBuilder) or pure
@@ -5,12 +5,10 @@
use anyhow::Result;
/// Builds the developer prompt to send to the compaction model.
pub fn format_compaction_developer_prompt() -> Result<String> {
Ok(include_str!("../templates/compaction_developer_prompt.txt").to_string())
}
/// Builds the user prompt to send to the compaction model.
pub fn format_compaction_user_prompt() -> Result<String> {
Ok(include_str!("../templates/compaction_user_prompt.txt").to_string())
}
@@ -27,9 +25,8 @@ mod tests {
assert!(!user.trim().is_empty(), "user prompt empty");
}
/// Belt-and-suspenders: the developer and user prompts are intentionally
/// identical so the model sees the instructions on both turns. If you edit
/// one, edit the other — this test catches drift.
/// The two templates are separate files that must stay byte-identical; this
/// catches drift when only one is edited.
#[test]
fn compaction_prompts_match() {
let dev = format_compaction_developer_prompt().expect("dev prompt renders");
@@ -2,11 +2,10 @@
use serde::{Deserialize, Serialize};
/// Strategy for how conversation compaction is performed.
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CompactionStrategy {
/// Send all turns to the LLM in one shot (original behaviour).
/// Send all turns to the LLM in one shot.
#[default]
Basic,
/// Divide turns into ≤ `dnc_chunk_token_limit` chunks, compact each,
@@ -44,12 +44,10 @@ pub fn validate_compaction_text(
text_content: &str,
strategy: &CompactionStrategy,
) -> Result<(), CompactionValidationError> {
// 1. Non-empty text content
if text_content.trim().is_empty() {
return Err(CompactionValidationError::EmptyContent);
}
// 2. DnC: validate chunk_summary tags are balanced
if matches!(strategy, CompactionStrategy::DivideAndConquer) {
let open_count = text_content.matches("<chunk_summary").count();
let close_count = text_content.matches("</chunk_summary>").count();
@@ -70,8 +70,7 @@ pub struct ChunkedCompactionOutput {
/// attachment refs). `conversation_id` / `response_id` are threaded
/// through for log correlation only.
///
/// Observer events (the Kigi chat observer maps them to the
/// pre-unification metrics):
/// Observer events:
/// - [`InterCompactionObserver::on_recompaction`] when prior-compaction
/// summary items are found.
/// - [`InterCompactionObserver::on_chunk_count`] — chunk count after
@@ -111,7 +110,6 @@ pub async fn sample_compaction_chunked<T: CompactionItemBuilder + Send + Sync>(
"[InterCompaction] starting chunked compaction"
);
// Step 1 — filter.
let filtered = filter_turns_for_inter_compaction(turns);
info!(
conversation_id = %conversation_id,
@@ -127,14 +125,13 @@ pub async fn sample_compaction_chunked<T: CompactionItemBuilder + Send + Sync>(
)));
}
// Step 2 — split prior `<kigi_user_queries>` out of every prior
// compaction summary item. The LLM never sees them (it would re-emit
// them verbatim and snowball across rounds); they are reattached to
// the final summary via `assemble_user_queries_preamble`. Shared with
// intra-compaction's `History` target.
// Split prior `<kigi_user_queries>` out of every prior compaction summary.
// The LLM never sees them (it would re-emit them and snowball across
// rounds); they are reattached via `assemble_user_queries_preamble`.
// Shared with intra-compaction's `History` target.
let separated = separate_prior_user_queries(&filtered);
// Step 3 — chunk + flush over the LLM-safe item list.
// Chunk + flush over the LLM-safe item list.
let mut compactable: Vec<T> = Vec::new();
let mut chunk_tokens: u32 = 0;
let mut chunk_outputs: Vec<LlmCompactionOutput> = Vec::new();
@@ -193,7 +190,7 @@ pub async fn sample_compaction_chunked<T: CompactionItemBuilder + Send + Sync>(
);
}
// Step 4a — combine summaries.
// Combine summaries.
let preamble =
assemble_user_queries_preamble(separated.prior_user_queries, current_user_queries);
let mut combined = preamble;
@@ -203,14 +200,11 @@ pub async fn sample_compaction_chunked<T: CompactionItemBuilder + Send + Sync>(
combined.push_str("\n</chunk_summary>\n\n");
}
// Step 4b — combine thinking-channel output.
let mut combined_analysis = String::new();
for (i, output) in chunk_outputs.iter().enumerate() {
combined_analysis.push_str(&wrap_chunk_analysis(i, &output.thinking));
}
// Record chunk count after assembly so dashboards see the same timing
// they saw pre-unification (where this lived inside DnC).
observer.on_chunk_count(chunk_outputs.len());
info!(
@@ -16,13 +16,11 @@ use crate::history::types::CompactionStrategy;
pub struct InterCompactionConfig {
/// The agent/scheduler name to use for the compaction model.
///
/// NOTE: model routing is host policy — kept here only because
/// service configs deserialize this struct as-is; slated to move to the
/// per-harness policy split in a later phase.
/// Model routing is host policy — kept here because service configs
/// deserialize this struct as-is.
pub compaction_model_name: String,
/// End-to-end timeout for the compaction sampling in seconds.
pub sampling_timeout_secs: u64,
/// Which compaction strategy to use.
pub compaction_strategy: CompactionStrategy,
/// [DivideAndConquer] Max tokens per chunk before sending to the LLM.
/// (Basic strategy ignores this and emits a single chunk.)
@@ -6,7 +6,6 @@
use std::time::Duration;
/// Receives inter-compaction pipeline events. All methods default to no-ops.
pub trait InterCompactionObserver: Send + Sync {
/// A prior compaction summary was found in the input (re-compaction).
/// `strategy` is the stable label from `CompactionStrategy::label()`.
@@ -15,7 +14,6 @@ pub trait InterCompactionObserver: Send + Sync {
/// One chunk's LLM call finished (success or error).
fn on_chunk_sampled(&self, _success: bool, _elapsed: Duration) {}
/// The whole pipeline finished assembling `num_chunks` chunk summaries.
fn on_chunk_count(&self, _num_chunks: usize) {}
}
@@ -221,7 +221,6 @@ where
{
let start = Instant::now();
// 1. Read the whole conversation (history ++ accumulated steps).
let source_turns = stream_proc.get_all_turns_for_compaction().await;
if source_turns.is_empty() {
return Err(IntraCompactionError::NothingToCompact);
@@ -243,27 +242,22 @@ where
"[IntraCompaction] starting full replace"
);
// 2. Summarize the whole conversation through kigi's shared core.
// FullReplace always uses the shared summarizer (it *is* the
// `code_compaction` path); `policy.summarizer` is ignored for this mode.
// FullReplace always uses the shared summarizer (it *is* the
// `code_compaction` path); `policy.summarizer` is ignored for this mode.
let summary_text = sample_shared_summary_with_retries(sampler, &source_turns, policy).await?;
// 2b. Preserve in-flight active agent state (e.g. running sub-agents) across
// the compaction. FullReplace drops the working tail, so append the
// harness-supplied `<system-reminder>` (verbatim ids) to the summary so
// the model can keep polling/cancelling them. Empty/None → no change.
// Shared with Kigi chat inter-compaction via `append_reminder_block` so
// both inject the reminder into the summary text identically, before the
// reduction guard below counts it.
// FullReplace drops the working tail — append harness-supplied
// `<system-reminder>` (verbatim ids) so in-flight sub-agents stay
// pollable/cancellable. Empty/None → no change. Shared with inter via
// `append_reminder_block` so both inject before the reduction guard counts it.
let summary_text = crate::append_reminder_block(summary_text, active_reminder);
// 3. Build the replacement developer turn. Snapshot the summary as a
// cheap-to-clone `Arc<str>` before moving the owned text into the item.
// Snapshot summary as cheap-to-clone `Arc<str>` before moving text into the item.
let summary: Arc<str> = Arc::from(summary_text.as_str());
let compaction_turn = T::compaction_summary_item(summary_text);
let tokens_after = token_counter.count_item_tokens(&compaction_turn);
// 4. Guard: don't apply if compaction didn't help.
// Don't apply if compaction didn't help.
if tokens_before > 0
&& tokens_after > (tokens_before as f64 * policy.max_reduction_ratio) as u32
{
@@ -280,7 +274,6 @@ where
});
}
// 5. Commit: replace the entire conversation with the single summary turn.
let turns_compacted = source_turns.len();
stream_proc
.replace_with_compaction(
@@ -441,9 +434,8 @@ where
{
let start = Instant::now();
// 1. Read source turns for this target. `FullReplace` never reaches
// `compact_one_pass` — it has a dedicated `apply_full_replace_compaction`
// (no tail-keep), so the orchestrator only routes `Steps`/`History` here.
// `FullReplace` never reaches here — dedicated `apply_full_replace_compaction`
// (no tail-keep); only `Steps`/`History` are routed here.
let source_turns = match target {
CompactionTarget::Steps => stream_proc.get_accumulated_turns_for_compaction().await,
CompactionTarget::History => stream_proc.get_history_turns_for_compaction().await,
@@ -459,7 +451,6 @@ where
.map(|t| token_counter.count_item_tokens(t))
.collect();
// 2. Choose split point.
let target_tokens =
(trigger.context_window as u64 * policy.target_threshold_percent as u64 / 100) as u32;
let plan = select_turns_to_compact(
@@ -483,12 +474,11 @@ where
"[IntraCompaction] starting"
);
// 3a. For `History` target, split prior `<kigi_user_queries>` blocks
// out of any prior compaction summary items before sampling — same
// primitive inter-compaction uses, so the LLM never sees
// `<kigi_user_queries>` and won't re-emit it (which would snowball
// with our explicit preamble across re-compactions). `Steps` target
// has no user-queries semantics and skips this entirely.
// For `History`, split prior `<kigi_user_queries>` out of prior compaction
// summary items before sampling — same primitive inter uses, so the LLM
// never sees `<kigi_user_queries>` and won't re-emit it (which would
// snowball with our explicit preamble). `Steps` has no user-queries
// semantics and skips this.
let (turns_for_llm, prior_user_queries) = match target {
CompactionTarget::History => {
let separated = separate_prior_user_queries(&turns_to_compact);
@@ -500,34 +490,29 @@ where
}
};
// 3b. Sample the summary. The *summarization algorithm* is switchable via
// `policy.summarizer`; everything around it — tail selection, the
// reduction guard, the prefix-replace commit, the Steps/History modes,
// and the `<kigi_user_queries>` preamble below — stays intra's.
// Summarization algorithm is switchable via `policy.summarizer`; tail
// selection, reduction guard, commit, Steps/History modes, and the
// `<kigi_user_queries>` preamble stay intra's.
let summary_text = match policy.summarizer {
// Previous intra algorithm: per-target prompt, bounded retry, and NO
// output cleaning — the raw model text flows straight to the preamble.
// Legacy: per-target prompt, bounded retry, no output cleaning —
// raw model text flows straight to the preamble.
IntraSummarizer::Legacy => {
let prompt = build_prompt_for_target(target)?;
let timeout = Duration::from_secs(policy.sampling_timeout_secs);
sample_compaction_with_retries(sampler, &turns_for_llm, &prompt, timeout, policy)
.await?
}
// New (default): kigi's shared summarization core from
// `code_compaction` — `build_summary_prompt` + degenerate-reject +
// `format_compact_summary` cleaning — run intra-locally.
// Shared: `code_compaction` core — `build_summary_prompt` +
// degenerate-reject + `format_compact_summary` cleaning.
IntraSummarizer::Shared => {
sample_shared_summary_with_retries(sampler, &turns_for_llm, policy).await?
}
};
// 3c. For `History` target, prepend a `<kigi_user_queries>` preamble so
// the original user messages + attachment refs survive the
// summarization. Carries forward both prior (from earlier
// compactions) and current (from this round's `User` turns) via
// the same `assemble_user_queries_preamble` helper that inter
// uses. (Legacy feeds the raw summary text here; Shared feeds the
// already-cleaned summary.)
// For `History`, prepend `<kigi_user_queries>` so original user messages
// + attachment refs survive. Carries prior (earlier compactions) and
// current (`User` turns) via the same `assemble_user_queries_preamble`
// helper as inter. Legacy feeds raw summary text; Shared feeds cleaned.
let final_summary_text = match target {
CompactionTarget::History => {
// Current user queries come from `turns_to_compact` (the
@@ -549,16 +534,13 @@ where
}
};
// 4. Build the replacement item. Carries category metadata so that
// subsequent compaction passes (inter or intra) treat it as
// already-compacted content. Snapshot the (possibly large) summary as a
// cheap-to-clone `Arc<str>` for the result before moving the owned text
// into the item.
// Replacement item carries category metadata so later passes treat it as
// already-compacted. Snapshot large summary as `Arc<str>` before move.
let summary: Arc<str> = Arc::from(final_summary_text.as_str());
let compaction_turn = T::compaction_summary_item(final_summary_text);
let tokens_after = token_counter.count_item_tokens(&compaction_turn);
// 5. Guard: don't apply if compaction didn't help.
// Don't apply if compaction didn't help.
if plan.tokens_to_compact > 0
&& tokens_after > (plan.tokens_to_compact as f64 * policy.max_reduction_ratio) as u32
{
@@ -575,9 +557,7 @@ where
});
}
// 6. Commit the LLM-produced summary into parser state. The trait
// method dispatches internally on `target` (Steps view vs History
// view) and rebuilds any derived state (e.g. SglangEngine).
// Trait method dispatches on `target` and rebuilds derived state (e.g. SglangEngine).
stream_proc
.replace_with_compaction(target, plan.split_idx, compaction_turn)
.await?;
@@ -676,7 +656,7 @@ where
.await
{
// kigi returns the raw summary and cleans it in its assembler;
// intra has no assembler, so it cleans here (pre-refactor behavior).
// intra has no assembler, so it cleans here.
Ok(SampledSummary { summary, .. }) => Ok(format_compact_summary(&summary)),
Err(SampleRetryError::Empty { .. }) => Err(IntraCompactionError::EmptyResponse),
Err(SampleRetryError::Failure {
@@ -853,8 +833,6 @@ mod tests {
}
}
// ── Legacy summarizer: error mapping ──
#[test]
fn compaction_sample_error_to_intra_maps_timeout() {
let intra = compaction_sample_error_to_intra(CompactionSampleError::Timeout {
@@ -931,8 +909,6 @@ mod tests {
)));
}
// ── generic orchestrator over a pure mock item ─────────────────────
/// Mock item: a `(role, text)` pair with deterministic token counting.
#[derive(Debug, Clone)]
struct MockItem {
@@ -1097,8 +1073,7 @@ mod tests {
fn enabled_policy() -> IntraCompactionConfig {
IntraCompactionConfig {
enabled: true,
// These tests exercise the steps (tail-keep) path; pin the mode so
// they stay independent of the crate default (now `FullReplace`).
// Steps (tail-keep) path; pin mode independent of FullReplace default.
mode: IntraCompactionMode::StepsOnly,
trigger_threshold_percent: 85,
target_threshold_percent: 50,
@@ -1138,7 +1113,6 @@ mod tests {
#[tokio::test]
async fn compact_replaces_turns_on_success_and_notifies_observer() {
// 6 turns × 500 tokens (2000 chars / 4); ctx 1000, target 50% → 500
// → keep the newest 1 turn, compact the oldest 5 (2500 tokens). The
// summary must be non-degenerate (>= 500 cleaned chars) for the shared
// sampler to accept it, yet still pass the reduction guard (≤ 2000).
@@ -1321,8 +1295,6 @@ mod tests {
assert!(matches!(result, Err(IntraCompactionError::Apply(_))));
}
// ── FullReplace mode (default): whole-conversation replace ────────
fn full_replace_policy() -> IntraCompactionConfig {
IntraCompactionConfig {
mode: IntraCompactionMode::FullReplace,
@@ -1332,7 +1304,6 @@ mod tests {
#[tokio::test]
async fn full_replace_compacts_whole_conversation_and_notifies_observer() {
// 6 turns × 500 tokens (2000 chars / 4) = 3000 tokens. FullReplace
// summarizes *all* of them (no tail-keep) into one developer turn.
let turns: Vec<_> = (0..6).map(|_| MockItem::user(&"x".repeat(2000))).collect();
let sp = MockStreamProc::with_turns(turns);
@@ -1355,7 +1326,7 @@ mod tests {
assert_eq!(r.turns_compacted, 6);
assert!(!r.summary.is_empty());
assert_eq!(sampler.call_count(), 1);
// The mock now holds only the single summary turn.
// Mock holds only the single summary turn.
assert_eq!(sp.turns.lock().unwrap().len(), 1);
// Observer recorded a `FullReplace` success (drives the
// `target="full_replace"` metric).
@@ -1423,7 +1394,6 @@ mod tests {
#[tokio::test]
async fn full_replace_skips_below_min_compactable_tokens() {
// 2 turns × 1 token = 2 tokens, below `min_compactable_tokens`.
let turns: Vec<_> = (0..2).map(|_| MockItem::user("x")).collect();
let sp = MockStreamProc::with_turns(turns);
let sampler = MockSampler::returns(&long_summary());
@@ -1442,8 +1412,6 @@ mod tests {
assert!(!sp.was_applied());
}
// ── Shared summarizer (default): direct helper coverage ───────────
#[tokio::test]
async fn shared_summarizer_rejects_degenerate_then_errors() {
// A too-short summary is degenerate; `Shared` retries (max_attempts=2)
@@ -1511,12 +1479,10 @@ mod tests {
assert!(!cleaned.contains("<summary>"), "tags must be neutralized");
}
// ── Legacy summarizer: end-to-end switch ──────────────────────────
#[tokio::test]
async fn legacy_summarizer_accepts_short_uncleaned_summary() {
// Legacy has no degenerate floor and does NO cleaning: a short raw
// summary is accepted verbatim (would be rejected under `Shared`).
// Legacy has no degenerate floor and does no cleaning: a short raw
// summary is accepted verbatim (rejected under `Shared`).
let turns: Vec<_> = (0..6).map(|_| MockItem::user(&"x".repeat(400))).collect();
let sp = MockStreamProc::with_turns(turns);
let sampler = MockSampler::returns("compacted summary");
@@ -32,11 +32,11 @@ pub enum IntraCompactionMode {
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum IntraSummarizer {
/// New (default): the shared summarization core — `build_summary_prompt`
/// + degenerate-reject + `format_compact_summary` cleaning.
/// Shared summarization core — `build_summary_prompt` + degenerate-reject
/// + `format_compact_summary` cleaning.
#[default]
Shared,
/// Previous intra algorithm: per-target prompt (`format_compaction_prompt`
/// Legacy path: per-target prompt (`format_compaction_prompt`
/// / history dev+user prompts), no cleaning. Kept for switchability.
Legacy,
}
@@ -82,11 +82,11 @@ pub enum IntraSummarizer {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IntraCompactionConfig {
// ───────────────────────────── Common (all modes) ─────────────────────────────
// Common (all modes)
// Present on every config path regardless of `mode`. Some trigger fields
// are ignored under FullReplace (see per-field docs).
// -- Enablement & strategy selection --
// Enablement & strategy selection
/// Enable intra-compaction between steps. Default: `false` (disabled).
pub enabled: bool,
@@ -94,7 +94,7 @@ pub struct IntraCompactionConfig {
/// Default: `FullReplace`.
pub mode: IntraCompactionMode,
// -- Trigger gating: when a compaction pass fires (see `should_compact`) --
// Trigger gating: when a compaction pass fires (see `should_compact`)
/// Context window usage percentage (0-100) that triggers compaction.
/// Compared against: `last_prompt_tokens / context_length.max_len`.
/// Default: `85`.
@@ -112,7 +112,7 @@ pub struct IntraCompactionConfig {
/// / reduction guards after a trigger.
pub min_steps_before_compact: u32,
// -- Reduction guards: whether a produced summary is worth keeping --
// Reduction guards: whether a produced summary is worth keeping
/// Minimum tokens that must be reducible before compaction is worth
/// running. Below this, the LLM overhead outweighs the savings.
/// Default: `5000`.
@@ -123,7 +123,7 @@ pub struct IntraCompactionConfig {
/// Default: `0.8`.
pub max_reduction_ratio: f64,
// -- Compaction LLM call (sampling) --
// Compaction LLM call (sampling)
/// Compaction model name. Blank/`None` → [`DEFAULT_COMPACTION_MODEL_NAME`].
/// Prefer [`Self::effective_compaction_model_name`].
pub compaction_model_name: Option<String>,
@@ -142,12 +142,12 @@ pub struct IntraCompactionConfig {
/// Delay between retries. Default: `3`.
pub retry_delay_secs: u64,
// -- Audit --
// Audit
/// Version string for the compaction (e.g. `"intra-v1"`).
/// Recorded in audit logs. Default: `"intra-v1"`.
pub compaction_version: String,
// ───────────────────────────── Mode-specific ─────────────────────────────
// Mode-specific
// Each field below is read by only a subset of modes; the other modes
// ignore it entirely. The bracketed `[...]` tag on each doc names the modes
// that consume it.
@@ -172,7 +172,7 @@ pub struct IntraCompactionConfig {
/// replaces everything and never reads it.
pub target_threshold_percent: u8,
// -- HistoryThenSteps only --
// HistoryThenSteps only
/// [HistoryThenSteps mode] Only compact accumulated step turns when their
/// token count exceeds this fraction of the history token count.
///
@@ -5,7 +5,7 @@
//! counters/histograms in the harness crate)
//! without the shared crate depending on a metrics backend. Emission points
//! and label values are part of the behavior contract — Kigi chat's
//! observer preserves them byte-for-byte.
//! observer preserves them exactly.
use std::time::Duration;
@@ -1,27 +1,19 @@
//! Trait abstractions for intra-compaction.
use async_trait::async_trait;
use super::trigger::IntraCompactionError;
/// Which segment of the conversation a single intra-compaction pass acts on.
///
/// Determines the prompt template the orchestrator uses, which read-view
/// it pulls items from on the stream processor (`get_accumulated_turns_for_compaction`
/// vs `get_history_turns_for_compaction`), and which branch the stream processor's
/// [`CompactionStreamProc::replace_with_compaction`] dispatches to.
/// Selects both the prompt template and the read-view items are pulled from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionTarget {
/// Compact the agent loop's accumulated step turns (assistant outputs,
/// tool calls, tool results). Fine-grained prompt.
/// The agent loop's accumulated step turns: assistant outputs, tool calls,
/// tool results.
Steps,
/// Compact prior conversation-history turns (user/assistant exchanges
/// from before the current agent loop). Coarser prompt, shared with
/// inter-compaction.
/// Prior conversation-history turns, from before the current agent loop.
/// Coarser prompt, shared with inter-compaction.
History,
/// Replace the *whole* conversation (prior history + accumulated steps)
/// with a single summary — kigi's full-replace strategy. No tail is
/// kept; the read-view is [`CompactionStreamProc::get_all_turns_for_compaction`].
/// Prior history *and* accumulated steps, replaced wholesale by a single
/// summary. No tail is kept.
FullReplace,
}
@@ -37,26 +29,8 @@ impl CompactionTarget {
}
/// Minimal interface the compaction orchestrator needs from the agent's
/// stream processor. Implemented by Kigi chat's
/// `StreamProcessor` (`Item = Arc<KigiTurn>`).
///
/// Two read-views are exposed:
///
/// - **Accumulated step turns**: items added since the agent loop started
/// — assistant outputs, tool calls, tool results, recovery turns. The
/// original conversation (system prompt, user messages, prior history)
/// is excluded. Used by step (fine-grained) compaction.
/// - **History turns**: items from prior user-query/assistant-response
/// exchanges, before the current agent loop began. Used by history
/// (coarse) compaction.
///
/// The single mutator [`Self::replace_with_compaction`] takes a
/// [`CompactionTarget`] and dispatches internally to the steps- or
/// history-specific path. It is the final step of a compaction cycle:
/// the LLM-produced summary is committed into parser state. The
/// orchestrator [`super::apply_intra_compaction`] and its peers
/// [`super::apply_steps_compaction`] / [`super::apply_history_compaction`]
/// are the layers above that produce the summary and call this method.
/// stream processor. Implemented by Kigi chat's `StreamProcessor`
/// (`Item = Arc<KigiTurn>`).
///
/// Implementations that don't support a particular target return
/// [`IntraCompactionError::Unsupported`] from the matching match arm.
@@ -65,13 +39,13 @@ pub trait CompactionStreamProc: Send + Sync {
/// The harness's conversation item type.
type Item;
/// Get the items accumulated across all completed steps, oldest first.
/// Candidates for **steps** compaction.
/// Items accumulated across all completed steps, oldest first. The
/// original conversation (system prompt, user messages, prior history) is
/// excluded.
async fn get_accumulated_turns_for_compaction(&self) -> Vec<Self::Item>;
/// Get the conversation-history items (prior user/assistant exchanges
/// from before the current agent loop), oldest first. Candidates for
/// **history** compaction.
/// Items from prior user/assistant exchanges, before the current agent
/// loop began, oldest first.
///
/// Default impl returns empty — implementations that do not support
/// history compaction will have nothing to compact.
@@ -79,13 +53,11 @@ pub trait CompactionStreamProc: Send + Sync {
Vec::new()
}
/// Get the **whole** conversation — prior history followed by the
/// accumulated step turns, oldest first. Candidates for **full-replace**
/// (`CompactionTarget::FullReplace`) compaction.
/// The whole conversation — prior history followed by accumulated step
/// turns, oldest first.
///
/// The default composes the two read-views above (`history ++ steps`),
/// which is correct for any implementation; override only if a harness can
/// produce the combined view more cheaply.
/// The default composition is correct for any implementation; override
/// only if a harness can produce the combined view more cheaply.
///
/// The `Self::Item: Send` bound lets the default hold the history vec across
/// the second `await` while keeping the boxed future `Send`; every concrete
@@ -99,13 +71,10 @@ pub trait CompactionStreamProc: Send + Sync {
all
}
/// Top-level intra-compaction mutator. Replaces the first
/// `n_turns_to_remove` items in the read-view selected by `target` with
/// the single given `compaction_turn`.
/// Replaces the first `n_turns_to_remove` items of the read-view selected
/// by `target` with the single given `compaction_turn`.
///
/// Implementations dispatch internally on `target` to the steps or
/// history specific path. On invalid input
/// (`n_turns_to_remove > view.len()`), returns
/// On invalid input (`n_turns_to_remove > view.len()`), returns
/// [`IntraCompactionError::InvalidSplit`] and leaves state untouched.
async fn replace_with_compaction(
&self,
@@ -24,7 +24,6 @@ pub struct IntraCompactionTrigger {
pub step: u32,
}
/// Result of a successful compaction.
#[derive(Debug, Clone)]
pub struct IntraCompactionResult {
/// Sum of tokens in the turns that were compacted.
+1 -5
View File
@@ -23,16 +23,13 @@
/// `ConversationItem` variants (kigi).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionRole {
/// System prompt.
System,
/// Developer prompt (Kigi chat) — maps to System on harnesses without a
/// distinct developer role.
Developer,
/// A user message.
User,
/// An assistant output (may carry tool requests).
/// Assistant output (may carry tool requests).
Assistant,
/// A tool result.
Tool,
}
@@ -43,7 +40,6 @@ pub enum CompactionRole {
pub struct CompactionFileRef {
/// Stable unique id of the attachment source.
pub id: String,
/// Human-readable file name.
pub name: String,
}
+2 -2
View File
@@ -57,8 +57,8 @@ pub mod token;
/// 3. this constant
pub use intra_compaction::DEFAULT_COMPACTION_MODEL_NAME;
// kigi's full-replace subsystem now lives under `code_compaction`;
// re-exported at the crate root so consumers keep a stable public API.
// Full-replace subsystem lives under `code_compaction`; re-exported at the
// crate root for a stable public API.
pub use code_compaction::{
CompactedHistoryParts, DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT, FailureKind,
FullReplaceAttemptOutcome, FullReplaceConfig, FullReplaceContext, FullReplaceError,
+4 -6
View File
@@ -1,14 +1,12 @@
//! The shared compaction prompt seam.
//!
//! [`CompactionPrompt`] is the system+user prompt pair every orchestrator's
//! [`CompactionSampler`](crate::sampler::CompactionSampler) call takes. The
//! per-strategy prompt *content* lives with each subsystem:
//! The shared compaction prompt seam. Per-strategy prompt *content* lives with
//! each subsystem:
//!
//! - steps prompt → [`crate::steps::format_compaction_prompt`]
//! - history prompts → [`crate::history::prompt`]
//! - kigi summary prompt → [`crate::code_compaction::build_summary_prompt`]
/// System + user prompt pair for the compaction LLM call.
/// System + user prompt pair every
/// [`CompactionSampler`](crate::sampler::CompactionSampler) call takes.
#[derive(Debug, Clone)]
pub struct CompactionPrompt {
pub system: String,
@@ -17,9 +17,7 @@
//! views** (`&str` over live state) so long fields (commands, todo content,
//! descriptions, ids) are not cloned just to format.
// ---------------------------------------------------------------------------
// Borrowed views over harness live state (no long-string clones)
// ---------------------------------------------------------------------------
/// Model-facing poll/cancel tool names from the current toolset.
/// Never hard-code: a client manifest can rename them.
@@ -102,9 +100,7 @@ impl ActiveAgentReminderState<'_> {
}
}
// ---------------------------------------------------------------------------
// Section formatters
// ---------------------------------------------------------------------------
/// `## Running Background Tasks`, or `None` when empty.
pub fn section_background_tasks(tasks: &[BackgroundTask<'_>]) -> Option<String> {
@@ -249,9 +245,7 @@ pub fn format_active_agent_reminder(
wrap_system_reminder(format_active_agent_sections(state, subagent_tools))
}
// ---------------------------------------------------------------------------
// Summary injection
// ---------------------------------------------------------------------------
/// Append a trailing block to a compaction summary, separated by a blank line.
/// Returns `summary` unchanged when `reminder` is `None` or blank.
+6 -9
View File
@@ -7,9 +7,7 @@ use async_trait::async_trait;
use crate::prompt::CompactionPrompt;
// ---------------------------------------------------------------------------
// Sampler output + error types
// ---------------------------------------------------------------------------
/// Raw text captured from a compaction LLM call, split by channel.
///
@@ -45,16 +43,17 @@ pub enum CompactionSampleError {
Build(String),
/// The sampling call could not be started.
///
/// Classification is asymmetric for pre-migration parity: the *inter*
/// retry policy ([`Self::is_deterministic`]) treats it as deterministic
/// (no retry), while the *intra* orchestrator maps it to
/// Classification is asymmetric: the *inter* retry policy
/// ([`Self::is_deterministic`]) treats it as deterministic (no retry),
/// while the *intra* orchestrator maps it to
/// `IntraCompactionError::SamplerStart` which its retry loop treats as
/// transient.
Start(String),
/// The model produced no response-channel content. Transient.
EmptyResponse,
/// Anything else — classified by string matching for backward
/// compatibility with samplers that pre-date the structured variants.
/// Opaque error — classified by string matching when the sampler does not
/// use a structured variant. Keep match literals in sync with the harness
/// sampler (`compaction_sample_error_to_intra*` tests guard the mapping).
Other(anyhow::Error),
}
@@ -103,9 +102,7 @@ impl CompactionSampleError {
}
}
// ---------------------------------------------------------------------------
// Sampler trait
// ---------------------------------------------------------------------------
/// Interface for the LLM call that produces compaction summaries.
///
+9 -10
View File
@@ -74,14 +74,13 @@ pub fn select_turns_to_compact<T: CompactionItem>(
return None;
}
// Step 1: Walk backward, sum "keep" tokens until target is reached.
// Find the highest split_idx such that sum(item_token_counts[split_idx..]) ≤ target_tokens.
// Highest split_idx such that sum(item_token_counts[split_idx..]) ≤ target_tokens.
let mut kept = 0u32;
let mut split_idx = total; // start with "compact nothing", will move down
// Start with "compact nothing", walk down.
let mut split_idx = total;
for i in (0..total).rev() {
let count = item_token_counts[i];
if kept.saturating_add(count) > target_tokens {
// Adding this item would exceed the budget — split here.
split_idx = i + 1;
break;
}
@@ -94,7 +93,6 @@ pub fn select_turns_to_compact<T: CompactionItem>(
return None;
}
// Step 2: Snap the split forward to a safe boundary.
let safe_split_idx = snap_to_safe_boundary(items, split_idx);
// After snapping forward we might have eaten everything.
@@ -102,7 +100,6 @@ pub fn select_turns_to_compact<T: CompactionItem>(
return None;
}
// Step 3: Compute tokens to compact and check the minimum.
let tokens_to_compact: u32 = item_token_counts[..safe_split_idx]
.iter()
.copied()
@@ -131,7 +128,6 @@ fn snap_to_safe_boundary<T: CompactionItem>(items: &[T], candidate: usize) -> us
return total;
}
// If candidate is not a tool-result item, no snap needed.
if !items[candidate].is_tool_result() {
return candidate;
}
@@ -214,7 +210,8 @@ mod tests {
MockItem::user(),
MockItem::assistant(),
];
let counts = vec![40, 30, 20, 10]; // keep last two (sum 30)
// keep last two (sum 30)
let counts = vec![40, 30, 20, 10];
let plan = select_turns_to_compact(&counts, &items, 30, 5).expect("should split");
assert_eq!(plan.split_idx, 2);
assert_eq!(plan.tokens_to_compact, 70);
@@ -235,7 +232,8 @@ mod tests {
let items = vec![
MockItem::user(),
MockItem::assistant(),
MockItem::assistant(), // pretend this had tool_requests
// pretend this had tool_requests
MockItem::assistant(),
MockItem::tool(),
MockItem::tool(),
MockItem::assistant(),
@@ -256,7 +254,8 @@ mod tests {
let items = vec![
MockItem::user(),
MockItem::assistant(),
MockItem::user(), // safe split here
// safe split here
MockItem::user(),
MockItem::assistant(),
];
let counts = vec![50, 50, 10, 10];
@@ -1,11 +1,10 @@
//! Steps compaction — prompt content for compacting accumulated step
//! turns (tool calls + assistant responses) within a single agent turn.
//! Prompt content for compacting accumulated step turns (tool calls +
//! assistant responses) within a single agent turn.
//!
//! Parallel to [`crate::history`] (the history-compaction content): this is the
//! *steps* side. The orchestration that uses it lives in
//! [`crate::intra_compaction`] (the `Steps` target / `StepsOnly` mode), and the
//! Parallel to [`crate::history`], the history-compaction content. The
//! orchestration that uses it lives in [`crate::intra_compaction`], and the
//! turn selection it shares with the History target is the crate-root
//! [`select`](crate::select) primitive (not steps-specific).
//! [`select`](crate::select) primitive.
pub mod prompt;
@@ -1,16 +1,11 @@
//! Prompt construction for **steps** compaction.
//!
//! The step-level intra-compaction prompt: short and focused on summarising
//! tool-call history mid-task. Parallel to [`crate::history::prompt`] (the
//! history-compaction prompts); templates live in the crate-root `templates/`.
//! Prompt construction for **steps** compaction. Templates live in the
//! crate-root `templates/`.
use crate::prompt::CompactionPrompt;
/// Build the standard prompt for step-level intra-compaction.
///
/// The prompts are short and focused on summarising tool-call history
/// mid-task — the assistant has already done several steps of work and
/// we need to free up context so it can continue.
/// Short prompts focused on summarising tool-call history mid-task: the
/// assistant has already done several steps of work and needs context freed up
/// to continue.
pub fn format_compaction_prompt() -> CompactionPrompt {
CompactionPrompt {
system: include_str!("../templates/intra_compaction_system.txt").to_string(),
+5 -12
View File
@@ -1,24 +1,17 @@
//! Token-count seam.
//!
//! Budgeting math in the shared engine needs a *trusted* token count, but the
//! two harnesses disagree on how to produce one:
//!
//! - Kigi chat has a real tokenizer (`TextTokenizer` / `ImageTokenizer`) and
//! counts whole turns via `KigiTurn::get_num_tokens`.
//! - kigi estimates with `bytes / 4`.
//!
//! Rather than bake either policy into the shared crate, callers supply an
//! [`ItemTokenCounter`]. This keeps the engine deterministic and testable
//! while letting each harness plug in its own counting strategy.
//! two harnesses disagree on how to produce one: Kigi chat has a real tokenizer
//! (`TextTokenizer` / `ImageTokenizer`) and counts whole turns via
//! `KigiTurn::get_num_tokens`, while kigi estimates with `bytes / 4`. Rather
//! than bake either policy into the shared crate, callers supply an
//! [`ItemTokenCounter`].
//!
//! There is intentionally **no** blanket `Arc` forwarding here: each harness
//! implements the counter directly for the item type its algorithms run on
//! (Kigi chat: `ItemTokenCounter<Arc<KigiTurn>>`), so exactly one mechanism
//! is in play.
/// Counts tokens for a single conversation item on behalf of the shared
/// budgeting logic.
pub trait ItemTokenCounter<T: ?Sized>: Send + Sync {
/// Trusted token count of `item`.
fn count_item_tokens(&self, item: &T) -> u32;
}