Files
Kigi-CLI/crates/common/kigi-compaction/src/inter_compaction/compact.rs
T
ZacharyZhang-NY a02b555e66 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).
2026-07-23 16:55:39 -04:00

267 lines
10 KiB
Rust

//! Inter-compaction chunked pipeline (shared core).
//!
//! Single pipeline shared by both `CompactionStrategy::Basic` and
//! `CompactionStrategy::DivideAndConquer`. The only difference between
//! the two is the per-chunk token budget:
//!
//! - **Basic** → unbounded chunk budget → exactly one chunk.
//! - **DivideAndConquer** → `config.dnc_chunk_token_limit` → N chunks.
//!
//! Everything else — turn filtering, prior-compaction user-query
//! extraction, chunk summarisation, and the final `<kigi_user_queries>`
//! + `<chunk_summary>` assembly — is shared. The harness supplies the
//! candidate items, the *current* user-queries preamble (Kigi chat
//! extracts it from the raw `ChatCompletionRequest`), the sampler, the
//! token counter, and an observer for metrics.
use std::time::{Duration, Instant};
use tracing::info;
use crate::history::filter::{
assemble_user_queries_preamble, filter_turns_for_inter_compaction, separate_prior_user_queries,
wrap_chunk_analysis,
};
use crate::history::prompt::{format_compaction_developer_prompt, format_compaction_user_prompt};
use crate::history::types::CompactionStrategy;
use crate::item::CompactionItemBuilder;
use crate::prompt::CompactionPrompt;
use crate::sampler::{CompactionSampleError, CompactionSampler, LlmCompactionOutput};
use crate::token::ItemTokenCounter;
use super::config::InterCompactionConfig;
use super::observer::InterCompactionObserver;
/// Sentinel chunk budget used by [`CompactionStrategy::Basic`] so the
/// chunking loop emits exactly one chunk.
const UNBOUNDED_CHUNK_LIMIT: u32 = u32::MAX;
/// Output of the shared chunked pipeline — assembled text, not yet wrapped
/// into a harness message type.
#[derive(Debug, Clone)]
pub struct ChunkedCompactionOutput {
/// `<kigi_user_queries>` preamble + `<chunk_summary index="i">` blocks.
/// The harness wraps this into its summary-carrier message.
pub combined_text: String,
/// Thinking-channel output: `<chunk_analysis>` blocks. Empty when the
/// model produced no thinking output. Stored for audit/debug only.
pub analysis_text: String,
}
/// Shared chunked pipeline.
///
/// Steps:
/// 1. Filter items with
/// [`filter_turns_for_inter_compaction`](crate::history::filter::filter_turns_for_inter_compaction).
/// 2. [`separate_prior_user_queries`] — split prior `<kigi_user_queries>`
/// blocks out of every prior compaction summary item. The LLM never sees
/// them. Shared with intra-compaction's `History` target so both
/// pipelines handle re-compactions identically.
/// 3. Walk the LLM-safe item list. Flush a chunk whenever the running
/// token count would exceed the chunk budget (`UNBOUNDED_CHUNK_LIMIT`
/// for Basic — single chunk).
/// 4. Combine `prior_user_queries + current_user_queries + <chunk_summary>`
/// blocks into the final summary text via
/// [`assemble_user_queries_preamble`]; combine the per-chunk
/// `thinking` channels into the analysis text.
///
/// `current_user_queries` is the harness-extracted preamble for *this*
/// round's user messages (Kigi chat: verbatim from the raw request, with
/// attachment refs). `conversation_id` / `response_id` are threaded
/// through for log correlation only.
///
/// Observer events:
/// - [`InterCompactionObserver::on_recompaction`] when prior-compaction
/// summary items are found.
/// - [`InterCompactionObserver::on_chunk_count`] — chunk count after
/// assembly (always 1 for Basic; N for DnC).
/// - [`InterCompactionObserver::on_chunk_sampled`] — per-chunk LLM latency.
#[allow(clippy::too_many_arguments)]
pub async fn sample_compaction_chunked<T: CompactionItemBuilder + Send + Sync>(
turns: &[T],
current_user_queries: Option<String>,
conversation_id: &str,
response_id: &str,
start_response_id: &str,
config: &InterCompactionConfig,
sampler: &dyn CompactionSampler<Item = T>,
token_counter: &dyn ItemTokenCounter<T>,
observer: &dyn InterCompactionObserver,
) -> Result<ChunkedCompactionOutput, CompactionSampleError> {
let chunk_token_limit = match config.compaction_strategy {
CompactionStrategy::Basic => UNBOUNDED_CHUNK_LIMIT,
CompactionStrategy::DivideAndConquer => config.dnc_chunk_token_limit,
CompactionStrategy::FullReplace => {
return Err(CompactionSampleError::Build(
"full_replace must be routed through the event-proc compact_conversation helper"
.to_string(),
));
}
};
let strategy_label = config.compaction_strategy.label();
info!(
conversation_id = %conversation_id,
response_id = %response_id,
strategy = strategy_label,
num_turns = turns.len(),
chunk_token_limit,
user_compact_threshold = config.user_message_compact_threshold,
"[InterCompaction] starting chunked compaction"
);
let filtered = filter_turns_for_inter_compaction(turns);
info!(
conversation_id = %conversation_id,
start_response_id = %start_response_id,
last_response_id = %response_id,
original = turns.len(),
filtered = filtered.len(),
"[InterCompaction] filtered turns"
);
if filtered.is_empty() {
return Err(CompactionSampleError::Other(anyhow::anyhow!(
"No turns remaining after filtering"
)));
}
// 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);
// 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();
let mut chunk_idx: usize = 0;
for turn in &separated.turns_for_llm {
let turn_tokens = token_counter.count_item_tokens(turn);
// Flush the current chunk if adding this item would exceed the
// budget (`UNBOUNDED_CHUNK_LIMIT` disables flushing — Basic).
if !compactable.is_empty()
&& chunk_token_limit != UNBOUNDED_CHUNK_LIMIT
&& chunk_tokens.saturating_add(turn_tokens) > chunk_token_limit
{
let output = flush_chunk(
&compactable,
conversation_id,
response_id,
chunk_idx,
config,
sampler,
token_counter,
observer,
)
.await?;
chunk_outputs.push(output);
chunk_idx += 1;
compactable.clear();
chunk_tokens = 0;
}
compactable.push(turn.clone());
chunk_tokens += turn_tokens;
}
// Final flush — one chunk for Basic, the trailing chunk for DnC.
if !compactable.is_empty() {
let output = flush_chunk(
&compactable,
conversation_id,
response_id,
chunk_idx,
config,
sampler,
token_counter,
observer,
)
.await?;
chunk_outputs.push(output);
}
if separated.has_prior_compaction {
observer.on_recompaction(strategy_label);
info!(
conversation_id = %conversation_id,
strategy = strategy_label,
"[InterCompaction] Re-compaction detected"
);
}
// Combine summaries.
let preamble =
assemble_user_queries_preamble(separated.prior_user_queries, current_user_queries);
let mut combined = preamble;
for (i, output) in chunk_outputs.iter().enumerate() {
combined.push_str(&format!("<chunk_summary index=\"{}\">\n", i));
combined.push_str(&output.response);
combined.push_str("\n</chunk_summary>\n\n");
}
let mut combined_analysis = String::new();
for (i, output) in chunk_outputs.iter().enumerate() {
combined_analysis.push_str(&wrap_chunk_analysis(i, &output.thinking));
}
observer.on_chunk_count(chunk_outputs.len());
info!(
conversation_id = %conversation_id,
response_id = %response_id,
strategy = strategy_label,
num_chunks = chunk_outputs.len(),
combined_len = combined.len(),
analysis_len = combined_analysis.len(),
"[InterCompaction] chunked compaction complete"
);
Ok(ChunkedCompactionOutput {
combined_text: combined,
analysis_text: combined_analysis,
})
}
/// Compact a single chunk of items via the LLM.
#[allow(clippy::too_many_arguments)]
async fn flush_chunk<T: CompactionItemBuilder + Send + Sync>(
turns: &[T],
conversation_id: &str,
response_id: &str,
chunk_idx: usize,
config: &InterCompactionConfig,
sampler: &dyn CompactionSampler<Item = T>,
token_counter: &dyn ItemTokenCounter<T>,
observer: &dyn InterCompactionObserver,
) -> Result<LlmCompactionOutput, CompactionSampleError> {
let total_tokens: u32 = turns
.iter()
.map(|t| token_counter.count_item_tokens(t))
.sum();
info!(
conversation_id = %conversation_id,
response_id = %response_id,
chunk_idx = chunk_idx,
num_turns = turns.len(),
total_tokens = total_tokens,
"[InterCompaction] Compacting chunk"
);
let prompt = CompactionPrompt {
system: format_compaction_developer_prompt().map_err(CompactionSampleError::from)?,
user: format_compaction_user_prompt().map_err(CompactionSampleError::from)?,
};
let timeout = Duration::from_secs(config.sampling_timeout_secs);
let t0 = Instant::now();
let result = sampler.sample_compaction(turns, &prompt, timeout).await;
observer.on_chunk_sampled(result.is_ok(), t0.elapsed());
info!(
conversation_id = %conversation_id,
chunk_idx = chunk_idx,
elapsed_ms = t0.elapsed().as_millis() as u64,
success = result.is_ok(),
"[InterCompaction] Chunk compaction done"
);
result
}