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.
248 lines
8.5 KiB
Rust
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());
|
|
}
|
|
}
|