Files
Kigi-CLI/crates/common/kigi-compaction/src/intra_compaction/trigger.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

248 lines
8.5 KiB
Rust

//! Trigger decision and result types for intra-compaction.
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use super::config::{IntraCompactionConfig, IntraCompactionMode};
/// Information about why intra-compaction was triggered.
///
/// Constructed by [`should_compact`] and threaded through to
/// [`crate::compact`] and the agent's event stream.
#[derive(Debug, Clone)]
pub struct IntraCompactionTrigger {
/// Token count of the prompt most recently sent to the model.
pub last_prompt_tokens: u32,
/// Context window of the agent's current sampler (`max_len`).
pub context_window: u32,
/// `last_prompt_tokens / context_window` as an integer percentage,
/// clamped to [0, 100].
pub percent: u8,
/// Step index (0-based) at which the trigger fired.
pub step: u32,
}
/// Result of a successful compaction.
#[derive(Debug, Clone)]
pub struct IntraCompactionResult {
/// Sum of tokens in the turns that were compacted.
pub tokens_before: u32,
/// Tokens in the resulting compaction turn (the LLM summary).
pub tokens_after: u32,
/// Number of accumulated turns that were replaced.
pub turns_compacted: u32,
/// End-to-end elapsed time (decision → apply).
pub elapsed: Duration,
/// The summary text the LLM produced — the developer-turn content that
/// replaced the compacted turns (for `HistoryThenSteps`, both passes'
/// summaries joined). Carried so callers can record the actual result
/// (e.g. as a developer turn in the thinking trace). `Arc<str>` because the
/// summary can be large and is cloned along with the event downstream.
pub summary: Arc<str>,
}
/// Errors that can occur during intra-compaction.
///
/// All errors are non-fatal — the caller should log and continue without
/// compaction. Worst case the next sampling call may fail with 400, which
/// is the same as today (no compaction support at all).
#[derive(Debug, Error)]
pub enum IntraCompactionError {
/// The accumulated turn list has nothing meaningful to compact.
/// Triggered when:
/// - `get_accumulated_turns_for_compaction()` returns empty
/// - `select_turns_to_compact()` finds nothing reducible (below
/// `min_compactable_tokens` or no safe split point)
#[error("nothing to compact")]
NothingToCompact,
/// The compaction LLM call timed out with no usable output.
#[error("compaction LLM call timed out")]
Timeout,
/// The compaction LLM returned an empty response.
#[error("compaction LLM returned empty response")]
EmptyResponse,
/// Compaction result was not smaller than the original by the configured
/// minimum (`max_reduction_ratio`).
#[error("insufficient reduction: {tokens_after} > {tokens_before} * ratio")]
InsufficientReduction {
tokens_before: u32,
tokens_after: u32,
},
/// `apply_steps_compaction` received an invalid `n_turns_to_remove`
/// (greater than the current accumulated-turn count). Parser state is
/// left unchanged.
#[error("invalid split: requested {requested}, only {available} available")]
InvalidSplit { requested: usize, available: usize },
/// The parser variant does not support intra-compaction.
#[error("intra-compaction not supported by this parser variant")]
Unsupported,
/// LLM sampler construction failed.
#[error("compaction sampler build failed: {0}")]
SamplerBuild(String),
/// LLM sampler call could not be started.
#[error("compaction sampler start failed: {0}")]
SamplerStart(String),
/// LLM sampler emitted an error mid-stream.
#[error("compaction sampler error: {0}")]
SamplerStream(String),
/// `apply_steps_compaction` failed for a parser-specific reason
/// (e.g. SglangEngine rebuild error).
#[error("apply failed: {0}")]
Apply(String),
}
/// Pure decision function: should intra-compaction trigger now?
///
/// Returns `Some(trigger)` if all gating conditions are met; `None` otherwise.
/// Caller must additionally check the feature flag and/or any global kill
/// switch — this function deals only with the policy + step state.
///
/// `min_steps_before_compact` remains on [`IntraCompactionConfig`] for every
/// mode, but is **not** enforced when
/// [`mode`](IntraCompactionConfig::mode) is
/// [`IntraCompactionMode::FullReplace`] — that path matches kigi's
/// full-replace trigger (token threshold alone) so a large first-step prompt
/// can still compact. Partial modes still gate on min steps.
pub fn should_compact(
policy: &IntraCompactionConfig,
last_prompt_tokens: u32,
context_window: u32,
current_step: u32,
) -> Option<IntraCompactionTrigger> {
if !policy.enabled {
return None;
}
if context_window == 0 {
return None;
}
// FullReplace: token threshold only (field still present on config).
// Partial modes: skip early steps with little content to reduce.
if policy.mode != IntraCompactionMode::FullReplace
&& current_step < policy.min_steps_before_compact
{
return None;
}
let threshold = (context_window as u64 * policy.trigger_threshold_percent as u64 / 100) as u32;
if last_prompt_tokens <= threshold {
return None;
}
let percent = (last_prompt_tokens as u64 * 100 / context_window as u64).min(100) as u8;
Some(IntraCompactionTrigger {
last_prompt_tokens,
context_window,
percent,
step: current_step,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled_policy() -> IntraCompactionConfig {
IntraCompactionConfig {
enabled: true,
// Default mode is FullReplace — min_steps stored but not enforced.
trigger_threshold_percent: 85,
target_threshold_percent: 50,
min_steps_before_compact: 3,
..Default::default()
}
}
fn enabled_partial_policy(mode: IntraCompactionMode) -> IntraCompactionConfig {
IntraCompactionConfig {
mode,
..enabled_policy()
}
}
#[test]
fn returns_none_when_disabled() {
let mut p = enabled_policy();
p.enabled = false;
assert!(should_compact(&p, 90_000, 100_000, 10).is_none());
}
#[test]
fn returns_none_when_below_threshold() {
let p = enabled_policy();
// 84% of 100K = 84_000, threshold 85% = 85_000.
assert!(should_compact(&p, 84_000, 100_000, 10).is_none());
}
#[test]
fn returns_some_when_above_threshold() {
let p = enabled_policy();
let t = should_compact(&p, 90_000, 100_000, 10).expect("should trigger");
assert_eq!(t.last_prompt_tokens, 90_000);
assert_eq!(t.context_window, 100_000);
assert_eq!(t.percent, 90);
assert_eq!(t.step, 10);
}
#[test]
fn full_replace_keeps_field_but_ignores_min_steps() {
let p = enabled_policy();
assert_eq!(p.mode, IntraCompactionMode::FullReplace);
assert_eq!(p.min_steps_before_compact, 3);
// Field is present; FullReplace only uses the token threshold
// (parity with kigi auto-compact).
let t = should_compact(&p, 90_000, 100_000, 0).expect("should trigger");
assert_eq!(t.step, 0);
assert!(should_compact(&p, 90_000, 100_000, 2).is_some());
}
#[test]
fn partial_modes_enforce_min_steps() {
for mode in [
IntraCompactionMode::StepsOnly,
IntraCompactionMode::HistoryOnly,
IntraCompactionMode::HistoryThenSteps,
] {
let p = enabled_partial_policy(mode);
assert!(
should_compact(&p, 90_000, 100_000, 2).is_none(),
"{mode:?} should gate on min_steps"
);
let t = should_compact(&p, 90_000, 100_000, 3).expect("should trigger at min steps");
assert_eq!(t.step, 3);
}
}
#[test]
fn returns_none_when_context_window_zero() {
let p = enabled_policy();
assert!(should_compact(&p, 1_000, 0, 10).is_none());
}
#[test]
fn percent_caps_at_100() {
let p = enabled_policy();
let t = should_compact(&p, 200_000, 100_000, 10).expect("should trigger");
assert_eq!(t.percent, 100);
}
#[test]
fn boundary_exact_threshold_does_not_trigger() {
let p = enabled_policy();
// last_prompt_tokens == threshold: not strictly greater than.
assert!(should_compact(&p, 85_000, 100_000, 10).is_none());
// One above triggers.
assert!(should_compact(&p, 85_001, 100_000, 10).is_some());
}
}