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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user