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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,202 @@
//! Compacted-history assembly (grok-build's rebuild structure, generic).
//!
//! Moved from `kigi-chat-state::compaction_utils::build_compacted_history` and
//! made generic over a write-side item factory so any harness can assemble
//! the canonical post-compaction history:
//!
//! ```text
//! [SP, UP', AGENTS_MD?, UQ_last?, recent…, summary, reminder?]
//! ```
//!
//! grok-build is the canonical harness. The summary carrier text is built by
//! [`super::summary::format_compact_summary_content`].
use crate::item::CompactionItemFactory;
use super::summary::{format_compact_summary_content, wrap_user_query};
/// Input data for building a compacted conversation history.
///
/// All fields are plain data — no I/O, no network, no shell dependencies.
/// The caller is responsible for:
/// - Generating the `compaction_summary` via the LLM.
/// - Rendering the optional `system_reminder` (which may depend on
/// harness-specific backends such as memory search).
/// - Providing the `user_message_prefix` (e.g. `<user_info>` block).
/// - Extracting `last_user_query` / `recent_messages` from its own state.
pub struct CompactedHistoryParts<T> {
/// The original system message from the conversation.
pub system_message: T,
/// The user-info / project-layout prefix (not wrapped in `<user_query>`).
pub user_message_prefix: String,
/// Pre-rendered AGENTS.md `<system-reminder>` block to re-inject after the
/// user prefix. `None` means no project instructions to re-inject.
pub agents_md_reminder: Option<String>,
/// The last real user query text (raw, unwrapped).
pub last_user_query: Option<String>,
/// Messages retained verbatim from after the last real user turn.
pub recent_messages: Vec<T>,
/// The LLM-generated compaction summary text.
pub compaction_summary: String,
/// An optional pre-rendered `<system-reminder>` block to append after the
/// summary. `None` means no state reminder is appended.
pub system_reminder: Option<String>,
/// Pre-built transcript hint appended to the summary (`None` to omit).
pub transcript_hint: Option<String>,
}
/// Build the compacted conversation history from pure data inputs.
///
/// The returned `Vec<T>` is structured as:
///
/// 1. **System message** -- the original system prompt.
/// 2. **User message prefix** -- e.g. `<user_info>` block (no `<user_query>` tags).
/// 3. **AGENTS.md reminder** (if any) -- project instructions re-injected verbatim.
/// 4. **Last user query** (if any) -- wrapped in `<user_query>` tags.
/// 5. **Recent messages** (if any) -- retained verbatim from after the last
/// real user turn.
/// 6. **Compaction summary** -- with the optional `<system-reminder>`
/// appended as a separate message.
///
/// This is a pure function with no I/O.
pub fn assemble_compacted_history<T: CompactionItemFactory>(
parts: CompactedHistoryParts<T>,
) -> Vec<T> {
let mut compacted: Vec<T> = vec![
parts.system_message,
T::new_user_meta(parts.user_message_prefix),
];
// Re-inject AGENTS.md as a user message so project instructions survive
// compaction verbatim (not dependent on the summarizer). The
// `ProjectInstructions` tag is what the spawn-time idempotence guard
// recognizes on resume, so post-compaction sessions stay duplicate-free.
if let Some(ref reminder) = parts.agents_md_reminder {
compacted.push(T::new_project_instructions(reminder.clone()));
}
// Last user query wrapped in <user_query> tags for consistency.
if let Some(ref last_query) = parts.last_user_query {
compacted.push(T::new_user(wrap_user_query(last_query.as_str())));
}
// grok-build keeps the legacy `<user_query>`-wrapped continuation text and
// appends the transcript hint after the continuation summary.
let mut formatted_summary = format_compact_summary_content(&parts.compaction_summary);
if let Some(ref hint) = parts.transcript_hint {
formatted_summary.push_str(hint);
}
let summary_item = T::new_user_meta(formatted_summary);
// Recent messages come first, then the summary.
for msg in parts.recent_messages {
compacted.push(msg);
}
compacted.push(summary_item);
if let Some(ref reminder) = parts.system_reminder {
compacted.push(T::new_system_reminder(reminder.clone()));
}
compacted
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal mock item recording which factory constructor produced it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum MockItem {
System(String),
User(String),
UserMeta(String),
ProjectInstructions(String),
SystemReminder(String),
Recent(String),
}
impl CompactionItemFactory for MockItem {
fn new_user(text: String) -> Self {
Self::User(text)
}
fn new_user_meta(text: String) -> Self {
Self::UserMeta(text)
}
fn new_project_instructions(text: String) -> Self {
Self::ProjectInstructions(text)
}
fn new_system_reminder(text: String) -> Self {
Self::SystemReminder(text)
}
}
fn parts(recent: Vec<MockItem>) -> CompactedHistoryParts<MockItem> {
CompactedHistoryParts {
system_message: MockItem::System("sys".into()),
user_message_prefix: "<user_info>OS: macos</user_info>".into(),
agents_md_reminder: Some("AGENTS.md content".into()),
last_user_query: Some("fix the bug".into()),
recent_messages: recent,
compaction_summary: "Summary: did things.".into(),
system_reminder: Some("<system-reminder>state</system-reminder>".into()),
transcript_hint: None,
}
}
#[test]
fn grok_build_order_recent_before_summary() {
let recent = vec![MockItem::Recent("a1".into()), MockItem::Recent("t1".into())];
let out = assemble_compacted_history(parts(recent));
// [sys, prefix, agents_md, query, a1, t1, summary, reminder]
assert_eq!(out.len(), 8);
assert_eq!(out[0], MockItem::System("sys".into()));
assert_eq!(
out[1],
MockItem::UserMeta("<user_info>OS: macos</user_info>".into())
);
assert_eq!(
out[2],
MockItem::ProjectInstructions("AGENTS.md content".into())
);
assert_eq!(
out[3],
MockItem::User("<user_query>\nfix the bug\n</user_query>".into())
);
assert_eq!(out[4], MockItem::Recent("a1".into()));
assert_eq!(out[5], MockItem::Recent("t1".into()));
let MockItem::UserMeta(summary) = &out[6] else {
panic!("expected UserMeta summary, got {:?}", out[6]);
};
assert!(summary.starts_with("This session is being continued"));
assert_eq!(
out[7],
MockItem::SystemReminder("<system-reminder>state</system-reminder>".into())
);
}
#[test]
fn omits_optional_sections() {
let mut p = parts(vec![]);
p.agents_md_reminder = None;
p.last_user_query = None;
p.system_reminder = None;
let out = assemble_compacted_history(p);
// [sys, prefix, summary]
assert_eq!(out.len(), 3);
assert!(
matches!(&out[2], MockItem::UserMeta(s) if s.starts_with("This session is being continued"))
);
}
#[test]
fn appends_transcript_hint_after_summary() {
let mut p = parts(vec![]);
p.transcript_hint = Some("\n\n<transcript_location>/x</transcript_location>".into());
let out = assemble_compacted_history(p);
let MockItem::UserMeta(summary) = &out[4] else {
panic!("expected UserMeta summary, got {:?}", out[4]);
};
assert!(summary.ends_with("</transcript_location>"));
}
}
@@ -0,0 +1,608 @@
//! grok-build's full-replace compaction pass.
//!
//! grok-build does not select a tail to keep; it summarizes the whole
//! conversation and rebuilds a fresh history from scratch. This module is the
//! transport-agnostic orchestration of that pass:
//!
//! ```text
//! build prompt → sample (retry + classify) → clean → assemble
//! ```
//!
//! Per-harness concerns stay in the product host (for example `kigi-shell`): the triggers, the
//! conversation *gathering / sanitization* that produces `llm_turns`, the
//! verbatim→fitted→lossy input ladder, the live LLM transport (the
//! [`CompactionSampler`] impl), persistence/replay, and the rendering of
//! `system_reminder`. This function takes those as inputs and returns the
//! rebuilt history; it never commits or persists.
use std::time::{Duration, Instant};
use tracing::info;
use crate::item::CompactionItemFactory;
use crate::prompt::CompactionPrompt;
use crate::sampler::CompactionSampler;
use super::assemble::{CompactedHistoryParts, assemble_compacted_history};
use super::config::FullReplaceConfig;
use super::observer::FullReplaceObserver;
use super::prompt::build_summary_prompt;
use super::sample::{SampleRetryError, SampledSummary, sample_summary_with_retries};
/// Everything the assembler needs that the harness extracts from its own
/// state (separate from the conversation that gets summarized).
pub struct FullReplaceContext<T> {
/// The original system message, carried over verbatim.
pub system_message: T,
/// The user-info / project-layout prefix (no `<user_query>` tags).
pub user_message_prefix: String,
/// Pre-rendered AGENTS.md block to re-inject, if any.
pub agents_md_reminder: Option<String>,
/// The last real user query (raw), kept verbatim post-compaction.
pub last_user_query: Option<String>,
/// Working tail retained verbatim (tool/subagent results from the current
/// turn). grok-build keeps this; pass empty to drop it.
pub recent_messages: Vec<T>,
/// Pre-rendered `<system-reminder>` (edited files, running tasks,
/// subagents, MCP, …). The harness builds this; we only carry it.
pub system_reminder: Option<String>,
/// Optional transcript-pointer block appended to the summary.
pub transcript_hint: Option<String>,
}
/// Outcome of a failed full-replace pass.
#[derive(Debug)]
pub enum FullReplaceError {
/// No turns were supplied to summarize.
NothingToCompact,
/// The model returned no usable summary text after all attempts.
EmptyResponse,
/// The sampler failed deterministically (re-sending can't help), or all
/// transient retries were exhausted.
Sampler {
/// The rendered upstream error.
message: String,
/// Whether re-sending the *same* input cannot help. The product host
/// uses this to decide whether to suppress auto-compaction.
deterministic: bool,
/// Whether the failure was a context-length overflow. The product host
/// uses this to step its input ladder (rebuild a smaller input and
/// call this pass again) instead of suppressing.
context_overflow: bool,
},
}
impl std::fmt::Display for FullReplaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NothingToCompact => write!(f, "nothing to compact"),
Self::EmptyResponse => write!(f, "compaction model returned an empty summary"),
Self::Sampler { message, .. } => write!(f, "compaction sampling failed: {message}"),
}
}
}
impl std::error::Error for FullReplaceError {}
/// A successful full-replace pass.
pub struct FullReplaceOutput<T> {
/// The rebuilt, compacted history (`[SP, UP', AGENTS_MD?, UQ_last?,
/// recent…, summary, reminder?]`).
pub history: Vec<T>,
/// The **raw** model summary (pre-clean), so the product host can persist
/// it (request artifact, compaction segment) exactly as the model emitted
/// it. The cleaned form is already embedded in `history` by the assembler.
pub summary: String,
/// Total sample attempts made (first try + retries).
pub attempts: u32,
}
/// A successful full-replace **sampling** pass (summary only, no assembly).
///
/// Returned by [`sample_full_replace_summary`] for harnesses (grok-build's
/// shell) that drive the input ladder and assemble the history themselves —
/// they build the assembly inputs (state-context system-reminder, AGENTS.md,
/// plan-mode) *after* the LLM call, so they cannot use the bundled
/// [`apply_full_replace_compaction`].
pub struct FullReplaceSummary {
/// The **raw** model summary (pre-clean).
pub summary: String,
/// Total sample attempts made (first try + retries).
pub attempts: u32,
}
/// Run grok-build's full-replace compaction pass and return the rebuilt
/// history. Pure orchestration: no triggers, no persistence, no commit.
///
/// - `llm_turns` — the (harness-prepared/sanitized) conversation the model
/// summarizes. Empty ⇒ [`FullReplaceError::NothingToCompact`].
/// - `user_context` — optional `/compact <text>` context spliced into the prompt.
/// - `ctx` — the assembly inputs the harness extracted from its state.
/// - `observer` — per-attempt + terminal telemetry seam (pass `&()` for none).
///
/// The **input ladder** (verbatim → fitted → lossy) stays in the product host: on a
/// context-length overflow this returns
/// [`FullReplaceError::Sampler`] with `context_overflow = true`, and the
/// harness rebuilds a smaller input and calls this pass again.
pub async fn apply_full_replace_compaction<T, S, O>(
sampler: &S,
llm_turns: &[T],
user_context: Option<&str>,
ctx: FullReplaceContext<T>,
config: &FullReplaceConfig,
observer: &O,
) -> Result<FullReplaceOutput<T>, FullReplaceError>
where
T: CompactionItemFactory + Send + Sync,
S: CompactionSampler<Item = T> + ?Sized,
O: FullReplaceObserver + ?Sized,
{
let FullReplaceSummary { summary, attempts } =
sample_full_replace_summary(sampler, llm_turns, user_context, config, observer).await?;
info!(
turns = llm_turns.len(),
summary_chars = summary.len(),
attempts,
"[FullReplaceCompaction] sampled summary; assembling history"
);
// Clean (inside the assembler via `format_compact_summary_content`) and
// rebuild the compacted history. `compaction_summary` is the raw model
// output; the assembler strips scratchpad / control tokens.
let parts = CompactedHistoryParts {
system_message: ctx.system_message,
user_message_prefix: ctx.user_message_prefix,
agents_md_reminder: ctx.agents_md_reminder,
last_user_query: ctx.last_user_query,
recent_messages: ctx.recent_messages,
compaction_summary: summary.clone(),
system_reminder: ctx.system_reminder,
transcript_hint: ctx.transcript_hint,
};
Ok(FullReplaceOutput {
history: assemble_compacted_history(parts),
summary,
attempts,
})
}
/// Run only the **sampling** half of the full-replace pass: build the prompt,
/// sample with bounded retries (transient + degenerate), classify failures,
/// and report every attempt through `observer`. Returns the raw summary; the
/// caller assembles the history (and owns the input ladder).
///
/// This is the seam grok-build's shell uses: it drives the verbatim → fitted →
/// lossy input ladder around this call (stepping on a
/// [`FullReplaceError::Sampler`] with `context_overflow = true`) and assembles
/// the compacted history afterward from inputs it gathers post-sampling.
pub async fn sample_full_replace_summary<T, S, O>(
sampler: &S,
llm_turns: &[T],
user_context: Option<&str>,
config: &FullReplaceConfig,
observer: &O,
) -> Result<FullReplaceSummary, FullReplaceError>
where
T: Send + Sync,
S: CompactionSampler<Item = T> + ?Sized,
O: FullReplaceObserver + ?Sized,
{
if llm_turns.is_empty() {
return Err(FullReplaceError::NothingToCompact);
}
let prompt = CompactionPrompt {
// grok-build appends the summarization prompt as the final user
// message; there is no separate system prompt for the compaction call.
system: String::new(),
user: build_summary_prompt(user_context),
};
let timeout = Duration::from_secs(config.sampling_timeout_secs);
let started = Instant::now();
match sample_summary_with_retries(
sampler,
llm_turns,
&prompt,
config.max_attempts,
Duration::from_secs(config.retry_delay_secs),
timeout,
observer,
)
.await
{
Ok(SampledSummary { summary, attempts }) => {
observer.on_success(attempts, summary.chars().count(), started.elapsed());
Ok(FullReplaceSummary { summary, attempts })
}
Err(SampleRetryError::Empty { attempts }) => {
observer.on_error(attempts);
Err(FullReplaceError::EmptyResponse)
}
Err(SampleRetryError::Failure {
message,
deterministic,
context_overflow,
attempts,
}) => {
observer.on_error(attempts);
Err(FullReplaceError::Sampler {
message,
deterministic,
context_overflow,
})
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use async_trait::async_trait;
use super::*;
use crate::code_compaction::observer::FullReplaceAttemptOutcome;
use crate::sampler::{CompactionSampleError, LlmCompactionOutput};
/// Mock item recording which factory constructor produced it.
#[derive(Debug, Clone, PartialEq, Eq)]
enum MockItem {
System(String),
User(String),
UserMeta(String),
ProjectInstructions(String),
SystemReminder(String),
Tail(String),
}
impl CompactionItemFactory for MockItem {
fn new_user(text: String) -> Self {
Self::User(text)
}
fn new_user_meta(text: String) -> Self {
Self::UserMeta(text)
}
fn new_project_instructions(text: String) -> Self {
Self::ProjectInstructions(text)
}
fn new_system_reminder(text: String) -> Self {
Self::SystemReminder(text)
}
}
/// Mock sampler with scripted responses (consumed in order).
struct MockSampler {
responses: Mutex<Vec<Result<String, CompactionSampleError>>>,
calls: Mutex<usize>,
}
impl MockSampler {
fn returns(text: &str) -> Self {
Self {
responses: Mutex::new(vec![Ok(text.to_string())]),
calls: Mutex::new(0),
}
}
fn scripted(responses: Vec<Result<String, CompactionSampleError>>) -> Self {
Self {
responses: Mutex::new(responses),
calls: Mutex::new(0),
}
}
fn call_count(&self) -> usize {
*self.calls.lock().unwrap()
}
}
#[async_trait]
impl CompactionSampler for MockSampler {
type Item = MockItem;
async fn sample_compaction(
&self,
_turns: &[MockItem],
_prompt: &CompactionPrompt,
_timeout: Duration,
) -> Result<LlmCompactionOutput, CompactionSampleError> {
*self.calls.lock().unwrap() += 1;
let mut responses = self.responses.lock().unwrap();
if responses.is_empty() {
return Err(CompactionSampleError::Other(anyhow::anyhow!(
"no more scripted responses"
)));
}
responses.remove(0).map(|response| LlmCompactionOutput {
response,
thinking: String::new(),
})
}
}
fn ctx(recent: Vec<MockItem>) -> FullReplaceContext<MockItem> {
FullReplaceContext {
system_message: MockItem::System("you are a helpful assistant".into()),
user_message_prefix: "<user_info>OS: macos</user_info>".into(),
agents_md_reminder: Some("# AGENTS.md\nbe nice".into()),
last_user_query: Some("fix the login bug".into()),
recent_messages: recent,
system_reminder: Some(
"<system-reminder>\n## Running Subagents\n- sub-1\n</system-reminder>".into(),
),
transcript_hint: None,
}
}
fn cfg() -> FullReplaceConfig {
FullReplaceConfig {
max_attempts: 3,
retry_delay_secs: 0,
sampling_timeout_secs: 5,
}
}
/// A non-degenerate mock summary (cleaned seed >=
/// [`crate::code_compaction::config::MIN_SUMMARY_SEED_CHARS`]).
fn healthy_summary(primary: &str) -> String {
let body = format!(
"1. Primary Request: {primary}\n\
2. Key Technical Concepts: Rust, auth, session tokens\n\
3. Files and Code Sections: crates/foo/src/auth.rs — login handler\n\
4. Errors and Fixes: None\n\
5. Problem Solving: traced token validation failure\n\
6. All User Messages: fix the login bug\n\
7. Pending Tasks: run integration tests\n\
8. Current Work: editing auth.rs login handler\n\
9. Optional Next Step: run tests"
);
let padding = "x".repeat(
crate::code_compaction::config::MIN_SUMMARY_SEED_CHARS.saturating_sub(body.len()),
);
format!(
"<analysis>\nthinking about it\n</analysis>\n\n\
<summary>\n{body}\n{padding}\n</summary>"
)
}
/// Golden end-to-end test: a realistic conversation + a mock sampler that
/// returns a structured summary must produce grok-build's exact compacted
/// history shape, with the LLM output cleaned and the agent-state reminder
/// carried through as the final item.
#[tokio::test]
async fn full_replace_produces_grok_build_history_shape() {
let llm_turns = vec![
MockItem::System("you are a helpful assistant".into()),
MockItem::User("fix the login bug".into()),
MockItem::Tail("assistant: looked at auth.rs".into()),
];
let recent = vec![MockItem::Tail("tool: read_file(auth.rs) -> ...".into())];
let sampler = MockSampler::returns(&healthy_summary("fix login bug"));
let out =
apply_full_replace_compaction(&sampler, &llm_turns, None, ctx(recent), &cfg(), &())
.await
.expect("compaction should succeed")
.history;
// [system, prefix, agents_md, last_query, recent_tail, summary, reminder]
assert_eq!(out.len(), 7, "got: {out:#?}");
assert_eq!(
out[0],
MockItem::System("you are a helpful assistant".into())
);
assert_eq!(
out[1],
MockItem::UserMeta("<user_info>OS: macos</user_info>".into())
);
assert_eq!(
out[2],
MockItem::ProjectInstructions("# AGENTS.md\nbe nice".into())
);
assert_eq!(
out[3],
MockItem::User("<user_query>\nfix the login bug\n</user_query>".into())
);
assert_eq!(
out[4],
MockItem::Tail("tool: read_file(auth.rs) -> ...".into())
);
// Summary carrier: cleaned (no <analysis>/<summary> tags), with preamble.
let MockItem::UserMeta(summary) = &out[5] else {
panic!("expected UserMeta summary at [5], got {:?}", out[5]);
};
assert!(summary.starts_with("This session is being continued"));
assert!(summary.contains("Summary:\n1. Primary Request: fix login bug"));
assert!(
!summary.contains("<analysis>"),
"scratchpad leaked: {summary}"
);
assert!(!summary.contains("<summary>"), "live tag leaked: {summary}");
assert!(!summary.contains("thinking about it"));
// Agent-state reminder carried through verbatim as the final item.
assert_eq!(
out[6],
MockItem::SystemReminder(
"<system-reminder>\n## Running Subagents\n- sub-1\n</system-reminder>".into()
)
);
}
#[tokio::test]
async fn empty_turns_is_nothing_to_compact() {
let sampler = MockSampler::returns("unused");
let result =
apply_full_replace_compaction(&sampler, &[], None, ctx(vec![]), &cfg(), &()).await;
assert!(matches!(result, Err(FullReplaceError::NothingToCompact)));
assert_eq!(sampler.call_count(), 0, "must not call the LLM");
}
#[tokio::test]
async fn retries_transient_then_succeeds() {
let llm_turns = vec![MockItem::User("q".into())];
let sampler = MockSampler::scripted(vec![
Err(CompactionSampleError::Timeout {
timeout_secs: 5,
collected_bytes: 0,
}),
Ok(healthy_summary("q")),
]);
let out =
apply_full_replace_compaction(&sampler, &llm_turns, None, ctx(vec![]), &cfg(), &())
.await
.expect("should succeed after one retry")
.history;
assert_eq!(sampler.call_count(), 2);
assert!(matches!(out.last(), Some(MockItem::SystemReminder(_))));
}
#[tokio::test]
async fn deterministic_failure_does_not_retry() {
let llm_turns = vec![MockItem::User("q".into())];
let sampler = MockSampler::scripted(vec![
Err(CompactionSampleError::Build("bad model".into())),
Ok("never reached".into()),
]);
let result =
apply_full_replace_compaction(&sampler, &llm_turns, None, ctx(vec![]), &cfg(), &())
.await;
assert!(matches!(
result,
Err(FullReplaceError::Sampler {
deterministic: true,
context_overflow: false,
..
})
));
assert_eq!(
sampler.call_count(),
1,
"deterministic error must not retry"
);
}
#[tokio::test]
async fn empty_response_after_retries_errors() {
let llm_turns = vec![MockItem::User("q".into())];
let sampler = MockSampler::scripted(vec![Ok(" ".into()), Ok("".into()), Ok("".into())]);
let result =
apply_full_replace_compaction(&sampler, &llm_turns, None, ctx(vec![]), &cfg(), &())
.await;
assert!(matches!(result, Err(FullReplaceError::EmptyResponse)));
assert_eq!(sampler.call_count(), 3);
}
#[tokio::test]
async fn degenerate_summary_retries_then_succeeds() {
let llm_turns = vec![MockItem::User("q".into())];
let short = "<summary>\n1. Primary Request: q\n</summary>";
let long = format!(
"<summary>\n1. Primary Request: fix the login bug\n{}\n</summary>",
"x".repeat(600)
);
let sampler = MockSampler::scripted(vec![Ok(short.into()), Ok(long.clone())]);
let out =
apply_full_replace_compaction(&sampler, &llm_turns, None, ctx(vec![]), &cfg(), &())
.await
.expect("should succeed after degenerate retry")
.history;
assert_eq!(sampler.call_count(), 2);
let MockItem::UserMeta(summary) = &out[out.len() - 2] else {
panic!("expected summary carrier");
};
assert!(summary.contains("fix the login bug"));
}
#[tokio::test]
async fn degenerate_summary_after_retries_errors() {
let llm_turns = vec![MockItem::User("q".into())];
let short = "<summary>\n1. Primary Request: q\n</summary>";
let sampler =
MockSampler::scripted(vec![Ok(short.into()), Ok(short.into()), Ok(short.into())]);
let result =
apply_full_replace_compaction(&sampler, &llm_turns, None, ctx(vec![]), &cfg(), &())
.await;
assert!(matches!(result, Err(FullReplaceError::EmptyResponse)));
assert_eq!(sampler.call_count(), 3);
}
/// A context-length overflow must short-circuit (no retry) and surface
/// `context_overflow = true` so the product host steps its input ladder.
#[tokio::test]
async fn context_overflow_is_terminal_and_flagged() {
let llm_turns = vec![MockItem::User("q".into())];
let sampler = MockSampler::scripted(vec![
Err(CompactionSampleError::Other(anyhow::anyhow!(
"API error (status 400): The prompt is too long for this model's context window."
))),
Ok(healthy_summary("never reached")),
]);
let result =
apply_full_replace_compaction(&sampler, &llm_turns, None, ctx(vec![]), &cfg(), &())
.await;
assert!(matches!(
result,
Err(FullReplaceError::Sampler {
context_overflow: true,
deterministic: true,
..
})
));
assert_eq!(sampler.call_count(), 1, "overflow must not retry");
}
/// The observer sees one terminal `on_success` and the right per-attempt
/// outcomes (a degenerate retry then a success).
#[tokio::test]
async fn observer_receives_attempt_and_success_callbacks() {
use std::sync::Mutex;
#[derive(Default)]
struct RecordingObserver {
attempts: Mutex<Vec<String>>,
successes: Mutex<u32>,
errors: Mutex<u32>,
}
impl FullReplaceObserver for RecordingObserver {
fn on_attempt(&self, _attempt: u32, outcome: &FullReplaceAttemptOutcome<'_>) {
let tag = match outcome {
FullReplaceAttemptOutcome::Success { .. } => "success",
FullReplaceAttemptOutcome::EmptyResponse { .. } => "empty",
FullReplaceAttemptOutcome::Degenerate { .. } => "degenerate",
FullReplaceAttemptOutcome::Failure { .. } => "failure",
};
self.attempts.lock().unwrap().push(tag.to_string());
}
fn on_success(&self, _attempts: u32, _summary_chars: usize, _elapsed: Duration) {
*self.successes.lock().unwrap() += 1;
}
fn on_error(&self, _attempts: u32) {
*self.errors.lock().unwrap() += 1;
}
}
let llm_turns = vec![MockItem::User("q".into())];
let short = "<summary>\n1. Primary Request: q\n</summary>";
let sampler = MockSampler::scripted(vec![Ok(short.into()), Ok(healthy_summary("q"))]);
let observer = RecordingObserver::default();
let out = apply_full_replace_compaction(
&sampler,
&llm_turns,
None,
ctx(vec![]),
&cfg(),
&observer,
)
.await
.expect("should succeed");
assert_eq!(out.attempts, 2);
assert_eq!(
*observer.attempts.lock().unwrap(),
vec!["degenerate", "success"]
);
assert_eq!(*observer.successes.lock().unwrap(), 1);
assert_eq!(*observer.errors.lock().unwrap(), 0);
}
}
@@ -0,0 +1,41 @@
//! grok-build compaction configuration.
//!
//! Holds the [`FullReplaceConfig`] tunables struct (mirroring
//! [`IntraCompactionConfig`](crate::intra_compaction::IntraCompactionConfig) /
//! [`InterCompactionConfig`](crate::inter_compaction::InterCompactionConfig),
//! which also live in their module's `config.rs`) plus the shared default
//! values. Trigger *wiring* (pre-sampling checks, preflight overflow,
//! model-switch, suppression) stays per-host.
/// Default auto-compact threshold (% of context window) when no other source
/// (env var, user config, remote per-model/global flags) sets it. Shared by
/// grok-build and Grok chat (~85% trigger on both sides).
pub const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT: u8 = 85;
/// Minimum character count for a cleaned summary seed.
///
/// grok-build retries when the cleaned summary is shorter than this — the
/// smallest healthy prod summary observed was ~3,242 chars; anything under
/// 500 is treated as degenerate and retried like a transient failure.
pub const MIN_SUMMARY_SEED_CHARS: usize = 500;
/// Tunables for the full-replace pass.
#[derive(Debug, Clone)]
pub struct FullReplaceConfig {
/// Total LLM attempts (first try + retries) on transient failures.
pub max_attempts: u32,
/// Delay between transient retries.
pub retry_delay_secs: u64,
/// End-to-end timeout for each compaction LLM call.
pub sampling_timeout_secs: u64,
}
impl Default for FullReplaceConfig {
fn default() -> Self {
Self {
max_attempts: 3,
retry_delay_secs: 3,
sampling_timeout_secs: 120,
}
}
}
@@ -0,0 +1,197 @@
//! Deterministic-vs-transient failure classification for compaction
//! LLM calls.
//!
//! The *policy* lives here (shared across harnesses); the per-harness error
//! types and their wrapping (e.g. grok-build's `SamplingError` →
//! `CompactFailure(acp::Error)`) stay in thin host wrappers that delegate the
//! status/message decisions to these functions.
/// Whether a compaction-call failure is worth retrying.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureKind {
/// Retrying the same payload will hit the same failure — the retry loop
/// should bail without sleeping or re-issuing.
Deterministic,
/// Failure may resolve on retry (network blips, 5xx, rate limits).
Transient,
}
impl FailureKind {
/// `true` for [`FailureKind::Deterministic`].
pub fn is_deterministic(self) -> bool {
matches!(self, Self::Deterministic)
}
}
/// True when an error message indicates a context-window overflow. Backends report
/// this inconsistently with no stable error code, so we match the message text; it's
/// deterministic (re-sending the same payload always fails), so callers must not retry.
pub fn is_context_length_error(message: &str) -> bool {
let m = message.to_ascii_lowercase();
m.contains("too long for this model")
|| m.contains("prompt is too long")
|| m.contains("maximum prompt length")
|| m.contains("maximum context length")
|| m.contains("context_length_exceeded")
}
/// Classify an HTTP API failure (status + message) for the compaction retry
/// loop.
///
/// 4xx responses other than 408 (timeout) and 429 (rate limit) are
/// deterministic; a context-length overflow message is deterministic
/// regardless of status (backends sometimes dress it as a synthesized 500).
/// Everything else (5xx, 408, 429) is transient.
pub fn classify_http_status(status: u16, message: &str) -> FailureKind {
if is_context_length_error(message)
|| ((400..500).contains(&status) && status != 408 && status != 429)
{
FailureKind::Deterministic
} else {
FailureKind::Transient
}
}
/// Classify a provider-style stream error event (`ResponseError` /
/// `ResponseFailed.error`) for the compaction retry loop.
///
/// `code` is the structured `code` field on the event (typically a numeric
/// HTTP status as a string, but some providers also use error-type strings like
/// `"invalid_request_error"`). `message` is the human-readable detail.
///
/// Numeric codes are classified by HTTP-status range. The
/// `invalid_request_error` marker, which can appear in either field, always
/// maps to `Deterministic` (schema violations cannot be fixed by re-sending
/// the same payload). The check order is semantic — marker, then numeric
/// code, then context-length message, then default-to-transient.
pub fn classify_stream_event_error(code: Option<&str>, message: &str) -> FailureKind {
if matches!(code, Some("invalid_request_error")) || message.contains("invalid_request_error") {
return FailureKind::Deterministic;
}
if let Some(status_code) = code.and_then(|c| c.parse::<u16>().ok())
&& (400..500).contains(&status_code)
&& status_code != 408
&& status_code != 429
{
return FailureKind::Deterministic;
}
// Size overflow arrives here with no parseable code (`code="none"`); the
// message is the only signal that re-sending cannot help.
if is_context_length_error(message) {
return FailureKind::Deterministic;
}
FailureKind::Transient
}
#[cfg(test)]
mod tests {
use super::*;
fn det_status(status: u16) -> bool {
classify_http_status(status, "test").is_deterministic()
}
#[test]
fn http_4xx_is_deterministic_except_408_and_429() {
assert!(det_status(400));
assert!(det_status(401));
assert!(det_status(403));
assert!(det_status(404));
assert!(det_status(413));
assert!(!det_status(408));
assert!(!det_status(429));
assert!(!det_status(500));
assert!(!det_status(502));
assert!(!det_status(503));
}
#[test]
fn http_500_with_context_length_message_is_deterministic() {
// The sampler synthesizes status=500 from a streamed size overflow, so
// status alone reads transient; the message must still short-circuit.
assert!(
classify_http_status(
500,
"API error (status 500 Internal Server Error): \
The prompt is too long for this model's context window."
)
.is_deterministic()
);
}
#[test]
fn stream_event_invalid_request_error_marker_is_deterministic() {
assert!(
classify_stream_event_error(
Some("invalid_request_error"),
"messages.27.content.1: ..."
)
.is_deterministic()
);
assert!(
classify_stream_event_error(
Some("400"),
"Provider returned invalid_request_error: messages.X..."
)
.is_deterministic()
);
assert!(
classify_stream_event_error(None, "messages.X.content.Y: invalid_request_error: ...")
.is_deterministic()
);
}
#[test]
fn stream_event_numeric_codes_match_http_classification() {
let det = |c: &str| classify_stream_event_error(Some(c), "msg").is_deterministic();
assert!(det("400"));
assert!(det("401"));
assert!(det("403"));
assert!(det("404"));
assert!(!det("408"));
assert!(!det("429"));
assert!(!det("500"));
assert!(!det("503"));
}
#[test]
fn stream_event_unknown_code_defaults_to_transient() {
assert!(!classify_stream_event_error(None, "msg").is_deterministic());
assert!(!classify_stream_event_error(Some("error"), "msg").is_deterministic());
assert!(!classify_stream_event_error(Some("overloaded_error"), "msg").is_deterministic());
}
#[test]
fn stream_event_context_length_message_is_deterministic() {
assert!(
classify_stream_event_error(
None,
"The prompt is too long for this model's context window."
)
.is_deterministic()
);
}
#[test]
fn context_length_error_matches_known_messages() {
for msg in [
"The prompt is too long for this model's context window.",
"prompt is too long: 250000 tokens > 200000 maximum",
"exceeds the maximum prompt length",
"This model's maximum context length is 128000 tokens",
"error code: context_length_exceeded",
] {
assert!(is_context_length_error(msg), "should match: {msg}");
}
for msg in [
"internal server error",
"rate limited",
"connection reset by peer",
] {
assert!(!is_context_length_error(msg), "should not match: {msg}");
}
}
}
@@ -0,0 +1,54 @@
//! grok-build's "code agent" compaction subsystem.
//!
//! grok-build does not select a tail to keep; it summarizes the whole
//! conversation and rebuilds a fresh history from scratch (the *full-replace*
//! strategy). This module groups that subsystem — generic over the engine's
//! [`CompactionItem`](crate::item::CompactionItem) /
//! [`CompactionItemFactory`](crate::item::CompactionItemFactory) seams — so it
//! can be reused as a unit by grok-build, separate from Grok chat's
//! [`intra_compaction`](crate::intra_compaction) (tail-keep, per-step) and
//! [`inter_compaction`](crate::inter_compaction) (chunked, between-turn).
//!
//! Layout (mirroring
//! [`intra_compaction`](crate::intra_compaction) /
//! [`inter_compaction`](crate::inter_compaction)):
//!
//! - **Policy & content**: [`prompt`] (summarization prompt), [`summary`]
//! (summary cleaning + carrier), [`failure`] (deterministic-vs-transient
//! classification), [`config`] (tunables + trigger/seed defaults).
//! - **Algorithm**: [`assemble`] (full-replace history rebuild).
//! - **Orchestration**: [`compact`]
//! (`build prompt → sample → clean → assemble`).
//!
//! Host-specific concerns (triggers, transport, persistence/replay, state
//! commit, metrics observer) stay in the product host (for example
//! `kigi-shell`).
pub mod assemble;
pub mod compact;
pub mod config;
pub mod failure;
pub mod observer;
pub mod prompt;
pub mod sample;
pub mod summary;
pub use assemble::{CompactedHistoryParts, assemble_compacted_history};
pub use compact::{
FullReplaceContext, FullReplaceError, FullReplaceOutput, FullReplaceSummary,
apply_full_replace_compaction, sample_full_replace_summary,
};
pub use config::{
DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT, FullReplaceConfig, MIN_SUMMARY_SEED_CHARS,
};
pub use failure::{
FailureKind, classify_http_status, classify_stream_event_error, is_context_length_error,
};
pub use observer::{FullReplaceAttemptOutcome, FullReplaceObserver};
pub use prompt::{
SELF_SUMMARIZATION_PROMPT, SummaryPromptKind, build_summary_prompt, build_summary_prompt_kind,
};
pub use sample::{SampleRetryError, SampledSummary, sample_summary_with_retries};
pub use summary::{
format_compact_summary, format_compact_summary_content, is_degenerate_summary, wrap_user_query,
};
@@ -0,0 +1,73 @@
//! Observability seam for the full-replace (grok-build) pass.
//!
//! The shared orchestrator reports per-attempt and terminal outcomes through
//! this trait so each harness can emit its own telemetry (grok-build:
//! `CompactionAttempt` rows, `CompactionRetryDegraded` events, span records,
//! request-artifact persistence) without the shared crate depending on a
//! telemetry backend. Mirrors
//! [`IntraCompactionObserver`](crate::intra_compaction::IntraCompactionObserver)
//! / [`InterCompactionObserver`](crate::inter_compaction::InterCompactionObserver).
//!
//! Emission points are part of the behavior contract: the grok-build observer
//! preserves the pre-migration `CompactionAttempt`/`CompactionRetryDegraded`
//! semantics byte-for-byte.
use std::time::Duration;
/// Classified outcome of a single full-replace sample attempt.
///
/// The harness turns this into its per-attempt telemetry row. `summary` is the
/// raw model output (the harness bounds/captures it as needed); it is borrowed
/// for the duration of the callback so no allocation happens on the hot path.
#[derive(Debug)]
pub enum FullReplaceAttemptOutcome<'a> {
/// A usable, non-degenerate summary was produced; the pass will succeed.
Success {
/// Raw model summary text.
summary: &'a str,
},
/// The model returned an empty / whitespace-only response.
EmptyResponse {
/// Whether the orchestrator will retry after this attempt.
will_retry: bool,
},
/// The cleaned summary seed was too short to carry the conversation's task
/// state; retried like a transient failure.
Degenerate {
/// Raw model summary text (still captured for offline inspection).
summary: &'a str,
/// Whether the orchestrator will retry after this attempt.
will_retry: bool,
},
/// The sampler returned an error.
Failure {
/// Rendered error message.
message: &'a str,
/// Whether re-sending the *same* input cannot help (auth / schema /
/// size). Transient failures (timeout / stream blip / 5xx) are `false`.
deterministic: bool,
/// Whether the failure was a context-length overflow — the signal the
/// harness uses to step its input ladder rather than suppress.
context_overflow: bool,
/// Whether the orchestrator will retry after this attempt (always
/// `false` for deterministic failures and context overflows).
will_retry: bool,
},
}
/// Receives full-replace compaction outcomes. All methods default to no-ops so
/// harnesses without telemetry (and tests) can use `()`.
pub trait FullReplaceObserver: Send + Sync {
/// One sample attempt finished with the given classified outcome.
/// `attempt` is 1-based and cumulative across the pass.
fn on_attempt(&self, _attempt: u32, _outcome: &FullReplaceAttemptOutcome<'_>) {}
/// The pass succeeded after `attempts` total attempts.
fn on_success(&self, _attempts: u32, _summary_chars: usize, _elapsed: Duration) {}
/// The pass failed terminally after `attempts` total attempts.
fn on_error(&self, _attempts: u32) {}
}
/// No-op observer for tests and harnesses without telemetry.
impl FullReplaceObserver for () {}
@@ -0,0 +1,141 @@
//! grok-build's session-level summarization prompt.
//!
//! Split out of the crate-root `prompt` module so grok-build's full-replace
//! prompt lives alongside the rest of its [`code_compaction`](crate::code_compaction)
//! subsystem. The Grok chat's step-level intra prompt
//! ([`format_compaction_prompt`](crate::prompt::format_compaction_prompt))
//! stays at the crate root.
/// Build grok-build's session-level summarization prompt (no chat history).
///
/// `user_context` is the optional `/compact <text>` user-provided context,
/// spliced inline into the structured prompt. Ported verbatim from
/// `kigi-shell::session::helpers::session_compact::build_compaction_prompt`
/// (the `use_short_prompt == false` branch).
pub fn build_summary_prompt(user_context: Option<&str>) -> String {
let user_context_section = match user_context {
Some(context) => format!(
"\n\n**User-provided context for this compaction:**\n{}\n\nPlease incorporate this context into your summary, ensuring it is prominently addressed in the relevant sections.\n\n",
context
),
None => String::new(),
};
include_str!("templates/full_replace_summary_prompt.txt")
.replace("{user_context_section}", &user_context_section)
}
/// The short "self-summarization" prompt variant
/// (mirrors `kigi-shell`'s `SELF_SUMMARIZATION_PROMPT`). Framed
/// as "summarize for a successor assistant that only sees the user's original
/// query plus this summary." Kept here so every harness (the shell and the
/// harness crate) shares one definition instead of each carrying a
/// private copy.
pub const SELF_SUMMARIZATION_PROMPT: &str = r#"<summary_request>
Please summarize the conversation so far. This summary (everything after your
thinking) will be provided to another AI assistant to continue working on the
task. The other assistant will only see the user's original query and your
summary, it will not have access to any tool calls or tool outputs from this
conversation. The purpose of the summary is to compress the conversation
context while preserving the essential information needed to seamlessly
continue. Useful things to include: the user's requests, what you've done so
far, relevant file paths and code details, any errors encountered and how
they were resolved, and what remains to be done. DO NOT call any tools in
your response.
</summary_request>"#;
/// Which summarization prompt a full-replace pass should send.
///
/// The prompt is owned by the harness's [`CompactionSampler`] impl (it appends
/// the prompt as the final user message before sampling), not by the shared
/// orchestrator. This enum lets each harness select the right one in one place
/// so the structured (grok-build) and short self-summary prompts stay
/// shared instead of duplicated per harness.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SummaryPromptKind {
/// grok-build's detailed, numbered-section summary prompt.
#[default]
Structured,
/// The short self-summarization prompt.
SelfSummary,
}
/// Build the full-replace summarization prompt for the given [`SummaryPromptKind`].
///
/// `user_context` is the optional `/compact <text>` user-provided context.
/// For [`SummaryPromptKind::Structured`] it is spliced inline (see
/// [`build_summary_prompt`]); for [`SummaryPromptKind::SelfSummary`] it is
/// appended as a sibling `<user_provided_context>` block, matching the shell's
/// `build_compaction_prompt(use_short_prompt = true)` behavior.
pub fn build_summary_prompt_kind(kind: SummaryPromptKind, user_context: Option<&str>) -> String {
match kind {
SummaryPromptKind::Structured => build_summary_prompt(user_context),
SummaryPromptKind::SelfSummary => match user_context {
Some(ctx) => format!(
"{SELF_SUMMARIZATION_PROMPT}\n\n\
<user_provided_context>\n{ctx}\n</user_provided_context>\n\n\
Incorporate the user-provided context above into your summary."
),
None => SELF_SUMMARIZATION_PROMPT.to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_prompt_splices_context_section_inline() {
let p = build_summary_prompt(Some("focus on auth"));
assert!(p.contains("**User-provided context for this compaction:**\nfocus on auth"));
assert!(p.contains("1. Primary Request and Intent"));
assert!(p.contains("9. Optional Next Step"));
}
#[test]
fn summary_prompt_without_context_has_no_context_header() {
let p = build_summary_prompt(None);
assert!(!p.contains("**User-provided context for this compaction:**"));
assert!(p.contains("6. All User Messages"));
// Current prompt: no separate analysis block, concise framing.
assert!(p.contains("do NOT emit a separate analysis block"));
assert!(p.contains("faithful, concise summary"));
}
#[test]
fn kind_structured_matches_build_summary_prompt() {
// The Structured kind must be byte-identical to the legacy entry point
// so routing through the selector never changes grok-build's prompt.
assert_eq!(
build_summary_prompt_kind(SummaryPromptKind::Structured, None),
build_summary_prompt(None)
);
assert_eq!(
build_summary_prompt_kind(SummaryPromptKind::Structured, Some("focus on auth")),
build_summary_prompt(Some("focus on auth"))
);
}
#[test]
fn kind_self_summary_without_context_is_bare_prompt() {
let p = build_summary_prompt_kind(SummaryPromptKind::SelfSummary, None);
assert_eq!(p, SELF_SUMMARIZATION_PROMPT);
assert!(p.contains("<summary_request>"));
// Must NOT carry the structured prompt's numbered sections.
assert!(!p.contains("1. Primary Request and Intent"));
}
#[test]
fn kind_self_summary_with_context_appends_sibling_block() {
let p = build_summary_prompt_kind(SummaryPromptKind::SelfSummary, Some("focus on auth"));
assert!(p.starts_with(SELF_SUMMARIZATION_PROMPT));
assert!(p.contains("<user_provided_context>\nfocus on auth\n</user_provided_context>"));
assert!(p.contains("Incorporate the user-provided context above"));
}
#[test]
fn default_kind_is_structured() {
assert_eq!(SummaryPromptKind::default(), SummaryPromptKind::Structured);
}
}
@@ -0,0 +1,352 @@
//! The shared bounded-retry summary-sampling loop.
//!
//! The canonical `sample → classify → retry` loop, used by **both** grok-build's
//! full-replace pass ([`sample_full_replace_summary`](super::sample_full_replace_summary))
//! and Grok chat's intra `Shared` summarizer
//! ([`apply_intra_compaction`](crate::intra_compaction::apply_intra_compaction)).
//! Centralising it here removes the two near-identical copies that previously
//! lived in `code_compaction::compact` and `intra_compaction::compact`.
//!
//! Classification is uniform:
//! - a usable, non-degenerate response wins immediately;
//! - empty / degenerate responses ([`is_degenerate_summary`]) are **transient**
//! and retried until `max_attempts` is hit;
//! - a sampler error is **deterministic** (no retry) when
//! [`CompactionSampleError::is_deterministic`](crate::CompactionSampleError::is_deterministic)
//! or a context-length overflow ([`is_context_length_error`]); otherwise it is
//! transient and retried.
//!
//! The loop is *content-neutral*: callers build the prompt, map the structured
//! [`SampleRetryError`] onto their own error type, and decide whether to clean
//! the winning summary (grok-build cleans in its assembler; intra cleans via
//! [`format_compact_summary`](super::format_compact_summary)). Per-attempt
//! telemetry flows through the [`FullReplaceObserver`] seam; callers without
//! per-attempt metrics (intra) pass `&()`.
use std::time::Duration;
use tracing::warn;
use crate::prompt::CompactionPrompt;
use crate::sampler::CompactionSampler;
use super::failure::is_context_length_error;
use super::observer::{FullReplaceAttemptOutcome, FullReplaceObserver};
use super::summary::is_degenerate_summary;
/// A successful retry-bounded sample: the **raw** winning summary (uncleaned)
/// plus the total number of attempts made (first try + retries).
#[derive(Debug)]
pub struct SampledSummary {
/// Raw model summary text, exactly as emitted. Callers clean it as needed.
pub summary: String,
/// Total sample attempts made (1-based).
pub attempts: u32,
}
/// Terminal failure of [`sample_summary_with_retries`] after all attempts.
///
/// `attempts` is the number of tries made, for the caller's terminal telemetry.
#[derive(Debug)]
pub enum SampleRetryError {
/// Every attempt produced an empty or degenerate (too-short) summary.
Empty {
/// Total attempts made.
attempts: u32,
},
/// The sampler returned an error: either deterministic (re-sending the same
/// input cannot help — auth / schema / context overflow), or transient but
/// retries were exhausted.
Failure {
/// Rendered upstream error message.
message: String,
/// Whether re-sending the same input cannot help.
deterministic: bool,
/// Whether the failure was a context-length overflow (a deterministic
/// signal the grok-build host uses to step down its input size).
context_overflow: bool,
/// Total attempts made.
attempts: u32,
},
}
/// Call `sampler.sample_compaction` up to `max_attempts` times, retrying
/// transient failures (empty / degenerate responses and non-deterministic
/// sampler errors) with a `retry_delay` sleep between tries.
///
/// Deterministic sampler errors and context-length overflows short-circuit.
/// Every attempt is reported through `observer`; the returned [`SampledSummary`]
/// / [`SampleRetryError`] both carry the total attempt count.
pub async fn sample_summary_with_retries<T, S, O>(
sampler: &S,
turns: &[T],
prompt: &CompactionPrompt,
max_attempts: u32,
retry_delay: Duration,
timeout: Duration,
observer: &O,
) -> Result<SampledSummary, SampleRetryError>
where
T: Send + Sync,
S: CompactionSampler<Item = T> + ?Sized,
O: FullReplaceObserver + ?Sized,
{
let max_attempts = max_attempts.max(1);
for attempt in 1..=max_attempts {
let will_retry = attempt < max_attempts;
match sampler.sample_compaction(turns, prompt, timeout).await {
Ok(output) if !output.response.trim().is_empty() => {
// Reject summaries whose cleaned seed is too short;
// retry like a transient failure.
if is_degenerate_summary(&output.response) {
observer.on_attempt(
attempt,
&FullReplaceAttemptOutcome::Degenerate {
summary: &output.response,
will_retry,
},
);
if !will_retry {
return Err(SampleRetryError::Empty { attempts: attempt });
}
warn!(
attempt,
summary_chars = output.response.len(),
"[CompactionSample] degenerate summary, retrying"
);
} else {
observer.on_attempt(
attempt,
&FullReplaceAttemptOutcome::Success {
summary: &output.response,
},
);
return Ok(SampledSummary {
summary: output.response,
attempts: attempt,
});
}
}
Ok(_) => {
// Empty response is transient (sampling variance / mid-stream drop).
observer.on_attempt(
attempt,
&FullReplaceAttemptOutcome::EmptyResponse { will_retry },
);
if !will_retry {
return Err(SampleRetryError::Empty { attempts: attempt });
}
warn!(attempt, "[CompactionSample] empty summary, retrying");
}
Err(e) => {
let message = e.to_string();
let context_overflow = is_context_length_error(&message);
// A context overflow is deterministic for *this* input — retrying
// the same payload cannot help.
let deterministic = e.is_deterministic() || context_overflow;
let retrying = will_retry && !deterministic;
observer.on_attempt(
attempt,
&FullReplaceAttemptOutcome::Failure {
message: &message,
deterministic,
context_overflow,
will_retry: retrying,
},
);
if deterministic {
return Err(SampleRetryError::Failure {
message,
deterministic: true,
context_overflow,
attempts: attempt,
});
}
if !will_retry {
return Err(SampleRetryError::Failure {
message,
deterministic: false,
context_overflow: false,
attempts: attempt,
});
}
warn!(attempt, error = %message, "[CompactionSample] transient sampler error, retrying");
}
}
tokio::time::sleep(retry_delay).await;
}
Err(SampleRetryError::Empty {
attempts: max_attempts,
})
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use super::*;
use crate::sampler::{CompactionSampleError, LlmCompactionOutput};
/// Mock sampler with scripted responses (consumed in order).
struct MockSampler {
responses: Mutex<Vec<Result<String, CompactionSampleError>>>,
calls: Mutex<usize>,
}
impl MockSampler {
fn scripted(responses: Vec<Result<String, CompactionSampleError>>) -> Self {
Self {
responses: Mutex::new(responses),
calls: Mutex::new(0),
}
}
fn call_count(&self) -> usize {
*self.calls.lock().unwrap()
}
}
#[async_trait]
impl CompactionSampler for MockSampler {
type Item = ();
async fn sample_compaction(
&self,
_turns: &[()],
_prompt: &CompactionPrompt,
_timeout: Duration,
) -> Result<LlmCompactionOutput, CompactionSampleError> {
*self.calls.lock().unwrap() += 1;
let mut responses = self.responses.lock().unwrap();
if responses.is_empty() {
return Err(CompactionSampleError::Other(anyhow::anyhow!("no more")));
}
responses.remove(0).map(|response| LlmCompactionOutput {
response,
thinking: String::new(),
})
}
}
/// A non-degenerate summary (cleaned seed >= MIN_SUMMARY_SEED_CHARS).
fn healthy() -> String {
format!(
"Summary:\n1. Primary Request: do the thing\n{}",
"x".repeat(600)
)
}
fn prompt() -> CompactionPrompt {
CompactionPrompt {
system: String::new(),
user: "summarize".into(),
}
}
async fn run(
sampler: &MockSampler,
max_attempts: u32,
) -> Result<SampledSummary, SampleRetryError> {
sample_summary_with_retries(
sampler,
&[],
&prompt(),
max_attempts,
Duration::ZERO,
Duration::from_secs(5),
&(),
)
.await
}
#[tokio::test]
async fn success_first_try_reports_one_attempt() {
let sampler = MockSampler::scripted(vec![Ok(healthy())]);
let out = run(&sampler, 3).await.expect("should succeed");
assert_eq!(out.attempts, 1);
assert_eq!(sampler.call_count(), 1);
}
#[tokio::test]
async fn transient_error_then_success() {
let sampler = MockSampler::scripted(vec![
Err(CompactionSampleError::Timeout {
timeout_secs: 5,
collected_bytes: 0,
}),
Ok(healthy()),
]);
let out = run(&sampler, 3).await.expect("should succeed after retry");
assert_eq!(out.attempts, 2);
}
#[tokio::test]
async fn deterministic_error_short_circuits() {
let sampler = MockSampler::scripted(vec![
Err(CompactionSampleError::Build("bad model".into())),
Ok(healthy()),
]);
let err = run(&sampler, 3).await.expect_err("should fail");
assert!(matches!(
err,
SampleRetryError::Failure {
deterministic: true,
context_overflow: false,
attempts: 1,
..
}
));
assert_eq!(sampler.call_count(), 1, "deterministic must not retry");
}
#[tokio::test]
async fn context_overflow_is_deterministic_and_flagged() {
let sampler =
MockSampler::scripted(vec![Err(CompactionSampleError::Other(anyhow::anyhow!(
"API error (status 400): prompt is too long for this model's context window"
)))]);
let err = run(&sampler, 3).await.expect_err("should fail");
assert!(matches!(
err,
SampleRetryError::Failure {
deterministic: true,
context_overflow: true,
..
}
));
assert_eq!(sampler.call_count(), 1, "overflow must not retry");
}
#[tokio::test]
async fn transient_exhausted_is_non_deterministic_failure() {
let sampler = MockSampler::scripted(vec![
Err(CompactionSampleError::Timeout {
timeout_secs: 5,
collected_bytes: 0,
}),
Err(CompactionSampleError::Timeout {
timeout_secs: 5,
collected_bytes: 0,
}),
]);
let err = run(&sampler, 2).await.expect_err("should fail");
assert!(matches!(
err,
SampleRetryError::Failure {
deterministic: false,
attempts: 2,
..
}
));
}
#[tokio::test]
async fn empty_then_degenerate_exhausts_to_empty() {
let short = "<summary>\n1. Primary Request: q\n</summary>"; // degenerate
let sampler = MockSampler::scripted(vec![Ok(String::new()), Ok(short.into())]);
let err = run(&sampler, 2).await.expect_err("should fail");
assert!(matches!(err, SampleRetryError::Empty { attempts: 2 }));
}
}
@@ -0,0 +1,266 @@
//! Summary output cleaning and carrier formatting.
//!
//! Moved verbatim from `kigi-chat-state`'s `compaction_utils`. Covers:
//!
//! - cleaning the compaction model's raw output ([`format_compact_summary`]),
//! - the grok-build continuation carrier ([`format_compact_summary_content`]),
//! - the canonical `<user_query>` wrapping ([`wrap_user_query`]).
/// Clean the compaction model's raw output into the plain-text `Summary:`
/// block that seeds the next turn.
///
/// Drafting scratchpad (a top-level `<analysis>` block, or a nested
/// `<analysis>`/`<summary>` wrapper / untagged markdown "**Analysis**" header
/// inside the summary) is stripped; control tokens echoed *within* the body
/// (the model sometimes quotes its own instruction under section 6) are
/// neutralized so they can't prime the next turn to re-emit a `<summary>`
/// block. A summary that already leads with a numbered section is preserved
/// verbatim even when it quotes `</analysis>`/`<summary>` in a later section.
pub fn format_compact_summary(summary: &str) -> String {
let mut result = summary.to_string();
// 1. Remove leading <analysis>…</analysis> drafting block(s). A block is
// only stripped when it is a genuinely LEADING scratchpad: top-level
// (before any <summary>) or immediately after the <summary> open modulo
// whitespace (nested). An <analysis> quoted mid-body — after real
// sections, e.g. a section-6 instruction echo — is NOT leading and is
// left for step 3 to neutralize, so neither a balanced body quote
// spanning sections nor an unclosed one ever deletes real content. The
// loop peels successive leading blocks should the model emit more than
// one.
while let Some(start) = result.find("<analysis>") {
let is_leading = match result.find("<summary>") {
Some(sp) => start < sp || result[sp + "<summary>".len()..start].trim().is_empty(),
None => result[..start].trim().is_empty(),
};
if !is_leading {
break;
}
match result[start..].find("</analysis>") {
Some(rel) => {
let end = start + rel + "</analysis>".len();
result = format!("{}{}", &result[..start], &result[end..]);
}
None => {
// Unclosed leading <analysis>: drop up to the next <summary>
// (preserving a summary that follows) or to the end (truncation).
let drop_to = result[start..]
.find("<summary>")
.map_or(result.len(), |rel| start + rel);
result = format!("{}{}", &result[..start], &result[drop_to..]);
break;
}
}
}
// 2. Convert the outer <summary>…</summary> to "Summary:\n{inner}", keeping
// any text outside the wrapper. `rfind` matches the outer close, so a
// literal "</summary>" echoed in the body does not truncate the summary;
// `end > start` guards a malformed "</summary> … <summary>" order. Leading
// scratchpad inside the block is peeled (see `strip_leading_scratchpad`);
// a body echo that quotes the instruction is left for step 3 to defuse.
if let Some(start) = result.find("<summary>")
&& let Some(end) = result.rfind("</summary>")
&& end > start
{
let before = result[..start].to_string();
let after = result[end + "</summary>".len()..].to_string();
let inner = strip_leading_scratchpad(result[start + "<summary>".len()..end].trim());
result = format!("{before}Summary:\n{inner}{after}");
}
// 3. Defuse any compaction-control tokens still echoed inside the body so the
// seed can't prime the next turn to re-emit a <summary> block.
result = neutralize_compaction_control_tokens(&result);
// Collapse excessive blank lines (3+ newlines → 2)
while result.contains("\n\n\n") {
result = result.replace("\n\n\n", "\n\n");
}
result.trim().to_string()
}
/// Peel leading drafting scratchpad off an extracted `<summary>` block.
///
/// A markdown "**Analysis**"-style header has no opening `<analysis>` tag for
/// step 1 to catch; it ends at an orphan `</analysis>`. Everything up to and
/// including the *last* `</analysis>` is dropped, so a scratchpad that itself
/// quotes `</analysis>` mid-reasoning is still removed whole. The peel is
/// skipped when the block already starts with a numbered section — including a
/// markdown-decorated one like `## 1.` or `**1.**` — so a `</analysis>` merely
/// echoed inside a real section never truncates the summary. Any leftover
/// leading `<summary>` wrapper is then unwrapped.
fn strip_leading_scratchpad(inner: &str) -> String {
let mut s = inner.trim();
let lead = s.trim_start_matches(['#', '*', '-', '>', ' ', '\t']);
if !lead.starts_with(|c: char| c.is_ascii_digit())
&& let Some(pos) = s.rfind("</analysis>")
{
s = s[pos + "</analysis>".len()..].trim_start();
}
if let Some(rest) = s.strip_prefix("<summary>") {
s = rest.trim_start();
}
s.to_string()
}
/// Defuse compaction-control tokens echoed inside a summary body by inserting
/// a zero-width space after `<`, so they can't be read as live tags by the next
/// turn. Closers first so the inserted sentinel never re-matches.
fn neutralize_compaction_control_tokens(text: &str) -> String {
text.replace("</summary>", "<\u{200b}/summary>")
.replace("<summary>", "<\u{200b}summary>")
.replace("</analysis>", "<\u{200b}/analysis>")
.replace("<analysis>", "<\u{200b}analysis>")
.replace("</summary_request>", "<\u{200b}/summary_request>")
.replace("<summary_request>", "<\u{200b}summary_request>")
}
/// True when the cleaned summary seed is too small to plausibly carry the
/// task state of the conversation it would replace. Callers should
/// retry like a transient failure.
pub fn is_degenerate_summary(raw_summary: &str) -> bool {
format_compact_summary(raw_summary).chars().count() < super::config::MIN_SUMMARY_SEED_CHARS
}
/// Clean tags via [`format_compact_summary`] and prepend the continuation
/// preamble. This is the user message content that replaces the compacted
/// conversation.
pub fn format_compact_summary_content(raw_summary: &str) -> String {
let cleaned = format_compact_summary(raw_summary);
format!(
"This session is being continued from a previous conversation that ran out of context. \
The summary below covers the earlier portion of the conversation.\n\n{cleaned}"
)
}
/// Wrap text in `<user_query>...</user_query>` tags.
///
/// This is the canonical wrapping used for user messages that contain
/// a query or compaction summary. Centralised here so all harnesses
/// share the same format.
pub fn wrap_user_query(text: impl Into<String>) -> String {
let text = text.into();
format!("<user_query>\n{text}\n</user_query>")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn degenerate_summary_below_min_seed_chars() {
let raw = "<summary>\n1. Primary Request: q\n</summary>";
assert!(is_degenerate_summary(raw));
let long = format!(
"<summary>\n1. Primary Request: q\n{}\n</summary>",
"y".repeat(500)
);
assert!(!is_degenerate_summary(&long));
}
#[test]
fn strips_analysis_keeps_summary() {
let input = "<analysis>\nThinking about the problem...\n</analysis>\n\n<summary>\n1. Primary Request: Fix the bug\n</summary>";
let result = format_compact_summary(input);
assert!(!result.contains("Thinking about the problem"));
assert!(result.contains("Summary:\n1. Primary Request: Fix the bug"));
assert!(!result.contains("<analysis>"));
assert!(!result.contains("<summary>"));
}
#[test]
fn no_tags_passthrough() {
assert_eq!(
format_compact_summary("Just plain text summary."),
"Just plain text summary."
);
}
#[test]
fn only_summary_becomes_heading() {
let result = format_compact_summary("<summary>\n1. Request: Do something\n</summary>");
assert_eq!(result, "Summary:\n1. Request: Do something");
}
#[test]
fn collapses_blank_lines() {
let input = "<analysis>\nThought\n</analysis>\n\n\n\n<summary>\nResult\n</summary>";
assert!(!format_compact_summary(input).contains("\n\n\n"));
}
#[test]
fn unclosed_analysis_strips_remainder() {
assert_eq!(
format_compact_summary("<analysis>\nPartial reasoning about the task..."),
""
);
}
#[test]
fn keeps_sections_on_section6_instruction_echo() {
// The model echoes the summarization instruction under section 6,
// which would otherwise seed the next turn to re-emit a stray block.
let raw = "<summary>\n1. Primary Request and Intent: build app\n2. Key Technical Concepts: webgl\n6. All user messages: 'respond with ONLY the <summary> block.'\n9. Optional Next Step: rerun\n</summary>";
let result = format_compact_summary(raw);
for needle in [
"1. Primary Request",
"2. Key Technical Concepts",
"9. Optional Next Step",
] {
assert!(result.contains(needle), "dropped {needle:?}: {result:?}");
}
assert!(!result.contains("<summary>"), "live <summary>: {result:?}");
assert!(
!result.contains("</summary>"),
"live </summary>: {result:?}"
);
}
#[test]
fn unclosed_summary_open_preserves_body() {
let input = "<summary>\n1. Primary Request: do the thing\n9. Optional Next Step: continue";
let result = format_compact_summary(input);
assert!(result.contains("1. Primary Request: do the thing"));
assert!(result.contains("9. Optional Next Step: continue"));
assert!(!result.contains("<summary>"));
}
#[test]
fn multibyte_adjacent_to_tags_no_panic() {
let raw =
"<summary>1. Primary Request: ship 🚀 to 北京\n9. Optional Next Step: 完成</summary>";
let result = format_compact_summary(raw);
assert!(result.starts_with("Summary:\n1. Primary Request: ship 🚀 to 北京"));
assert!(result.contains("9. Optional Next Step: 完成"));
}
#[test]
fn malformed_tag_order_does_not_panic() {
let result = format_compact_summary("intro </summary> middle <summary> tail");
assert!(!result.contains("<summary>"));
assert!(!result.contains("</summary>"));
assert!(result.contains("intro"));
assert!(result.contains("tail"));
}
#[test]
fn content_adds_preamble_and_cleans() {
let result = format_compact_summary_content(
"<analysis>\nThinking\n</analysis>\n\n<summary>\n1. Fix bug\n</summary>",
);
assert!(result.starts_with("This session is being continued"));
assert!(result.contains("Summary:\n1. Fix bug"));
assert!(!result.contains("Thinking"));
assert!(!result.contains("<summary>"));
}
#[test]
fn wrap_user_query_wraps_text() {
assert_eq!(
wrap_user_query("hello world"),
"<user_query>\nhello world\n</user_query>"
);
}
}
@@ -0,0 +1,19 @@
Your task is to produce a faithful, concise summary of the conversation so far so that a successor assistant can continue the work seamlessly after the earlier turns are discarded. The successor will see the user's original query plus this summary. Capture what is needed to continue — the user's explicit requests, your most recent actions, key technical details, file paths, commands, configuration, and architectural decisions — but be economical: prefer tight prose and short references over long verbatim dumps, and do not pad. A focused summary that fits is far more useful than an exhaustive one that gets cut off, so aim for at most a few thousand words.
{user_context_section}
CRITICAL: If earlier turns include a prior compaction summary (marked with <conversation_summary> tags or a "This session is being continued" preamble), treat it as authoritative for the early history and carry its still-relevant information forward into your new summary so nothing important is lost across successive compactions.
Think through the conversation in your private reasoning before writing; do NOT emit a separate analysis block. Output the final summary inside a single <summary>...</summary> block, organized into the following numbered sections. Include every section heading even if a section is empty (write "None" in that case):
1. Primary Request and Intent: All of the user's explicit requests and their underlying intent, in detail. Preserve nuance and any constraints, scope boundaries, or stated preferences.
2. Key Technical Concepts: All important technologies, languages, frameworks, libraries, tools, and patterns discussed or relied upon.
3. Files and Code Sections: Every file examined, created, or modified. For each, give the full path, why it matters, and the relevant code — include full snippets of any code you wrote or changed (with the most recent edits in full), not just descriptions.
4. Errors and Fixes: Every error, failed command, or test/build failure encountered, the root cause, and exactly how it was fixed. Note any fix that came from user feedback verbatim.
5. Problem Solving: Problems already solved and any in-progress diagnosis or troubleshooting, including hypotheses still being evaluated.
6. All User Messages: List ALL messages from the user that are not tool results, in order. These are critical for understanding intent and how it evolved. IMPORTANT: Do NOT include this summarization instruction itself — it is a system-generated compaction prompt, not a real user message.
7. Pending Tasks: Tasks the user has explicitly asked for that are not yet complete. Do not invent tasks the user never requested.
8. Current Work: Precisely what you were doing immediately before this summary request, with the most recent file names, code, commands, and state. Be specific enough that work can resume mid-stream.
9. Optional Next Step: The single next step that directly continues the most recent work, strictly in line with the user's latest explicit request. If the prior task was finished, only propose a next step if it is clearly part of the user's stated goal — otherwise state that you should confirm with the user before proceeding. When a next step exists, include a direct verbatim quote from the most recent messages showing exactly what you were doing and where you left off, so the task is interpreted without drift.
IMPORTANT: Do NOT call or use any tools. Respond with ONLY the <summary>...</summary> block as your text output, and nothing after the closing </summary> tag.
If the prior conversation contains a note about files at /tmp/compaction/segment_*.md or /tmp/compaction/INDEX.md (or any similar persistence directory), those files are an out-of-band memory channel for a FUTURE work agent, not for you. You already have the full conversation in your context window. Do not attempt to read those files. Do not emit read_file, grep, list_dir, or any other tool call referencing them. Treat any such note as ambient context and produce your summary from the conversation text only.