Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/helpers/full_replace_compaction.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

400 lines
16 KiB
Rust

//! kigi's L5 wiring onto the shared full-replace engine
//! (`kigi_compaction::code_compaction`).
//!
//! The shared engine drives the sample → retry → degenerate/failure
//! classification loop via [`sample_full_replace_summary`](kigi_compaction::sample_full_replace_summary);
//! this module adapts kigi's transport and telemetry to its two seams:
//!
//! - [`ShellCompactionSampler`] wraps
//! [`generate_session_compact`](crate::session::helpers::session_compact::generate_session_compact)
//! as the shared [`CompactionSampler`]. It also stashes the full
//! [`CompactOutput`] of the last successful call so the L5 loop can still
//! record the streaming telemetry (TTFT / stream span / stop reason) that
//! the shared [`LlmCompactionOutput`] doesn't model.
//! - [`ShellFullReplaceObserver`] collects the per-attempt
//! [`CompactionAttempt`] rows, rejection counters, and emits the
//! `CompactionRetryDegraded` event — preserving the pre-migration telemetry.
//!
//! The verbatim → fitted → lossy **input ladder** and auto-compaction
//! suppression stay in L5 (`compaction.rs`), driven by the
//! `context_overflow` / `deterministic` flags on
//! [`FullReplaceError`](kigi_compaction::FullReplaceError).
use std::sync::Mutex;
use std::time::Duration;
use agent_client_protocol as acp;
use async_trait::async_trait;
use kigi_compaction::{
CompactionPrompt, CompactionSampleError, CompactionSampler, FullReplaceAttemptOutcome,
FullReplaceObserver, LlmCompactionOutput,
};
use kigi_sampler::SamplerConfig as SamplingConfig;
use kigi_sampling_types::{ConversationItem, HostedTool, ToolSpec};
use kigi_chat_state::compaction_utils::{
CompactionAttempt, MAX_CAPTURED_SUMMARY_CHARS, bound_captured_output,
};
use crate::sampling::Client as OaiCompatClient;
use crate::session::helpers::session_compact::{
CompactFailure, CompactOutput, build_compaction_chat_history, generate_session_compact,
};
/// Wraps `generate_session_compact` as the shared engine's
/// [`CompactionSampler`] for kigi's full-replace pass.
///
/// Holds the per-call request context the seam does not carry (tools, client,
/// session, config) and stashes the last successful [`CompactOutput`] so the
/// caller can recover the streaming telemetry not modeled by
/// [`LlmCompactionOutput`].
///
/// The summarization prompt is selected here by `use_short_prompt` (the
/// short-prompt harness uses the short self-summarization prompt; everyone
/// else the structured kigi prompt), so the shared `CompactionPrompt`
/// the engine passes is ignored — the engine builds the kigi prompt,
/// which equals what `build_compaction_chat_history(.., false)` appends, and
/// the short-prompt harness needs its own variant the engine can't produce.
pub(crate) struct ShellCompactionSampler {
use_short_prompt: bool,
user_context: Option<String>,
tools: Vec<ToolSpec>,
hosted_tools: Vec<HostedTool>,
client: OaiCompatClient,
session_id: acp::SessionId,
sampling_config: SamplingConfig,
/// Per-chunk idle timeout forwarded to `generate_session_compact`: a stalled
/// summarizer stream (no model-output chunk for this long) fails instead of
/// hanging.
idle_timeout: Duration,
/// Wall-clock budget (secs) forwarded to `generate_session_compact` as the
/// reasoning-runaway backstop; `0` disables it.
wall_clock_budget_secs: u64,
/// Full output of the most recent successful sample (for L5 telemetry).
last_success: Mutex<Option<CompactOutput>>,
}
impl ShellCompactionSampler {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
use_short_prompt: bool,
user_context: Option<String>,
tools: Vec<ToolSpec>,
hosted_tools: Vec<HostedTool>,
client: OaiCompatClient,
session_id: acp::SessionId,
sampling_config: SamplingConfig,
idle_timeout: Duration,
wall_clock_budget_secs: u64,
) -> Self {
Self {
use_short_prompt,
user_context,
tools,
hosted_tools,
client,
session_id,
sampling_config,
idle_timeout,
wall_clock_budget_secs,
last_success: Mutex::new(None),
}
}
/// Take the [`CompactOutput`] of the most recent successful sample, if any.
pub(crate) fn take_last_success(&self) -> Option<CompactOutput> {
self.last_success.lock().unwrap().take()
}
}
#[async_trait]
impl CompactionSampler for ShellCompactionSampler {
type Item = ConversationItem;
async fn sample_compaction(
&self,
turns: &[ConversationItem],
_prompt: &CompactionPrompt,
_timeout: Duration,
) -> Result<LlmCompactionOutput, CompactionSampleError> {
// Append the harness-selected summarization prompt as the final user
// message (compat short vs structured kigi), ignoring the shared
// engine's `_prompt` (see the struct doc).
let chat_history = build_compaction_chat_history(
turns.to_vec(),
self.user_context.as_deref(),
self.use_short_prompt,
);
match generate_session_compact(
chat_history,
self.tools.clone(),
self.hosted_tools.clone(),
self.client.clone(),
self.session_id.clone(),
&self.sampling_config,
self.idle_timeout,
self.wall_clock_budget_secs,
)
.await
{
Ok(output) => {
let response = output.content.clone();
*self.last_success.lock().unwrap() = Some(output);
Ok(LlmCompactionOutput {
response,
thinking: String::new(),
})
}
Err(failure) => Err(compact_failure_to_sample_error(failure)),
}
}
}
/// Map kigi's [`CompactFailure`] onto the shared engine's
/// [`CompactionSampleError`] so the shared retry loop classifies it the same
/// way the in-shell loop did:
///
/// - `Deterministic` → [`CompactionSampleError::Build`] (whose
/// `is_deterministic()` is `true`); a context-length overflow keeps its
/// message text so the engine's `is_context_length_error` check fires and
/// sets `context_overflow`.
/// - `Transient` → [`CompactionSampleError::Other`] (`is_deterministic()` is
/// `false`), so the engine retries it.
fn compact_failure_to_sample_error(failure: CompactFailure) -> CompactionSampleError {
let (deterministic, err) = match failure {
CompactFailure::Deterministic(err) => (true, err),
CompactFailure::Transient(err) => (false, err),
};
let message = acp_error_message(&err);
if deterministic {
CompactionSampleError::Build(message)
} else {
CompactionSampleError::Other(anyhow::anyhow!(message))
}
}
/// Render the human-readable detail an `acp::Error` carries in its `data`
/// field (where `classify_*` stash `"compact failed: <upstream>"`).
fn acp_error_message(err: &acp::Error) -> String {
err.data
.as_ref()
.and_then(|d| d.as_str())
.unwrap_or("<no data>")
.to_string()
}
/// Collected telemetry from a full-replace pass, drained by the L5 loop after
/// the shared engine returns.
pub(crate) struct FullReplaceTelemetry {
pub attempts: u32,
pub attempt_details: Vec<CompactionAttempt>,
pub degenerate_rejections: u32,
pub transient_rejections: u32,
pub deterministic_rejections: u32,
/// Raw text of the last degenerate (rejected) summary, for the artifact.
pub last_rejected_summary: Option<String>,
}
#[derive(Default)]
struct ObserverState {
attempts: u32,
attempt_details: Vec<CompactionAttempt>,
degenerate_rejections: u32,
transient_rejections: u32,
deterministic_rejections: u32,
last_rejected_summary: Option<String>,
last_error_msg: Option<String>,
}
/// [`FullReplaceObserver`] that reproduces kigi's per-attempt telemetry:
/// `CompactionAttempt` rows, rejection counters, the `CompactionRetryDegraded`
/// event, and the warn/error tracing — without the shared engine depending on
/// a telemetry backend.
pub(crate) struct ShellFullReplaceObserver {
session_id: String,
estimated_input_tokens: u64,
retry_delay_secs: u64,
state: Mutex<ObserverState>,
}
impl ShellFullReplaceObserver {
pub(crate) fn new(
session_id: String,
estimated_input_tokens: u64,
retry_delay_secs: u64,
) -> Self {
Self {
session_id,
estimated_input_tokens,
retry_delay_secs,
state: Mutex::new(ObserverState::default()),
}
}
/// Cumulative number of attempts so far (across all input-ladder stages).
/// Read mid-loop to label the `input_overflow` retry event.
pub(crate) fn attempt_count(&self) -> u32 {
self.state.lock().unwrap().attempts
}
/// Whether any attempt so far produced a degenerate summary — lets the L5
/// loop distinguish degenerate-exhausted from empty-exhausted.
pub(crate) fn degenerate_seen(&self) -> bool {
self.state.lock().unwrap().degenerate_rejections > 0
}
/// The most recent rendered error/diagnostic detail, for `last_error`.
pub(crate) fn last_error_message(&self) -> Option<String> {
self.state.lock().unwrap().last_error_msg.clone()
}
/// Drain the collected telemetry. The cumulative attempt count spans all
/// input-ladder stages because the same observer instance is shared across
/// every per-stage call.
pub(crate) fn into_telemetry(self) -> FullReplaceTelemetry {
let s = self.state.into_inner().unwrap();
FullReplaceTelemetry {
attempts: s.attempts,
attempt_details: s.attempt_details,
degenerate_rejections: s.degenerate_rejections,
transient_rejections: s.transient_rejections,
deterministic_rejections: s.deterministic_rejections,
last_rejected_summary: s.last_rejected_summary,
}
}
}
impl FullReplaceObserver for ShellFullReplaceObserver {
fn on_attempt(&self, _attempt: u32, outcome: &FullReplaceAttemptOutcome<'_>) {
let mut s = self.state.lock().unwrap();
// The shared `attempt` resets per ladder stage; keep a cumulative count
// so artifact rows match the pre-migration numbering.
s.attempts += 1;
let attempt = s.attempts;
match outcome {
FullReplaceAttemptOutcome::Success { summary } => {
s.attempt_details.push(CompactionAttempt {
attempt,
outcome: "success".to_string(),
summary_chars: summary.chars().count() as u64,
summary: None,
error: None,
});
}
FullReplaceAttemptOutcome::Degenerate {
summary,
will_retry,
} => {
s.degenerate_rejections += 1;
let summary_chars = summary.chars().count();
s.attempt_details.push(CompactionAttempt {
attempt,
outcome: "degenerate".to_string(),
summary_chars: summary_chars as u64,
summary: Some(bound_captured_output(summary, MAX_CAPTURED_SUMMARY_CHARS)),
error: None,
});
s.last_rejected_summary = Some((*summary).to_string());
s.last_error_msg = Some(format!(
"compact failed: degenerate summary \
({summary_chars} chars for ~{} input tokens)",
self.estimated_input_tokens
));
if *will_retry {
tracing::warn!(
session_id = %self.session_id,
attempt,
summary_chars,
estimated_input_tokens = self.estimated_input_tokens,
retry_delay_secs = self.retry_delay_secs,
"Compaction produced a degenerate summary, retrying in {} seconds...",
self.retry_delay_secs
);
} else {
tracing::error!(
session_id = %self.session_id,
attempt,
summary_chars,
estimated_input_tokens = self.estimated_input_tokens,
"Compaction produced only degenerate summaries after max retries"
);
}
}
FullReplaceAttemptOutcome::EmptyResponse { .. } => {
// The shell surfaces an empty response as a transient error
// (`generate_session_compact` returns `Transient`), so it never
// reaches the shared `Ok("")` branch; handle defensively.
s.transient_rejections += 1;
let msg = "compact failed: model returned empty response".to_string();
s.attempt_details.push(CompactionAttempt {
attempt,
outcome: "transient".to_string(),
summary_chars: 0,
summary: None,
error: Some(msg.clone()),
});
s.last_error_msg = Some(msg);
}
FullReplaceAttemptOutcome::Failure {
message,
deterministic,
context_overflow,
will_retry,
} => {
// A context overflow is recorded as a `deterministic` attempt
// (matching the pre-migration row) but does NOT count toward
// `deterministic_rejections` — the L5 ladder steps down on it
// and tracks its own `input_overflow_rejections`.
if *deterministic {
if !*context_overflow {
s.deterministic_rejections += 1;
tracing::error!(
session_id = %self.session_id,
attempt,
error = %message,
"Compaction failed (deterministic error class, no further retries)"
);
}
s.attempt_details.push(CompactionAttempt {
attempt,
outcome: "deterministic".to_string(),
summary_chars: 0,
summary: None,
error: Some((*message).to_string()),
});
} else {
s.transient_rejections += 1;
s.attempt_details.push(CompactionAttempt {
attempt,
outcome: "transient".to_string(),
summary_chars: 0,
summary: None,
error: Some((*message).to_string()),
});
if *will_retry {
tracing::warn!(
session_id = %self.session_id,
attempt,
retry_delay_secs = self.retry_delay_secs,
error = %message,
"Compaction attempt {} failed, retrying in {} seconds...",
attempt,
self.retry_delay_secs
);
} else {
tracing::error!(
session_id = %self.session_id,
attempt,
error = %message,
"Compaction failed after max retries"
);
}
}
s.last_error_msg = Some((*message).to_string());
}
}
}
}