F2 — fixed three-platform registry in kigi-models: kimi-code (subscription, OAuth bearer, base kigi_env::coding_api_base_url()), moonshot-cn (https://api.moonshot.cn/v1), moonshot-ai (https://api.moonshot.ai/v1) with kimi-k model-prefix filtering. Moonshot API keys via KIGI_MOONSHOT_CN_API_KEY / KIGI_MOONSHOT_AI_API_KEY (+ KIGI_MOONSHOT_API_KEY shared fallback) or ~/.kigi/config.toml; values redacted from logs/display. F4 — model catalog now syncs from GET {base}/models (Bearer auth, wire shape per official kimi-cli: id/context_length/supports_reasoning/ supports_image_in/supports_video_in/display_name) with the official capability-derivation rules (thinking / always_thinking-in-name / kimi-k2 implicit set). Managed keys {platform_id}/{model_id}; default model = first list entry; default thinking iff capabilities contain thinking/always_thinking. Sync failure → last cache; no cache → built-in fallback table seeded from ids sourced in official kimi-cli (kimi-for-coding, kimi-k2-turbo-preview, kimi-k2-thinking-turbo). 401 during sync forces one token refresh and retries. Model resolution priority preserved: CLI > env > config > server > fallback. Grok model artifacts (grok-4*/grok-build catalog, tier gating remnants) removed from non-test code. All first-party endpoints re-verified live: device_authorization mints real codes; /models on all three platforms answers with real API auth errors when unauthenticated. Gates: check/clippy --all-targets 0/0, fmt clean, deny ok, kigi-shell lib 5136 green, kigi-tui lib 6819 green, kigi-models 8.
367 lines
16 KiB
Rust
367 lines
16 KiB
Rust
//! Configuration for intra-compaction.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Which targets intra-compaction may compact.
|
|
///
|
|
/// - `FullReplace` (default): grok-build's full-replace strategy — summarize
|
|
/// the *whole* conversation (prior history + accumulated steps) and rebuild
|
|
/// context from scratch as `[system] + [summary]`. Drives the shared
|
|
/// `code_compaction` summarizer directly; no tail is kept.
|
|
/// - `StepsOnly`: only compact accumulated step turns within the current
|
|
/// agent loop (keeps the recent tail).
|
|
/// - `HistoryOnly`: only compact prior conversation history; leave the
|
|
/// current loop's accumulated step turns alone.
|
|
/// - `HistoryThenSteps`: compact history first, then — only if the
|
|
/// accumulated step turns still account for a large enough share of the
|
|
/// prompt (controlled by [`IntraCompactionConfig::steps_trigger_ratio`]) —
|
|
/// also compact the current loop's steps.
|
|
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum IntraCompactionMode {
|
|
#[default]
|
|
FullReplace,
|
|
StepsOnly,
|
|
HistoryOnly,
|
|
HistoryThenSteps,
|
|
}
|
|
|
|
/// Which *summarization algorithm* intra-compaction uses to turn the selected
|
|
/// turns into the replacement summary. Orthogonal to [`IntraCompactionMode`]
|
|
/// (which picks *what* to compact); this picks *how* the summary is produced.
|
|
#[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.
|
|
#[default]
|
|
Shared,
|
|
/// Previous intra algorithm: per-target prompt (`format_compaction_prompt`
|
|
/// / history dev+user prompts), no cleaning. Kept for switchability.
|
|
Legacy,
|
|
}
|
|
|
|
/// Intra-compaction configuration for an agent's sample loop.
|
|
///
|
|
/// This is the intra-compaction analog of
|
|
/// [`InterCompactionConfig`](crate::inter_compaction::InterCompactionConfig).
|
|
/// The structural difference is *where the config lives*:
|
|
/// - inter-compaction runs as a singleton between-turn service, so it has one
|
|
/// global config resolved from service YAML.
|
|
/// - intra-compaction runs **per-agent** inside the harness sampler loop, so
|
|
/// this struct is embedded directly in each agent's spec. Defaults come from
|
|
/// the [`Default`] impl below; an agent can optionally override them under
|
|
/// `agents.<name>.intra_compaction` in agent config YAML (none set today).
|
|
/// There is no standalone service config for it.
|
|
///
|
|
/// When `enabled = false` (default), no intra-compaction runs for that agent.
|
|
///
|
|
/// Uses **percentage** thresholds (borrowed from grok-shell's
|
|
/// `CompactionPolicy`) for portability across models with different context
|
|
/// windows.
|
|
///
|
|
/// The fields are split into two groups: a **common** block that every mode
|
|
/// stores (enablement, trigger gate fields, reduction guards, the compaction
|
|
/// LLM call, audit) and a **mode-specific** block whose fields are each read by
|
|
/// only a subset of modes (see the per-field `[...]` tags). In particular,
|
|
/// `FullReplace` — the default — ignores `min_steps_before_compact` at trigger
|
|
/// time (token threshold only, matching grok-build) and also ignores
|
|
/// `summarizer`, `target_threshold_percent`, `steps_trigger_ratio`, and
|
|
/// `user_message_truncate_chars`. The field remains on this config for all
|
|
/// modes (YAML / remote agent config / defaults); only enforcement is mode-dependent.
|
|
///
|
|
/// **Unset / blank → default.** Every field has a default value (the [`Default`]
|
|
/// impl below). Leaving a field unset — absent in YAML, or blank in an agent
|
|
/// config editor — keeps that default; each field's doc states its default
|
|
/// inline as `Default: …`. Note that remote agent-config protos may only
|
|
/// surface a *subset* of these fields (`enabled`, `mode`,
|
|
/// `trigger_threshold_percent`, `target_threshold_percent`,
|
|
/// `min_steps_before_compact` [ignored by FullReplace], `steps_trigger_ratio`
|
|
/// [HistoryThenSteps], `compaction_model_name`); the remaining fields are
|
|
/// never sent remotely and therefore always take the defaults here.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct IntraCompactionConfig {
|
|
// ───────────────────────────── 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 --
|
|
/// Enable intra-compaction between steps. Default: `false` (disabled).
|
|
pub enabled: bool,
|
|
|
|
/// Which targets intra-compaction may compact. See [`IntraCompactionMode`].
|
|
/// Default: `FullReplace`.
|
|
pub mode: IntraCompactionMode,
|
|
|
|
// -- 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`.
|
|
pub trigger_threshold_percent: u8,
|
|
|
|
/// Minimum number of completed steps before compaction can trigger.
|
|
/// Default: `3`. Always stored on [`IntraCompactionConfig`]; agent YAML
|
|
/// may set it for any mode.
|
|
///
|
|
/// **Enforcement:** applied for `StepsOnly` / `HistoryOnly` /
|
|
/// `HistoryThenSteps`. **Ignored** when [`mode`](Self::mode) is
|
|
/// [`IntraCompactionMode::FullReplace`] (token threshold alone, same idea
|
|
/// as grok-build full-replace auto-compact). Worthless early passes are
|
|
/// still limited by [`min_compactable_tokens`](Self::min_compactable_tokens)
|
|
/// / reduction guards after a trigger.
|
|
pub min_steps_before_compact: u32,
|
|
|
|
// -- 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`.
|
|
pub min_compactable_tokens: u32,
|
|
|
|
/// Discard the compaction if it didn't shrink tokens below this ratio.
|
|
/// Matches inter-compaction's `0.8` (= 20% minimum reduction) guard.
|
|
/// Default: `0.8`.
|
|
pub max_reduction_ratio: f64,
|
|
|
|
// -- 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>,
|
|
|
|
/// End-to-end timeout for the compaction LLM call.
|
|
/// `120` by default, aligned with the inter-compaction service default.
|
|
/// Default: `120`.
|
|
pub sampling_timeout_secs: u64,
|
|
|
|
/// Max attempts for the compaction LLM call (effective value is `max(1)`).
|
|
/// This is the *total* number of tries, not retries-on-top: `2` (default)
|
|
/// = first try + one retry on a transient failure (timeout / empty / stream
|
|
/// / start), with `retry_delay_secs` between tries. Matches the
|
|
/// inter-compaction service default. Default: `2`.
|
|
pub max_attempts: u32,
|
|
/// Delay between retries. Default: `3`.
|
|
pub retry_delay_secs: u64,
|
|
|
|
// -- Audit --
|
|
/// Version string for the compaction (e.g. `"intra-v1"`).
|
|
/// Recorded in audit logs. Default: `"intra-v1"`.
|
|
pub compaction_version: String,
|
|
|
|
// ───────────────────────────── 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.
|
|
|
|
// -- Partial modes only: StepsOnly / HistoryOnly / HistoryThenSteps.
|
|
// FullReplace ignores both `summarizer` and `target_threshold_percent` —
|
|
// it always uses the shared summarizer and replaces the whole
|
|
// conversation, so it keeps no tail and never reads a target threshold. --
|
|
/// [StepsOnly / HistoryOnly / HistoryThenSteps] Which summarization
|
|
/// algorithm to use. See [`IntraSummarizer`]. Default: [`IntraSummarizer::Shared`].
|
|
///
|
|
/// Ignored by `FullReplace`, which *is* the shared `code_compaction` path
|
|
/// and always summarizes via `Shared` regardless of this value. (Not
|
|
/// always exposed by remote agent-config protos — defaults apply there.)
|
|
pub summarizer: IntraSummarizer,
|
|
|
|
/// [StepsOnly / HistoryOnly / HistoryThenSteps] Target usage percentage
|
|
/// after compaction. The compactor keeps enough recent turns to bring usage
|
|
/// below this. Default: `50`.
|
|
///
|
|
/// Only used by the partial modes for tail-keep selection; `FullReplace`
|
|
/// replaces everything and never reads it.
|
|
pub target_threshold_percent: u8,
|
|
|
|
// -- HistoryThenSteps only --
|
|
/// [HistoryThenSteps mode] Only compact accumulated step turns when their
|
|
/// token count exceeds this fraction of the history token count.
|
|
///
|
|
/// Rationale: when both history and current steps are large, compacting
|
|
/// history first usually buys enough budget. Compacting recent steps
|
|
/// loses fine-grained context (tool results, code snippets, recent
|
|
/// errors) and should only happen when steps themselves are large
|
|
/// relative to history.
|
|
///
|
|
/// At `0.0`, steps are always compacted (after history). At very large
|
|
/// values, steps compaction is effectively disabled in
|
|
/// `HistoryThenSteps` mode. Default: `0.3`.
|
|
pub steps_trigger_ratio: f64,
|
|
|
|
// -- History target only: HistoryOnly + HistoryThenSteps' history pass.
|
|
// Ignored by FullReplace and StepsOnly (neither emits a user-queries
|
|
// preamble). --
|
|
/// [HistoryOnly / HistoryThenSteps] Character threshold above which an
|
|
/// original user message gets middle-truncated when included in the
|
|
/// `<grok_user_queries>` preamble prepended to the history compaction
|
|
/// summary. Mirrors the inter-compaction Basic threshold. Has no
|
|
/// effect for `Steps` target — steps compaction has no user-queries
|
|
/// preamble. Default: `3000`. (Not always exposed by remote agent-config
|
|
/// protos — defaults apply there.)
|
|
pub user_message_truncate_chars: u32,
|
|
}
|
|
|
|
/// Code-level default compaction model name (last resort).
|
|
///
|
|
/// Override order: agent field (non-blank) → service YAML (inter) /
|
|
/// agent config → this constant. See crate-level docs on
|
|
/// [`crate::DEFAULT_COMPACTION_MODEL_NAME`].
|
|
pub const DEFAULT_COMPACTION_MODEL_NAME: &str = "kimi-for-coding";
|
|
|
|
impl IntraCompactionConfig {
|
|
/// Agent field; blank/`None` → [`DEFAULT_COMPACTION_MODEL_NAME`].
|
|
pub fn effective_compaction_model_name(&self) -> &str {
|
|
self.compaction_model_name
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or(DEFAULT_COMPACTION_MODEL_NAME)
|
|
}
|
|
}
|
|
|
|
impl Default for IntraCompactionConfig {
|
|
fn default() -> Self {
|
|
// These are the unset/blank defaults: the value each field takes when it
|
|
// is absent in YAML or left blank in an agent config editor.
|
|
Self {
|
|
// Common (all modes; min_steps stored always, enforced except FullReplace)
|
|
enabled: false,
|
|
mode: IntraCompactionMode::default(),
|
|
trigger_threshold_percent: 85,
|
|
min_steps_before_compact: 3,
|
|
min_compactable_tokens: 5_000,
|
|
max_reduction_ratio: 0.8,
|
|
compaction_model_name: Some(DEFAULT_COMPACTION_MODEL_NAME.to_string()),
|
|
sampling_timeout_secs: 120,
|
|
max_attempts: 2,
|
|
retry_delay_secs: 3,
|
|
compaction_version: "intra-v1".to_string(),
|
|
// Mode-specific
|
|
summarizer: IntraSummarizer::default(),
|
|
target_threshold_percent: 50,
|
|
steps_trigger_ratio: 0.3,
|
|
user_message_truncate_chars: 3_000,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_is_disabled() {
|
|
let p = IntraCompactionConfig::default();
|
|
assert!(!p.enabled);
|
|
assert_eq!(p.mode, IntraCompactionMode::FullReplace);
|
|
assert_eq!(p.summarizer, IntraSummarizer::Shared);
|
|
assert_eq!(p.trigger_threshold_percent, 85);
|
|
assert_eq!(p.target_threshold_percent, 50);
|
|
assert_eq!(
|
|
p.compaction_model_name.as_deref(),
|
|
Some(DEFAULT_COMPACTION_MODEL_NAME)
|
|
);
|
|
assert_eq!(
|
|
p.effective_compaction_model_name(),
|
|
DEFAULT_COMPACTION_MODEL_NAME
|
|
);
|
|
assert_eq!(p.max_attempts, 2);
|
|
assert_eq!(p.retry_delay_secs, 3);
|
|
assert!((p.steps_trigger_ratio - 0.3).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn blank_or_none_compaction_model_name_uses_default() {
|
|
let none = IntraCompactionConfig {
|
|
compaction_model_name: None,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
none.effective_compaction_model_name(),
|
|
DEFAULT_COMPACTION_MODEL_NAME
|
|
);
|
|
let empty = IntraCompactionConfig {
|
|
compaction_model_name: Some(String::new()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
empty.effective_compaction_model_name(),
|
|
DEFAULT_COMPACTION_MODEL_NAME
|
|
);
|
|
let ws = IntraCompactionConfig {
|
|
compaction_model_name: Some(" ".into()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
ws.effective_compaction_model_name(),
|
|
DEFAULT_COMPACTION_MODEL_NAME
|
|
);
|
|
let custom = IntraCompactionConfig {
|
|
compaction_model_name: Some("custom-model".into()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(custom.effective_compaction_model_name(), "custom-model");
|
|
}
|
|
|
|
#[test]
|
|
fn default_mode_is_full_replace() {
|
|
assert_eq!(
|
|
IntraCompactionMode::default(),
|
|
IntraCompactionMode::FullReplace
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mode_serde_round_trip() {
|
|
for (mode, s) in [
|
|
(IntraCompactionMode::FullReplace, "\"full_replace\""),
|
|
(IntraCompactionMode::StepsOnly, "\"steps_only\""),
|
|
(IntraCompactionMode::HistoryOnly, "\"history_only\""),
|
|
(
|
|
IntraCompactionMode::HistoryThenSteps,
|
|
"\"history_then_steps\"",
|
|
),
|
|
] {
|
|
let json = serde_json::to_string(&mode).unwrap();
|
|
assert_eq!(json, s);
|
|
let back: IntraCompactionMode = serde_json::from_str(s).unwrap();
|
|
assert_eq!(back, mode);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn summarizer_defaults_to_shared() {
|
|
assert_eq!(IntraSummarizer::default(), IntraSummarizer::Shared);
|
|
assert_eq!(
|
|
IntraCompactionConfig::default().summarizer,
|
|
IntraSummarizer::Shared
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summarizer_serde_round_trip() {
|
|
for (s, json) in [
|
|
(IntraSummarizer::Shared, "\"shared\""),
|
|
(IntraSummarizer::Legacy, "\"legacy\""),
|
|
] {
|
|
assert_eq!(serde_json::to_string(&s).unwrap(), json);
|
|
let back: IntraSummarizer = serde_json::from_str(json).unwrap();
|
|
assert_eq!(back, s);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_round_trip_with_serde_default() {
|
|
// Partial JSON — `#[serde(default)]` fills missing fields.
|
|
let json = r#"{
|
|
"enabled": true,
|
|
"trigger_threshold_percent": 80
|
|
}"#;
|
|
let p: IntraCompactionConfig = serde_json::from_str(json).unwrap();
|
|
assert!(p.enabled);
|
|
assert_eq!(p.trigger_threshold_percent, 80);
|
|
// Defaults preserved.
|
|
assert_eq!(p.target_threshold_percent, 50);
|
|
assert_eq!(p.compaction_version, "intra-v1");
|
|
}
|
|
}
|