M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
//! 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 = "grok-4.20";
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Intra-turn compaction — orchestration of the
|
||||
//! `select → sample → guard → commit` pass, generic over
|
||||
//! [`CompactionItemBuilder`](crate::CompactionItemBuilder).
|
||||
//!
|
||||
//! Harness wiring (trigger call sites, LLM transport, metrics backends,
|
||||
//! state commit) stays per-harness; the Grok chat host
|
||||
//! wraps these entry points with its tokenizer + metrics observers.
|
||||
|
||||
pub mod compact;
|
||||
pub mod config;
|
||||
pub mod observer;
|
||||
pub mod traits;
|
||||
pub mod trigger;
|
||||
|
||||
pub use compact::{
|
||||
apply_full_replace_compaction, apply_history_compaction, apply_intra_compaction,
|
||||
apply_steps_compaction, error_status_label,
|
||||
};
|
||||
pub use config::{
|
||||
DEFAULT_COMPACTION_MODEL_NAME, IntraCompactionConfig, IntraCompactionMode, IntraSummarizer,
|
||||
};
|
||||
pub use observer::IntraCompactionObserver;
|
||||
pub use traits::{CompactionStreamProc, CompactionTarget};
|
||||
pub use trigger::{
|
||||
IntraCompactionError, IntraCompactionResult, IntraCompactionTrigger, should_compact,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Observability seam for intra-compaction.
|
||||
//!
|
||||
//! The shared orchestrator reports terminal outcomes through this trait so
|
||||
//! each harness can emit its own metrics (Grok chat: its own metrics
|
||||
//! 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 — Grok chat's
|
||||
//! observer preserves them byte-for-byte.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::traits::CompactionTarget;
|
||||
|
||||
/// Receives intra-compaction outcomes. All methods default to no-ops.
|
||||
pub trait IntraCompactionObserver: Send + Sync {
|
||||
/// A pass ended in an error. `status` is the stable, low-cardinality
|
||||
/// label from [`super::error_status_label`].
|
||||
fn on_error(&self, _status: &'static str) {}
|
||||
|
||||
/// A single pass succeeded (called once per successful pass — twice for
|
||||
/// a `HistoryThenSteps` run where both passes fire).
|
||||
fn on_success(
|
||||
&self,
|
||||
_target: CompactionTarget,
|
||||
_tokens_before: u32,
|
||||
_tokens_after: u32,
|
||||
_turns_compacted: u32,
|
||||
_elapsed: Duration,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op observer for tests and harnesses without metrics.
|
||||
impl IntraCompactionObserver for () {}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! 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.
|
||||
#[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.
|
||||
Steps,
|
||||
/// Compact prior conversation-history turns (user/assistant exchanges
|
||||
/// 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 — grok-build's full-replace strategy. No tail is
|
||||
/// kept; the read-view is [`CompactionStreamProc::get_all_turns_for_compaction`].
|
||||
FullReplace,
|
||||
}
|
||||
|
||||
impl CompactionTarget {
|
||||
/// Stable metric label for this target.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Steps => "steps",
|
||||
Self::History => "history",
|
||||
Self::FullReplace => "full_replace",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal interface the compaction orchestrator needs from the agent's
|
||||
/// stream processor. Implemented by Grok chat's
|
||||
/// `StreamProcessor` (`Item = Arc<GrokTurn>`).
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Implementations that don't support a particular target return
|
||||
/// [`IntraCompactionError::Unsupported`] from the matching match arm.
|
||||
#[async_trait]
|
||||
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.
|
||||
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.
|
||||
///
|
||||
/// Default impl returns empty — implementations that do not support
|
||||
/// history compaction will have nothing to compact.
|
||||
async fn get_history_turns_for_compaction(&self) -> Vec<Self::Item> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Get the **whole** conversation — prior history followed by the
|
||||
/// accumulated step turns, oldest first. Candidates for **full-replace**
|
||||
/// (`CompactionTarget::FullReplace`) compaction.
|
||||
///
|
||||
/// 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 `Self::Item: Send` bound lets the default hold the history vec across
|
||||
/// the second `await` while keeping the boxed future `Send`; every concrete
|
||||
/// item type (`Arc<GrokTurn>`) already satisfies it.
|
||||
async fn get_all_turns_for_compaction(&self) -> Vec<Self::Item>
|
||||
where
|
||||
Self::Item: Send,
|
||||
{
|
||||
let mut all = self.get_history_turns_for_compaction().await;
|
||||
all.extend(self.get_accumulated_turns_for_compaction().await);
|
||||
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`.
|
||||
///
|
||||
/// Implementations dispatch internally on `target` to the steps or
|
||||
/// history specific path. On invalid input
|
||||
/// (`n_turns_to_remove > view.len()`), returns
|
||||
/// [`IntraCompactionError::InvalidSplit`] and leaves state untouched.
|
||||
async fn replace_with_compaction(
|
||||
&self,
|
||||
target: CompactionTarget,
|
||||
n_turns_to_remove: usize,
|
||||
compaction_turn: Self::Item,
|
||||
) -> Result<(), IntraCompactionError>;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 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 grok-build'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 grok-build 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user