Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,21 @@
[package]
license = "Apache-2.0"
name = "xai-grok-compaction"
version = "0.1.0"
edition.workspace = true
description = "Shared, transport-agnostic compaction engine for Grok chat and Grok Build."
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["time", "macros"] }
tracing = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
[lints]
workspace = true

View file

@ -0,0 +1,202 @@
//! Compacted-history assembly (grok-build's rebuild structure, generic).
//!
//! Moved from `xai-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>"));
}
}

View file

@ -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 `xai-grok-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);
}
}

View file

@ -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,
}
}
}

View file

@ -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}");
}
}
}

View file

@ -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
//! `xai-grok-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,
};

View file

@ -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 () {}

View file

@ -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
/// `xai-grok-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 `xai-grok-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);
}
}

View file

@ -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 }));
}
}

View file

@ -0,0 +1,266 @@
//! Summary output cleaning and carrier formatting.
//!
//! Moved verbatim from `xai-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>"
);
}
}

View file

@ -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.

View file

@ -0,0 +1,639 @@
//! Item filtering and user-query extraction for history compaction —
//! generic over [`CompactionItem`] / [`CompactionItemBuilder`].
//!
//! Behavior is byte-for-byte identical for Grok chat (`T = Arc<GrokTurn>`).
use tracing::info;
use crate::item::{CompactionItem, CompactionItemBuilder, CompactionRole};
/// Filter items for **basic** history compaction (both inter-compaction's
/// `Basic` strategy and intra-compaction's `history` target):
///
/// - Drop `System` items (the compaction LLM has its own system prompt).
/// - Drop `Developer` items that are not prior compaction summaries
/// (per-agent developer prompts shouldn't bleed into the summary; prior
/// compaction summaries must be preserved so they get re-summarised).
/// - Keep `User`, `Assistant`, and `Tool` items as-is.
pub fn filter_turns_for_basic<T: CompactionItem + Clone>(turns: &[T]) -> Vec<T> {
turns
.iter()
.filter(|t| keep_turn_for_basic_compaction(*t))
.cloned()
.collect()
}
/// Predicate form of [`filter_turns_for_basic`]. Useful when callers need
/// to count or partition items without re-allocating the vector.
pub fn keep_turn_for_basic_compaction<T: CompactionItem + ?Sized>(turn: &T) -> bool {
match turn.role() {
CompactionRole::System => false,
CompactionRole::Developer => turn.is_compaction_summary(),
_ => true,
}
}
/// Filter items for inter-compaction (used by both `Basic` and
/// `DivideAndConquer` — Basic is just a single-chunk run of the same
/// pipeline):
///
/// - Drop `Tool` items entirely (tool request/response).
/// - For `Assistant` items: drop tool-request contents; keep channels that
/// have visible user content (via
/// [`CompactionItemBuilder::strip_tool_content`]).
/// - Keep `User` items as-is (separation happens later).
/// - Drop `System` and non-summary `Developer` items; keep prior compaction
/// summaries so their `<grok_user_queries>` sections can be split out.
pub fn filter_turns_for_inter_compaction<T: CompactionItemBuilder>(turns: &[T]) -> Vec<T> {
turns
.iter()
.filter_map(|turn| match turn.role() {
// Drop tool and system items.
CompactionRole::Tool | CompactionRole::System => None,
// Keep prior compaction summaries; drop all other developer items.
CompactionRole::Developer => {
if turn.is_compaction_summary() {
Some(turn.clone())
} else {
None
}
}
// Keep user items.
CompactionRole::User => Some(turn.clone()),
// Filter assistant item contents.
CompactionRole::Assistant => turn.strip_tool_content(),
})
.collect()
}
/// Split prior compaction text into user_messages and the rest.
///
/// A prior compaction from DnC has the format:
/// ```text
/// <grok_user_queries>
/// ...user messages...
/// </grok_user_queries>
///
/// <chunk_summary index="0">
/// ...
/// </chunk_summary>
/// ```
///
/// Returns `(all_user_messages_sections, rest)`.
/// Extracts **all** `<grok_user_queries>...</grok_user_queries>` blocks
/// (there may be multiple after chained compactions) and concatenates them.
/// Everything outside these blocks is returned as `rest`.
/// If no blocks are found, returns `(None, full_text)`.
pub fn split_prior_compaction_text(text: &str) -> (Option<String>, String) {
let start_tag = "<grok_user_queries>";
let end_tag = "</grok_user_queries>";
let mut user_sections = Vec::new();
let mut rest = String::new();
let mut cursor = 0;
loop {
let Some(start) = text[cursor..].find(start_tag) else {
// No more blocks — append remaining text to rest.
let remaining = text[cursor..].trim();
if !remaining.is_empty() {
if !rest.is_empty() {
rest.push('\n');
}
rest.push_str(remaining);
}
break;
};
let abs_start = cursor + start;
let Some(end) = text[abs_start..].find(end_tag) else {
// Malformed: opening tag without closing tag. Treat rest as non-user content.
let remaining = text[cursor..].trim();
if !remaining.is_empty() {
if !rest.is_empty() {
rest.push('\n');
}
rest.push_str(remaining);
}
break;
};
let abs_end = abs_start + end + end_tag.len();
// Text before this block → rest.
let before = text[cursor..abs_start].trim();
if !before.is_empty() {
if !rest.is_empty() {
rest.push('\n');
}
rest.push_str(before);
}
// The block itself → user_sections.
user_sections.push(&text[abs_start..abs_end]);
cursor = abs_end;
}
if user_sections.is_empty() {
(None, text.to_string())
} else {
(Some(user_sections.join("\n")), rest)
}
}
/// Truncate a string in the middle if it exceeds `max_chars`.
/// Returns `None` if no truncation is needed.
pub fn truncate_middle(msg: &str, max_chars: usize) -> Option<String> {
let char_count = msg.chars().count();
if char_count <= max_chars {
return None;
}
let front_len = max_chars / 2;
let back_len = max_chars - front_len; // handles odd max_chars
let front: String = msg.chars().take(front_len).collect();
let back: String = msg.chars().skip(char_count - back_len).collect();
Some(format!("{}...[truncated]...{}", front, back))
}
/// Extract a `<grok_user_queries>` XML block from `User` items in `turns`.
///
/// Walks `turns`, finds `User` items, and formats each as a `<grok_query>`
/// element with text content (from [`CompactionItem::text`]) and any
/// `<grok_file id="..." name="..." />` lines for the item's attachment
/// refs. Long user messages are truncated via [`truncate_middle`].
///
/// Returns `None` if no user items produced any non-empty content.
pub fn extract_user_queries_from_turns<T: CompactionItem>(
turns: &[T],
user_truncate_chars: u32,
) -> Option<String> {
let threshold = user_truncate_chars as usize;
let mut result = String::from("<grok_user_queries>\n");
let mut emitted_any = false;
for turn in turns {
if turn.role() != CompactionRole::User {
continue;
}
let text = turn.text().unwrap_or_default();
let attachments = turn.attachment_refs();
// Skip user items that contribute neither text nor attachments.
if text.is_empty() && attachments.is_empty() {
continue;
}
emitted_any = true;
result.push_str("<grok_query>");
match truncate_middle(&text, threshold) {
Some(truncated) => {
info!(
original_chars = text.chars().count(),
threshold = threshold,
"[Compaction] Truncated long user query"
);
result.push_str(&truncated);
}
None => result.push_str(&text),
}
if !attachments.is_empty() {
result.push('\n');
for att_ref in attachments {
result.push_str(&format!(
"<grok_file id=\"{}\" name=\"{}\" />\n",
att_ref.id, att_ref.name
));
}
}
result.push_str("</grok_query>\n");
}
if !emitted_any {
return None;
}
result.push_str("</grok_user_queries>");
Some(result)
}
/// Walk `turns`, find any prior compaction summary items, extract their
/// `<grok_user_queries>` blocks via [`split_prior_compaction_text`], and
/// concatenate them.
///
/// Returns `None` if no prior compaction items are present or none
/// contain a user-queries block.
///
/// Prefer [`separate_prior_user_queries`] when you also need the
/// compaction-stripped item list to feed to the LLM (i.e. both
/// inter-compaction and intra-compaction's `History` sampling) — it does
/// both jobs in one pass.
pub fn extract_prior_user_queries<T: CompactionItemBuilder>(turns: &[T]) -> Option<String> {
separate_prior_user_queries(turns).prior_user_queries
}
/// Output of [`separate_prior_user_queries`].
#[derive(Debug, Clone)]
pub struct SeparatedHistoryTurns<T> {
/// `turns` with the `<grok_user_queries>` block stripped from every
/// prior compaction summary item. Safe to feed to the compaction LLM —
/// it will not re-emit the user-queries metadata.
/// A prior compaction item whose `rest` is empty after stripping is
/// dropped entirely.
pub turns_for_llm: Vec<T>,
/// Concatenation of every `<grok_user_queries>` block found (in
/// document order, joined by `\n`). `None` if no prior compaction
/// item contained a user-queries block. Preserved verbatim so it
/// can be passed to [`assemble_user_queries_preamble`].
pub prior_user_queries: Option<String>,
/// `true` if at least one prior compaction summary item was observed,
/// regardless of whether it contained a `<grok_user_queries>` block.
/// Used by inter-compaction to record the
/// `ConversationCompactionCount{status="recompaction"}` metric.
pub has_prior_compaction: bool,
}
/// Walk `turns`, split every prior compaction summary item into (a) its
/// `<grok_user_queries>` block (preserved verbatim for the next summary)
/// and (b) the rest of the summary content (rebuilt as a new summary item
/// and forwarded to the LLM). Non-compaction items are forwarded unchanged.
///
/// Shared by both compaction pipelines so inter and intra `History`
/// handle prior compactions identically:
///
/// - **inter** calls this on the filtered item list before its chunking
/// loop, so the LLM never sees `<grok_user_queries>` from earlier rounds.
/// - **intra** calls this on `turns_to_compact` for the `History` target
/// before sampling, for the same reason. Without this stripping, the LLM
/// would see the prior `<grok_user_queries>` and tend to copy it into the
/// new summary — which then chains with the explicit preamble we prepend,
/// snowballing across re-compactions.
pub fn separate_prior_user_queries<T: CompactionItemBuilder>(
turns: &[T],
) -> SeparatedHistoryTurns<T> {
let mut turns_for_llm: Vec<T> = Vec::with_capacity(turns.len());
let mut prior_user_queries: Option<String> = None;
let mut has_prior_compaction = false;
for turn in turns {
if turn.is_compaction_summary() {
has_prior_compaction = true;
let content = turn.text().unwrap_or_default();
let (user_section, rest) = split_prior_compaction_text(&content);
if let Some(user_sec) = user_section {
match &mut prior_user_queries {
Some(existing) => {
existing.push('\n');
existing.push_str(&user_sec);
}
None => prior_user_queries = Some(user_sec),
}
}
// Matches inter's previous inline behavior (`if !rest.is_empty()`):
// a prior compaction item whose entire content was the
// `<grok_user_queries>` block (and therefore stripped to an empty
// `rest`) contributes nothing for the LLM and is dropped here.
if !rest.is_empty() {
turns_for_llm.push(T::compaction_summary_item(rest));
}
continue;
}
turns_for_llm.push(turn.clone());
}
SeparatedHistoryTurns {
turns_for_llm,
prior_user_queries,
has_prior_compaction,
}
}
/// Assemble the final user-queries preamble that gets prepended to the
/// compaction summary: `prior\n\ncurrent\n\n`. Either side may be `None`;
/// when both are `None` an empty string is returned.
///
/// Used by both pipelines:
/// - inter passes `current = extract_original_user_messages(raw_request, …)`
/// - intra passes `current = extract_user_queries_from_turns(turns, …)`
///
/// `prior` is always [`separate_prior_user_queries`]`.prior_user_queries`.
pub fn assemble_user_queries_preamble(prior: Option<String>, current: Option<String>) -> String {
let mut preamble = String::new();
if let Some(p) = &prior {
preamble.push_str(p);
preamble.push_str("\n\n");
}
if let Some(c) = &current {
preamble.push_str(c);
preamble.push_str("\n\n");
}
preamble
}
/// Convenience wrapper around [`extract_prior_user_queries`] +
/// [`assemble_user_queries_preamble`].
///
/// Used by callers that don't separately need the
/// compaction-stripped item list (e.g. tests). Both production pipelines
/// instead call [`separate_prior_user_queries`] once and reuse both its
/// outputs (the stripped item list goes to the LLM, the prior queries
/// go to [`assemble_user_queries_preamble`]).
pub fn build_user_queries_preamble<T: CompactionItemBuilder>(
turns: &[T],
current_user_queries: Option<String>,
) -> String {
assemble_user_queries_preamble(extract_prior_user_queries(turns), current_user_queries)
}
/// Wrap a single chunk's thinking text in a `<chunk_analysis index="i">…</chunk_analysis>` block.
///
/// Returns the empty string when `thinking` is empty after trimming so we don't
/// persist empty wrappers.
pub fn wrap_chunk_analysis(index: usize, thinking: &str) -> String {
let trimmed = thinking.trim();
if trimmed.is_empty() {
return String::new();
}
format!(
"<chunk_analysis index=\"{}\">\n{}\n</chunk_analysis>\n\n",
index, trimmed
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item::CompactionFileRef;
/// Pure mock item for the shared filter algorithms.
#[derive(Debug, Clone, PartialEq)]
enum MockItem {
System,
Developer { text: String, summary: bool },
User { text: String },
Assistant { text: Option<String>, tools: bool },
Tool,
}
impl MockItem {
fn user(text: &str) -> Self {
Self::User {
text: text.to_string(),
}
}
fn summary(text: &str) -> Self {
Self::Developer {
text: text.to_string(),
summary: true,
}
}
}
impl CompactionItem for MockItem {
fn role(&self) -> CompactionRole {
match self {
Self::System => CompactionRole::System,
Self::Developer { .. } => CompactionRole::Developer,
Self::User { .. } => CompactionRole::User,
Self::Assistant { .. } => CompactionRole::Assistant,
Self::Tool => CompactionRole::Tool,
}
}
fn text(&self) -> Option<String> {
match self {
Self::Developer { text, .. } | Self::User { text } => Some(text.clone()),
Self::Assistant { text, .. } => text.clone(),
_ => None,
}
}
fn has_tool_requests(&self) -> bool {
matches!(self, Self::Assistant { tools: true, .. })
}
fn is_compaction_summary(&self) -> bool {
matches!(self, Self::Developer { summary: true, .. })
}
fn attachment_refs(&self) -> Vec<CompactionFileRef> {
Vec::new()
}
}
impl CompactionItemBuilder for MockItem {
fn compaction_summary_item(text: String) -> Self {
Self::Developer {
text,
summary: true,
}
}
fn strip_tool_content(&self) -> Option<Self> {
match self {
Self::Assistant { text: Some(t), .. } if !t.is_empty() => Some(Self::Assistant {
text: Some(t.clone()),
tools: false,
}),
Self::Assistant { .. } => None,
other => Some(other.clone()),
}
}
}
#[test]
fn basic_filter_drops_system_and_plain_developer() {
let items = vec![
MockItem::System,
MockItem::Developer {
text: "agent prompt".into(),
summary: false,
},
MockItem::summary("prior summary"),
MockItem::user("hi"),
MockItem::Tool,
];
let kept = filter_turns_for_basic(&items);
assert_eq!(
kept,
vec![
MockItem::summary("prior summary"),
MockItem::user("hi"),
MockItem::Tool
]
);
}
#[test]
fn inter_filter_drops_tools_and_strips_assistant() {
let items = vec![
MockItem::Tool,
MockItem::Assistant {
text: Some("visible".into()),
tools: true,
},
MockItem::Assistant {
text: None,
tools: true,
},
MockItem::user("q"),
];
let kept = filter_turns_for_inter_compaction(&items);
assert_eq!(
kept,
vec![
MockItem::Assistant {
text: Some("visible".into()),
tools: false
},
MockItem::user("q"),
]
);
}
#[test]
fn extract_user_queries_returns_none_when_no_user_turns() {
let turns = vec![MockItem::Assistant {
text: Some("a".into()),
tools: false,
}];
assert!(extract_user_queries_from_turns(&turns, 3_000).is_none());
}
#[test]
fn extract_user_queries_wraps_single_user_turn() {
let turns = vec![MockItem::user("hello world")];
let out = extract_user_queries_from_turns(&turns, 3_000).expect("got block");
assert!(out.starts_with("<grok_user_queries>"));
assert!(out.ends_with("</grok_user_queries>"));
assert!(out.contains("<grok_query>hello world</grok_query>"));
}
#[test]
fn extract_user_queries_truncates_long_messages() {
let long = "x".repeat(5_000);
let turns = vec![MockItem::user(&long)];
let out = extract_user_queries_from_turns(&turns, 100).expect("got block");
assert!(out.contains("...[truncated]..."));
assert!(!out.contains(&"x".repeat(5_000)));
}
#[test]
fn extract_prior_user_queries_concatenates_blocks() {
let inner = "<grok_user_queries>\n<grok_query>first</grok_query>\n</grok_user_queries>";
let inner2 = "<grok_user_queries>\n<grok_query>second</grok_query>\n</grok_user_queries>";
let turns = vec![MockItem::summary(inner), MockItem::summary(inner2)];
let out = extract_prior_user_queries(&turns).expect("found prior");
assert!(out.contains("first"));
assert!(out.contains("second"));
}
#[test]
fn extract_prior_user_queries_none_for_non_compaction_turns() {
let turns = vec![MockItem::user("hi")];
assert!(extract_prior_user_queries(&turns).is_none());
}
#[test]
fn separate_strips_user_queries_from_summary_item() {
let prior = "<grok_user_queries>\n<grok_query>Q1</grok_query>\n</grok_user_queries>\n\n<chunk_summary index=\"0\">S1</chunk_summary>";
let turns = vec![MockItem::summary(prior), MockItem::user("Q2")];
let sep = separate_prior_user_queries(&turns);
assert!(sep.has_prior_compaction);
let prior = sep.prior_user_queries.expect("prior queries extracted");
assert!(prior.contains("Q1"));
assert!(prior.contains("<grok_user_queries>"));
assert_eq!(sep.turns_for_llm.len(), 2);
match &sep.turns_for_llm[0] {
MockItem::Developer { text, summary } => {
assert!(*summary);
assert!(text.contains("<chunk_summary"));
assert!(!text.contains("<grok_user_queries>"));
assert!(!text.contains("Q1"));
}
other => panic!("expected summary item, got {:?}", other),
}
assert!(matches!(&sep.turns_for_llm[1], MockItem::User { .. }));
}
#[test]
fn separate_drops_summary_item_with_no_rest() {
let only_queries = "<grok_user_queries>\n<grok_query>Q</grok_query>\n</grok_user_queries>";
let turns = vec![MockItem::summary(only_queries), MockItem::user("hello")];
let sep = separate_prior_user_queries(&turns);
assert!(sep.has_prior_compaction);
assert!(sep.prior_user_queries.unwrap().contains("Q"));
assert_eq!(sep.turns_for_llm.len(), 1);
assert!(matches!(&sep.turns_for_llm[0], MockItem::User { .. }));
}
#[test]
fn separate_passes_through_when_no_prior_compaction() {
let turns = vec![MockItem::user("hi"), MockItem::user("there")];
let sep = separate_prior_user_queries(&turns);
assert!(!sep.has_prior_compaction);
assert!(sep.prior_user_queries.is_none());
assert_eq!(sep.turns_for_llm.len(), 2);
}
#[test]
fn separate_records_recompaction_flag_even_without_user_queries_block() {
let no_block = "<chunk_summary index=\"0\">just a summary</chunk_summary>";
let turns = vec![MockItem::summary(no_block)];
let sep = separate_prior_user_queries(&turns);
assert!(sep.has_prior_compaction);
assert!(sep.prior_user_queries.is_none());
assert_eq!(sep.turns_for_llm.len(), 1);
}
#[test]
fn assemble_empty_when_both_none() {
assert!(assemble_user_queries_preamble(None, None).is_empty());
}
#[test]
fn assemble_prior_only() {
let out = assemble_user_queries_preamble(Some("PRIOR".into()), None);
assert_eq!(out, "PRIOR\n\n");
}
#[test]
fn assemble_current_only() {
let out = assemble_user_queries_preamble(None, Some("CURRENT".into()));
assert_eq!(out, "CURRENT\n\n");
}
#[test]
fn assemble_combines_prior_then_current() {
let out = assemble_user_queries_preamble(Some("PRIOR".into()), Some("CURRENT".into()));
assert_eq!(out, "PRIOR\n\nCURRENT\n\n");
}
#[test]
fn test_wrap_chunk_analysis_empty_thinking() {
assert_eq!(wrap_chunk_analysis(0, ""), "");
assert_eq!(wrap_chunk_analysis(2, " \n\t"), "");
}
#[test]
fn test_wrap_chunk_analysis_non_empty_thinking() {
let wrapped = wrap_chunk_analysis(3, "reasoned about X");
assert_eq!(
wrapped,
"<chunk_analysis index=\"3\">\nreasoned about X\n</chunk_analysis>\n\n"
);
}
#[test]
fn test_wrap_chunk_analysis_trims() {
let wrapped = wrap_chunk_analysis(0, " reasoned about X \n");
assert_eq!(
wrapped,
"<chunk_analysis index=\"0\">\nreasoned about X\n</chunk_analysis>\n\n"
);
}
}

View file

@ -0,0 +1,23 @@
//! Conversation history compaction — shared selection/assembly logic for compacting
//! prior conversation turns into a summary.
//!
//! Everything here is generic over [`CompactionItem`](crate::CompactionItem)
//! / [`CompactionItemBuilder`](crate::CompactionItemBuilder) or pure
//! string/text manipulation. Harness-bound extraction (Grok chat's
//! `GrokConversation` traversal, `ChatCompletionRequest` user-message
//! extraction, `GrokMessage` assembly) stays in the harness crate.
pub mod filter;
pub mod prompt;
pub mod types;
pub mod validate;
pub use filter::{
SeparatedHistoryTurns, assemble_user_queries_preamble, build_user_queries_preamble,
extract_prior_user_queries, extract_user_queries_from_turns, filter_turns_for_basic,
filter_turns_for_inter_compaction, keep_turn_for_basic_compaction, separate_prior_user_queries,
split_prior_compaction_text, truncate_middle, wrap_chunk_analysis,
};
pub use prompt::{format_compaction_developer_prompt, format_compaction_user_prompt};
pub use types::CompactionStrategy;
pub use validate::{CompactionValidationError, validate_compaction_text};

View file

@ -0,0 +1,42 @@
//! Prompt construction for conversation history compaction.
//!
//! The developer and user prompts are intentionally identical so the model
//! sees the instructions on both turns.
use anyhow::Result;
/// Builds the developer prompt to send to the compaction model.
pub fn format_compaction_developer_prompt() -> Result<String> {
Ok(include_str!("../templates/compaction_developer_prompt.txt").to_string())
}
/// Builds the user prompt to send to the compaction model.
pub fn format_compaction_user_prompt() -> Result<String> {
Ok(include_str!("../templates/compaction_user_prompt.txt").to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_are_non_empty() {
let dev = format_compaction_developer_prompt().expect("dev prompt renders");
assert!(!dev.trim().is_empty(), "developer prompt empty");
let user = format_compaction_user_prompt().expect("user prompt renders");
assert!(!user.trim().is_empty(), "user prompt empty");
}
/// Belt-and-suspenders: the developer and user prompts are intentionally
/// identical so the model sees the instructions on both turns. If you edit
/// one, edit the other — this test catches drift.
#[test]
fn compaction_prompts_match() {
let dev = format_compaction_developer_prompt().expect("dev prompt renders");
let user = format_compaction_user_prompt().expect("user prompt renders");
assert_eq!(
dev, user,
"compaction_developer_prompt.txt and compaction_user_prompt.txt must stay in sync"
);
}
}

View file

@ -0,0 +1,30 @@
//! Shared types for conversation history compaction.
use serde::{Deserialize, Serialize};
/// Strategy for how conversation compaction is performed.
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CompactionStrategy {
/// Send all turns to the LLM in one shot (original behaviour).
#[default]
Basic,
/// Divide turns into ≤ `dnc_chunk_token_limit` chunks, compact each,
/// then combine the summaries into a final compaction.
DivideAndConquer,
/// grok-build style full-replace summarization: summarize the selected
/// persisted history range with the code-compaction full-replace prompt and
/// persist the summary as the durable conversation compaction overlay.
FullReplace,
}
impl CompactionStrategy {
/// Stable, low-cardinality metric label for this strategy.
pub fn label(&self) -> &'static str {
match self {
Self::Basic => "basic",
Self::DivideAndConquer => "divide_and_conquer",
Self::FullReplace => "full_replace",
}
}
}

View file

@ -0,0 +1,108 @@
//! Compaction result validation (text-level, harness-agnostic).
//!
//! The Grok chat's `validate_compaction_result(GrokMessage, …)` wrapper in
//! the harness crate extracts the message text and delegates here.
use super::types::CompactionStrategy;
/// Errors from validating a compaction result before persisting.
#[derive(Debug)]
pub enum CompactionValidationError {
/// The compaction output has no text content. Persisting an empty
/// summary would be silently skipped on hydration while blocking
/// future compaction triggers.
EmptyContent,
/// DivideAndConquer `<chunk_summary>` XML tags are not balanced, indicating
/// the LLM output was truncated or malformed. The content may be partially
/// usable but signals an incomplete compaction.
UnbalancedChunkTags { open: usize, close: usize },
}
impl std::fmt::Display for CompactionValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyContent => write!(f, "compaction message has empty text content"),
Self::UnbalancedChunkTags { open, close } => {
write!(
f,
"unbalanced chunk_summary tags: {} open, {} close",
open, close
)
}
}
}
}
/// Validate compaction output text before persisting.
///
/// Checks:
/// 1. Non-empty text content — an empty compaction would be silently skipped
/// on hydration while blocking future compaction triggers.
/// 2. DivideAndConquer: balanced `<chunk_summary>` tags — unbalanced tags
/// indicate truncated LLM output.
pub fn validate_compaction_text(
text_content: &str,
strategy: &CompactionStrategy,
) -> Result<(), CompactionValidationError> {
// 1. Non-empty text content
if text_content.trim().is_empty() {
return Err(CompactionValidationError::EmptyContent);
}
// 2. DnC: validate chunk_summary tags are balanced
if matches!(strategy, CompactionStrategy::DivideAndConquer) {
let open_count = text_content.matches("<chunk_summary").count();
let close_count = text_content.matches("</chunk_summary>").count();
if open_count != close_count {
return Err(CompactionValidationError::UnbalancedChunkTags {
open: open_count,
close: close_count,
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_content_rejected() {
assert!(matches!(
validate_compaction_text("", &CompactionStrategy::Basic),
Err(CompactionValidationError::EmptyContent)
));
assert!(matches!(
validate_compaction_text(" \n ", &CompactionStrategy::DivideAndConquer),
Err(CompactionValidationError::EmptyContent)
));
}
#[test]
fn valid_basic_accepted() {
assert!(validate_compaction_text("A valid summary", &CompactionStrategy::Basic).is_ok());
}
#[test]
fn unbalanced_dnc_tags_rejected() {
let text = "<chunk_summary index=\"0\">\nsummary\n</chunk_summary>\n<chunk_summary index=\"1\">\nmissing close";
assert!(matches!(
validate_compaction_text(text, &CompactionStrategy::DivideAndConquer),
Err(CompactionValidationError::UnbalancedChunkTags { open: 2, close: 1 })
));
}
#[test]
fn balanced_dnc_tags_accepted() {
let text = "<chunk_summary index=\"0\">\nsummary 0\n</chunk_summary>\n<chunk_summary index=\"1\">\nsummary 1\n</chunk_summary>";
assert!(validate_compaction_text(text, &CompactionStrategy::DivideAndConquer).is_ok());
}
#[test]
fn basic_ignores_unbalanced_tags() {
let text = "<chunk_summary index=\"0\">no close tag";
assert!(validate_compaction_text(text, &CompactionStrategy::Basic).is_ok());
}
}

View file

@ -0,0 +1,272 @@
//! Inter-compaction chunked pipeline (shared core).
//!
//! Single pipeline shared by both `CompactionStrategy::Basic` and
//! `CompactionStrategy::DivideAndConquer`. The only difference between
//! the two is the per-chunk token budget:
//!
//! - **Basic** → unbounded chunk budget → exactly one chunk.
//! - **DivideAndConquer** → `config.dnc_chunk_token_limit` → N chunks.
//!
//! Everything else — turn filtering, prior-compaction user-query
//! extraction, chunk summarisation, and the final `<grok_user_queries>`
//! + `<chunk_summary>` assembly — is shared. The harness supplies the
//! candidate items, the *current* user-queries preamble (Grok chat
//! extracts it from the raw `ChatCompletionRequest`), the sampler, the
//! token counter, and an observer for metrics.
use std::time::{Duration, Instant};
use tracing::info;
use crate::history::filter::{
assemble_user_queries_preamble, filter_turns_for_inter_compaction, separate_prior_user_queries,
wrap_chunk_analysis,
};
use crate::history::prompt::{format_compaction_developer_prompt, format_compaction_user_prompt};
use crate::history::types::CompactionStrategy;
use crate::item::CompactionItemBuilder;
use crate::prompt::CompactionPrompt;
use crate::sampler::{CompactionSampleError, CompactionSampler, LlmCompactionOutput};
use crate::token::ItemTokenCounter;
use super::config::InterCompactionConfig;
use super::observer::InterCompactionObserver;
/// Sentinel chunk budget used by [`CompactionStrategy::Basic`] so the
/// chunking loop emits exactly one chunk.
const UNBOUNDED_CHUNK_LIMIT: u32 = u32::MAX;
/// Output of the shared chunked pipeline — assembled text, not yet wrapped
/// into a harness message type.
#[derive(Debug, Clone)]
pub struct ChunkedCompactionOutput {
/// `<grok_user_queries>` preamble + `<chunk_summary index="i">` blocks.
/// The harness wraps this into its summary-carrier message.
pub combined_text: String,
/// Thinking-channel output: `<chunk_analysis>` blocks. Empty when the
/// model produced no thinking output. Stored for audit/debug only.
pub analysis_text: String,
}
/// Shared chunked pipeline.
///
/// Steps:
/// 1. Filter items with
/// [`filter_turns_for_inter_compaction`](crate::history::filter::filter_turns_for_inter_compaction).
/// 2. [`separate_prior_user_queries`] — split prior `<grok_user_queries>`
/// blocks out of every prior compaction summary item. The LLM never sees
/// them. Shared with intra-compaction's `History` target so both
/// pipelines handle re-compactions identically.
/// 3. Walk the LLM-safe item list. Flush a chunk whenever the running
/// token count would exceed the chunk budget (`UNBOUNDED_CHUNK_LIMIT`
/// for Basic — single chunk).
/// 4. Combine `prior_user_queries + current_user_queries + <chunk_summary>`
/// blocks into the final summary text via
/// [`assemble_user_queries_preamble`]; combine the per-chunk
/// `thinking` channels into the analysis text.
///
/// `current_user_queries` is the harness-extracted preamble for *this*
/// round's user messages (Grok chat: verbatim from the raw request, with
/// attachment refs). `conversation_id` / `response_id` are threaded
/// through for log correlation only.
///
/// Observer events (the Grok chat observer maps them to the
/// pre-unification metrics):
/// - [`InterCompactionObserver::on_recompaction`] when prior-compaction
/// summary items are found.
/// - [`InterCompactionObserver::on_chunk_count`] — chunk count after
/// assembly (always 1 for Basic; N for DnC).
/// - [`InterCompactionObserver::on_chunk_sampled`] — per-chunk LLM latency.
#[allow(clippy::too_many_arguments)]
pub async fn sample_compaction_chunked<T: CompactionItemBuilder + Send + Sync>(
turns: &[T],
current_user_queries: Option<String>,
conversation_id: &str,
response_id: &str,
start_response_id: &str,
config: &InterCompactionConfig,
sampler: &dyn CompactionSampler<Item = T>,
token_counter: &dyn ItemTokenCounter<T>,
observer: &dyn InterCompactionObserver,
) -> Result<ChunkedCompactionOutput, CompactionSampleError> {
let chunk_token_limit = match config.compaction_strategy {
CompactionStrategy::Basic => UNBOUNDED_CHUNK_LIMIT,
CompactionStrategy::DivideAndConquer => config.dnc_chunk_token_limit,
CompactionStrategy::FullReplace => {
return Err(CompactionSampleError::Build(
"full_replace must be routed through the event-proc compact_conversation helper"
.to_string(),
));
}
};
let strategy_label = config.compaction_strategy.label();
info!(
conversation_id = %conversation_id,
response_id = %response_id,
strategy = strategy_label,
num_turns = turns.len(),
chunk_token_limit,
user_compact_threshold = config.user_message_compact_threshold,
"[InterCompaction] starting chunked compaction"
);
// Step 1 — filter.
let filtered = filter_turns_for_inter_compaction(turns);
info!(
conversation_id = %conversation_id,
start_response_id = %start_response_id,
last_response_id = %response_id,
original = turns.len(),
filtered = filtered.len(),
"[InterCompaction] filtered turns"
);
if filtered.is_empty() {
return Err(CompactionSampleError::Other(anyhow::anyhow!(
"No turns remaining after filtering"
)));
}
// Step 2 — split prior `<grok_user_queries>` out of every prior
// compaction summary item. The LLM never sees them (it would re-emit
// them verbatim and snowball across rounds); they are reattached to
// the final summary via `assemble_user_queries_preamble`. Shared with
// intra-compaction's `History` target.
let separated = separate_prior_user_queries(&filtered);
// Step 3 — chunk + flush over the LLM-safe item list.
let mut compactable: Vec<T> = Vec::new();
let mut chunk_tokens: u32 = 0;
let mut chunk_outputs: Vec<LlmCompactionOutput> = Vec::new();
let mut chunk_idx: usize = 0;
for turn in &separated.turns_for_llm {
let turn_tokens = token_counter.count_item_tokens(turn);
// Flush the current chunk if adding this item would exceed the
// budget (`UNBOUNDED_CHUNK_LIMIT` disables flushing — Basic).
if !compactable.is_empty()
&& chunk_token_limit != UNBOUNDED_CHUNK_LIMIT
&& chunk_tokens.saturating_add(turn_tokens) > chunk_token_limit
{
let output = flush_chunk(
&compactable,
conversation_id,
response_id,
chunk_idx,
config,
sampler,
token_counter,
observer,
)
.await?;
chunk_outputs.push(output);
chunk_idx += 1;
compactable.clear();
chunk_tokens = 0;
}
compactable.push(turn.clone());
chunk_tokens += turn_tokens;
}
// Final flush — one chunk for Basic, the trailing chunk for DnC.
if !compactable.is_empty() {
let output = flush_chunk(
&compactable,
conversation_id,
response_id,
chunk_idx,
config,
sampler,
token_counter,
observer,
)
.await?;
chunk_outputs.push(output);
}
if separated.has_prior_compaction {
observer.on_recompaction(strategy_label);
info!(
conversation_id = %conversation_id,
strategy = strategy_label,
"[InterCompaction] Re-compaction detected"
);
}
// Step 4a — combine summaries.
let preamble =
assemble_user_queries_preamble(separated.prior_user_queries, current_user_queries);
let mut combined = preamble;
for (i, output) in chunk_outputs.iter().enumerate() {
combined.push_str(&format!("<chunk_summary index=\"{}\">\n", i));
combined.push_str(&output.response);
combined.push_str("\n</chunk_summary>\n\n");
}
// Step 4b — combine thinking-channel output.
let mut combined_analysis = String::new();
for (i, output) in chunk_outputs.iter().enumerate() {
combined_analysis.push_str(&wrap_chunk_analysis(i, &output.thinking));
}
// Record chunk count after assembly so dashboards see the same timing
// they saw pre-unification (where this lived inside DnC).
observer.on_chunk_count(chunk_outputs.len());
info!(
conversation_id = %conversation_id,
response_id = %response_id,
strategy = strategy_label,
num_chunks = chunk_outputs.len(),
combined_len = combined.len(),
analysis_len = combined_analysis.len(),
"[InterCompaction] chunked compaction complete"
);
Ok(ChunkedCompactionOutput {
combined_text: combined,
analysis_text: combined_analysis,
})
}
/// Compact a single chunk of items via the LLM.
#[allow(clippy::too_many_arguments)]
async fn flush_chunk<T: CompactionItemBuilder + Send + Sync>(
turns: &[T],
conversation_id: &str,
response_id: &str,
chunk_idx: usize,
config: &InterCompactionConfig,
sampler: &dyn CompactionSampler<Item = T>,
token_counter: &dyn ItemTokenCounter<T>,
observer: &dyn InterCompactionObserver,
) -> Result<LlmCompactionOutput, CompactionSampleError> {
let total_tokens: u32 = turns
.iter()
.map(|t| token_counter.count_item_tokens(t))
.sum();
info!(
conversation_id = %conversation_id,
response_id = %response_id,
chunk_idx = chunk_idx,
num_turns = turns.len(),
total_tokens = total_tokens,
"[InterCompaction] Compacting chunk"
);
let prompt = CompactionPrompt {
system: format_compaction_developer_prompt().map_err(CompactionSampleError::from)?,
user: format_compaction_user_prompt().map_err(CompactionSampleError::from)?,
};
let timeout = Duration::from_secs(config.sampling_timeout_secs);
let t0 = Instant::now();
let result = sampler.sample_compaction(turns, &prompt, timeout).await;
observer.on_chunk_sampled(result.is_ok(), t0.elapsed());
info!(
conversation_id = %conversation_id,
chunk_idx = chunk_idx,
elapsed_ms = t0.elapsed().as_millis() as u64,
success = result.is_ok(),
"[InterCompaction] Chunk compaction done"
);
result
}

View file

@ -0,0 +1,34 @@
//! Configuration for inter-compaction.
//!
//! This is a plain data struct — harness-specific service-config integration
//! stays in the compaction subscriber, which resolves config values and
//! constructs this struct.
use serde::{Deserialize, Serialize};
use crate::history::types::CompactionStrategy;
/// Runtime configuration for a single inter-compaction invocation.
///
/// Mirrors the fields used by the between-turn compaction service config,
/// without a harness-specific config-macro dependency.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterCompactionConfig {
/// The agent/scheduler name to use for the compaction model.
///
/// NOTE: model routing is host policy — kept here only because
/// service configs deserialize this struct as-is; slated to move to the
/// per-harness policy split in a later phase.
pub compaction_model_name: String,
/// End-to-end timeout for the compaction sampling in seconds.
pub sampling_timeout_secs: u64,
/// Which compaction strategy to use.
pub compaction_strategy: CompactionStrategy,
/// [DivideAndConquer] Max tokens per chunk before sending to the LLM.
/// (Basic strategy ignores this and emits a single chunk.)
pub dnc_chunk_token_limit: u32,
/// User messages with character count > this threshold are truncated
/// (middle-cut) when assembling the `<grok_user_queries>` preamble.
/// Applies to both Basic and DivideAndConquer.
pub user_message_compact_threshold: u32,
}

View file

@ -0,0 +1,15 @@
//! Inter-compaction — the chunked summarisation pipeline shared by both
//! `Basic` and `DivideAndConquer` strategies, generic over
//! [`CompactionItemBuilder`](crate::CompactionItemBuilder).
//!
//! Harness wiring (turn selection from the conversation store, raw-request
//! user-query extraction, summary-message assembly, persistence) stays
//! per-harness; the Grok chat host wraps this pipeline.
pub mod compact;
pub mod config;
pub mod observer;
pub use compact::{ChunkedCompactionOutput, sample_compaction_chunked};
pub use config::InterCompactionConfig;
pub use observer::InterCompactionObserver;

View file

@ -0,0 +1,23 @@
//! Observability seam for inter-compaction.
//!
//! Same rationale as [`crate::intra_compaction::observer`]: the shared
//! pipeline reports events; each harness emits its own metrics. Emission
//! points and label values are part of the behavior contract.
use std::time::Duration;
/// Receives inter-compaction pipeline events. All methods default to no-ops.
pub trait InterCompactionObserver: Send + Sync {
/// A prior compaction summary was found in the input (re-compaction).
/// `strategy` is the stable label from `CompactionStrategy::label()`.
fn on_recompaction(&self, _strategy: &'static str) {}
/// One chunk's LLM call finished (success or error).
fn on_chunk_sampled(&self, _success: bool, _elapsed: Duration) {}
/// The whole pipeline finished assembling `num_chunks` chunk summaries.
fn on_chunk_count(&self, _num_chunks: usize) {}
}
/// No-op observer for tests and harnesses without metrics.
impl InterCompactionObserver for () {}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}

View file

@ -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,
};

View file

@ -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 () {}

View file

@ -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>;
}

View file

@ -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());
}
}

View file

@ -0,0 +1,183 @@
//! Data abstraction — the `CompactionItem` seam.
//!
//! The shared compaction algorithms operate over a sequence of *items*
//! (turns/messages) without knowing the concrete harness type. The chat
//! harness implements [`CompactionItem`] for its `GrokTurn`;
//! grok-build implements it for `xai_grok_sampling_types::ConversationItem`.
//!
//! Keeping the contract minimal is deliberate: the algorithms only need
//! enough structure to (a) classify roles, (b) read text, and (c) preserve
//! the tool-request/tool-result pairing invariant when selecting a split
//! point (an `Assistant(tool_request)` and the `Tool` results that satisfy
//! it must never be separated, or the model API rejects the orphaned tool
//! results with a 400).
//!
//! [`CompactionItemBuilder`] is the *constructive* extension used by the
//! history-compaction algorithms that need to rebuild items (strip prior
//! `<grok_user_queries>` blocks, drop tool content from assistant turns,
//! wrap an LLM summary into a carrier item).
/// Harness-agnostic role of a single conversation item.
///
/// This is the common denominator of `GrokRole` (Grok chat) and the
/// `ConversationItem` variants (grok-build).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionRole {
/// System prompt.
System,
/// Developer prompt (Grok chat) — maps to System on harnesses without a
/// distinct developer role.
Developer,
/// A user message.
User,
/// An assistant output (may carry tool requests).
Assistant,
/// A tool result.
Tool,
}
/// A file attached to a user item, as seen by the shared user-query
/// extraction (`<grok_file id=".." name=".." />` lines in the
/// `<grok_user_queries>` preamble).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionFileRef {
/// Stable unique id of the attachment source.
pub id: String,
/// Human-readable file name.
pub name: String,
}
/// Contract: one turn/item in a conversation, as seen by the shared
/// compaction algorithms.
///
/// Implementors:
/// - Grok chat: `GrokTurn`
/// - grok-build: `ConversationItem`
pub trait CompactionItem {
/// The harness-agnostic role of this item.
fn role(&self) -> CompactionRole;
/// The item's text content, if any. Tool results and assistant tool-only
/// turns may have no text.
///
/// Returns an owned `String` because some harnesses (Grok chat's
/// `GrokTurn`) compute the flattened text on demand rather than storing a
/// borrowable slice.
fn text(&self) -> Option<String>;
/// Whether this item is a tool result. Used by the split-point selector to
/// avoid orphaning tool results from their originating assistant turn.
fn is_tool_result(&self) -> bool {
matches!(self.role(), CompactionRole::Tool)
}
/// Whether this (assistant) item carries at least one tool request.
/// `false` for all non-assistant items.
fn has_tool_requests(&self) -> bool;
/// Whether this item carries a *prior compaction summary* (Grok chat: a
/// `Developer` turn with `DeveloperPromptCategory::ConversationCompaction`).
///
/// The basic history filter keeps such items so earlier summaries get
/// re-summarised instead of dropped, and `separate_prior_user_queries`
/// strips their `<grok_user_queries>` blocks before sampling.
///
/// Required (no default) on purpose: a forgotten implementation or a
/// missed `Arc` forwarding would silently drop prior summaries on
/// re-compaction.
fn is_compaction_summary(&self) -> bool;
/// File attachments on a (user) item, for the `<grok_file>` lines in the
/// `<grok_user_queries>` preamble. Empty for items without attachments.
///
/// Required (no default) for the same reason as
/// [`Self::is_compaction_summary`]: silent attachment loss on compaction
/// must be a compile error, not a runtime surprise.
fn attachment_refs(&self) -> Vec<CompactionFileRef>;
}
/// Constructive extension of [`CompactionItem`] for algorithms that rebuild
/// items (history filtering and summary-carrier construction).
///
/// Not object-safe (`compaction_summary_item` has no receiver) — always used
/// through generics, never as `dyn`.
pub trait CompactionItemBuilder: CompactionItem + Clone {
/// Construct the item that carries a compaction summary back into the
/// conversation (Grok chat: a `Developer` turn with category
/// `ConversationCompaction`). The result must satisfy
/// `is_compaction_summary() == true`.
fn compaction_summary_item(text: String) -> Self;
/// Rebuild this item keeping only user-visible content, dropping tool
/// requests/results (Grok chat: keep only `Channel` contents of an
/// assistant turn). Returns `None` when nothing visible remains.
///
/// Only meaningful for `Assistant` items; the shared filters never call
/// it for other roles, but implementations should return
/// `Some(self.clone())` for them to keep the contract total.
fn strip_tool_content(&self) -> Option<Self>;
}
/// Write seam for the full-replace **assembler**
/// ([`crate::code_compaction::assemble::assemble_compacted_history`]):
/// constructs the typed harness items that make up grok-build's rebuilt
/// history.
///
/// This is a sibling of [`CompactionItemBuilder`], not a part of it, on
/// purpose. `CompactionItemBuilder` is already implemented by Grok chat's
/// `GrokTurn`; adding these constructors to it as required methods would break
/// that impl. They are also grok-build-specific (Grok chat's tail-keep path
/// has no `user_meta` / `project_instructions` / `system_reminder` carrier
/// concept), so they live in their own seam that only the full-replace
/// assembler depends on.
///
/// The grok-build implementor (`ConversationItem`) maps each constructor to the
/// matching factory so the `SyntheticReason` tags the replay / spawn-time
/// idempotence guards rely on are preserved.
pub trait CompactionItemFactory: Sized {
/// A real user message (used for the last user query).
fn new_user(text: String) -> Self;
/// A synthetic user message carrying compaction metadata (user-info
/// prefix, summary carrier).
fn new_user_meta(text: String) -> Self;
/// A user message carrying project instructions (AGENTS.md), tagged so
/// spawn-time idempotence guards recognize it on resume.
fn new_project_instructions(text: String) -> Self;
/// A synthetic user message carrying a `<system-reminder>` block.
fn new_system_reminder(text: String) -> Self;
}
/// Forward [`CompactionItem`] through shared references so the algorithms can
/// operate over `&[Arc<T>]` (Grok chat stores turns as `Arc<GrokTurn>`).
impl<T: CompactionItem + ?Sized> CompactionItem for std::sync::Arc<T> {
fn role(&self) -> CompactionRole {
(**self).role()
}
fn text(&self) -> Option<String> {
(**self).text()
}
fn is_tool_result(&self) -> bool {
(**self).is_tool_result()
}
fn has_tool_requests(&self) -> bool {
(**self).has_tool_requests()
}
fn is_compaction_summary(&self) -> bool {
(**self).is_compaction_summary()
}
fn attachment_refs(&self) -> Vec<CompactionFileRef> {
(**self).attachment_refs()
}
}
/// Forward [`CompactionItemBuilder`] through `Arc` — rebuilt items are
/// wrapped in a fresh `Arc`, untouched items are *not* deep-cloned (the
/// shared filters clone the `Arc` pointer directly).
impl<T: CompactionItemBuilder> CompactionItemBuilder for std::sync::Arc<T> {
fn compaction_summary_item(text: String) -> Self {
std::sync::Arc::new(T::compaction_summary_item(text))
}
fn strip_tool_content(&self) -> Option<Self> {
(**self).strip_tool_content().map(std::sync::Arc::new)
}
}

View file

@ -0,0 +1,83 @@
//! Shared, transport-agnostic compaction engine.
//!
//! This crate is the `compaction-core`: shared policy, prompts, selection,
//! and assembly. Host-specific trigger wiring, transport, persistence /
//! replay / rewind, state commit, metrics backends, and prompt-variant forks
//! stay in each product host (for example `xai-grok-shell`).
//!
//! The crate depends on **neither** a conversation-type crate nor
//! `xai-grok-sampling-types`. It is decoupled from both Grok chat and
//! grok-build hosts through a small set of trait seams:
//!
//! - [`CompactionItem`] / [`CompactionRole`] / [`CompactionItemBuilder`] —
//! abstracts a single turn and its reconstruction.
//! - [`ItemTokenCounter`] — trusted token counting per host.
//! - [`CompactionSampler`] — the LLM call.
//! - [`CompactionStreamProc`](intra_compaction::CompactionStreamProc) —
//! state commit for intra-compaction.
//! - [`IntraCompactionObserver`](intra_compaction::IntraCompactionObserver) /
//! [`InterCompactionObserver`](inter_compaction::InterCompactionObserver)
//! — host metrics.
//!
//! Compaction styles live in their own modules:
//!
//! - [`code_compaction`] — grok-build's whole-session **full-replace**
//! subsystem (prompt/summary/failure/config, assemble, orchestration).
//! - [`intra_compaction`] — Grok chat's tail-keep, per-step pass.
//! - [`inter_compaction`] — Grok chat's chunked, between-turn pass.
//!
//! Compaction-type content (parallel subfolders): [`steps`] (the step-level
//! prompt) and [`history`] (filtering, history prompts, validation +
//! user-query preservation).
//!
//! Shared seams/primitives: [`item`], [`token`], [`sampler`],
//! [`prompt::CompactionPrompt`], [`select`] (tool-pair-safe tail-keep
//! selection — shared by the intra `Steps` and `History` targets, so it stays
//! neutral at the crate root rather than under `steps`), and [`reminder`]
//! (active-agent-state `<system-reminder>` formatting shared by Grok chat and
//! grok-build; hosts still own snapshotting and host-only sections).
pub mod code_compaction;
pub mod history;
pub mod inter_compaction;
pub mod intra_compaction;
pub mod item;
pub mod prompt;
pub mod reminder;
pub mod sampler;
pub mod select;
pub mod steps;
pub mod token;
/// Shared code default for the dedicated compaction model name.
///
/// Override order (highest first):
/// 1. the agent's `compaction_model_name` setting (non-blank)
/// 2. service / harness config YAML
/// 3. this constant
pub use intra_compaction::DEFAULT_COMPACTION_MODEL_NAME;
// grok-build's full-replace subsystem now lives under `code_compaction`;
// re-exported at the crate root so consumers keep a stable public API.
pub use code_compaction::{
CompactedHistoryParts, DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT, FailureKind,
FullReplaceAttemptOutcome, FullReplaceConfig, FullReplaceContext, FullReplaceError,
FullReplaceObserver, FullReplaceOutput, FullReplaceSummary, MIN_SUMMARY_SEED_CHARS,
SELF_SUMMARIZATION_PROMPT, SummaryPromptKind, apply_full_replace_compaction,
assemble_compacted_history, build_summary_prompt, build_summary_prompt_kind,
classify_http_status, classify_stream_event_error, format_compact_summary,
format_compact_summary_content, is_context_length_error, is_degenerate_summary,
sample_full_replace_summary, wrap_user_query,
};
pub use item::{
CompactionFileRef, CompactionItem, CompactionItemBuilder, CompactionItemFactory, CompactionRole,
};
pub use prompt::CompactionPrompt;
// Reminder types/formatters: import from `reminder::` (borrowed views).
// Only the summary-injection helper is re-exported at the crate root — both
// intra FullReplace and inter already use it by this name.
pub use reminder::append_reminder_block;
pub use sampler::{CompactionSampleError, CompactionSampler, LlmCompactionOutput};
pub use select::{SplitPlan, select_turns_to_compact};
pub use steps::format_compaction_prompt;
pub use token::ItemTokenCounter;

View file

@ -0,0 +1,16 @@
//! The shared compaction prompt seam.
//!
//! [`CompactionPrompt`] is the system+user prompt pair every orchestrator's
//! [`CompactionSampler`](crate::sampler::CompactionSampler) call takes. The
//! per-strategy prompt *content* lives with each subsystem:
//!
//! - steps prompt → [`crate::steps::format_compaction_prompt`]
//! - history prompts → [`crate::history::prompt`]
//! - grok-build summary prompt → [`crate::code_compaction::build_summary_prompt`]
/// System + user prompt pair for the compaction LLM call.
#[derive(Debug, Clone)]
pub struct CompactionPrompt {
pub system: String,
pub user: String,
}

View file

@ -0,0 +1,519 @@
//! Shared post-compaction reminder helpers (host-agnostic).
//!
//! Lives at the crate root rather than under a compaction-style submodule
//! because it is consumed by *both* compaction styles and both harnesses:
//!
//! - Grok chat intra FullReplace ([`crate::intra_compaction`]) and inter
//! (appends after sampling via [`append_reminder_block`])
//! - grok-build full-replace ([`crate::code_compaction`] assemble's
//! `system_reminder`)
//!
//! **What lives here:** pure formatting of the three **common** active-agent
//! sections — Running Background Tasks, TODO List, Running Subagents — plus
//! `<system-reminder>` wrapping and summary append.
//!
//! **What stays in the product host:** snapshotting, tool-name resolution, and harness-only
//! sections (files, AGENTS.md, skills, MCP, memory). Callers pass **borrowed
//! views** (`&str` over live state) so long fields (commands, todo content,
//! descriptions, ids) are not cloned just to format.
// ---------------------------------------------------------------------------
// Borrowed views over harness live state (no long-string clones)
// ---------------------------------------------------------------------------
/// Model-facing poll/cancel tool names from the current toolset.
/// Never hard-code: a client manifest can rename them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SubagentToolNames<'a> {
pub poll: &'a str,
pub cancel: &'a str,
}
/// Status of a todo item in the post-compaction reminder.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TodoStatus {
Pending,
InProgress,
Completed,
Cancelled,
}
impl TodoStatus {
pub fn is_actionable(self) -> bool {
matches!(self, Self::Pending | Self::InProgress)
}
pub fn tag(self) -> &'static str {
match self {
Self::Pending => "[pending]",
Self::InProgress => "[in_progress]",
Self::Completed => "[completed]",
Self::Cancelled => "[cancelled]",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TodoItem<'a> {
pub id: &'a str,
pub content: &'a str,
pub status: TodoStatus,
}
/// Still-running background task. `task_id` is rendered verbatim.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BackgroundTask<'a> {
pub task_id: &'a str,
pub command: &'a str,
/// Parenthetical status (typically `"running"`).
pub status: &'a str,
pub tool_name: Option<&'a str>,
}
/// Still-running sub-agent. `subagent_id` is rendered verbatim.
///
/// `subagent_type` / `description` are optional so chat (no type, optional
/// desc) and build (both present) share one line format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RunningSubagent<'a> {
pub subagent_id: &'a str,
pub subagent_type: Option<&'a str>,
pub description: Option<&'a str>,
pub elapsed_secs: u64,
}
/// Borrowed active-agent state for reminder rendering.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ActiveAgentReminderState<'a> {
pub running_commands: &'a [BackgroundTask<'a>],
pub todos: &'a [TodoItem<'a>],
pub running_subagents: &'a [RunningSubagent<'a>],
}
impl ActiveAgentReminderState<'_> {
pub fn is_empty(&self) -> bool {
self.running_commands.is_empty()
&& self.running_subagents.is_empty()
&& !self.has_actionable_todos()
}
pub fn has_actionable_todos(&self) -> bool {
self.todos.iter().any(|t| t.status.is_actionable())
}
}
// ---------------------------------------------------------------------------
// Section formatters
// ---------------------------------------------------------------------------
/// `## Running Background Tasks`, or `None` when empty.
pub fn section_background_tasks(tasks: &[BackgroundTask<'_>]) -> Option<String> {
if tasks.is_empty() {
return None;
}
let lines = tasks
.iter()
.map(|t| match t.tool_name {
Some(tool) => format!(
"- \"{}\": `{}` ({}, {})",
t.task_id, t.command, t.status, tool
),
None => format!("- \"{}\": `{}` ({})", t.task_id, t.command, t.status),
})
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"## Running Background Tasks\n\
These tasks are still running:\n{lines}"
))
}
/// `## TODO List` for actionable items, or `None` when none. Completed/
/// cancelled collapse to a count trailer.
pub fn section_todo_list(todos: &[TodoItem<'_>]) -> Option<String> {
let active: Vec<_> = todos
.iter()
.filter(|t| t.status.is_actionable())
.map(|t| format!("- {} {}: {}", t.status.tag(), t.id, t.content))
.collect();
if active.is_empty() {
return None;
}
let completed = todos
.iter()
.filter(|t| t.status == TodoStatus::Completed)
.count();
let cancelled = todos
.iter()
.filter(|t| t.status == TodoStatus::Cancelled)
.count();
let trailer = match (completed, cancelled) {
(0, 0) => String::new(),
(c, 0) => format!("\n({c} completed)"),
(0, k) => format!("\n({k} cancelled)"),
(c, k) => format!("\n({c} completed, {k} cancelled)"),
};
Some(format!(
"## TODO List\n\
This is your task list from before the conversation was compacted it is still \
active. Keep working through the items below and update their status as you make \
progress:\n{}{trailer}",
active.join("\n"),
))
}
/// `## Running Subagents`, or `None` when empty. Omit entirely when tool
/// names cannot be resolved rather than point at wrong names.
pub fn section_running_subagents(
subagents: &[RunningSubagent<'_>],
tools: &SubagentToolNames<'_>,
) -> Option<String> {
if subagents.is_empty() {
return None;
}
let lines = subagents
.iter()
.map(format_subagent_line)
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"## Running Subagents\n\
These subagents were launched before this compaction and are still running. \
Use `{}` with the subagent_id to check their status or retrieve results. \
Use `{}` with the subagent_id to cancel a subagent.\n{lines}",
tools.poll, tools.cancel
))
}
fn format_subagent_line(s: &RunningSubagent<'_>) -> String {
let mut head = format!("subagent_id: `{}`", s.subagent_id);
if let Some(ty) = s.subagent_type {
head.push_str(", type: `");
head.push_str(ty);
head.push('`');
}
if let Some(desc) = s.description {
head.push_str(", task: \"");
head.push_str(desc);
head.push('"');
}
format!("- {head} (running for {}s)", s.elapsed_secs)
}
/// Common sections in order: Background Tasks → TODO → Subagents.
/// Empty kinds omitted; subagents also omitted when `subagent_tools` is `None`.
pub fn format_active_agent_sections(
state: &ActiveAgentReminderState<'_>,
subagent_tools: Option<&SubagentToolNames<'_>>,
) -> Vec<String> {
let mut sections = Vec::with_capacity(3);
if let Some(s) = section_background_tasks(state.running_commands) {
sections.push(s);
}
if let Some(s) = section_todo_list(state.todos) {
sections.push(s);
}
if let Some(tools) = subagent_tools
&& let Some(s) = section_running_subagents(state.running_subagents, tools)
{
sections.push(s);
}
sections
}
/// Wrap non-empty sections in `<system-reminder>…</system-reminder>`.
pub fn wrap_system_reminder(sections: impl IntoIterator<Item = impl AsRef<str>>) -> Option<String> {
let mut body = String::new();
for s in sections {
let s = s.as_ref();
if s.trim().is_empty() {
continue;
}
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(s);
}
if body.is_empty() {
None
} else {
Some(format!("<system-reminder>\n{body}\n</system-reminder>"))
}
}
/// Full active-agent-state `<system-reminder>`, or `None` when nothing to preserve.
pub fn format_active_agent_reminder(
state: &ActiveAgentReminderState<'_>,
subagent_tools: Option<&SubagentToolNames<'_>>,
) -> Option<String> {
wrap_system_reminder(format_active_agent_sections(state, subagent_tools))
}
// ---------------------------------------------------------------------------
// Summary injection
// ---------------------------------------------------------------------------
/// Append a trailing block to a compaction summary, separated by a blank line.
/// Returns `summary` unchanged when `reminder` is `None` or blank.
pub fn append_reminder_block(summary: String, reminder: Option<&str>) -> String {
match reminder {
Some(reminder) if !reminder.trim().is_empty() => format!("{summary}\n\n{reminder}"),
_ => summary,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tools_native() -> SubagentToolNames<'static> {
SubagentToolNames {
poll: "get_task_output",
cancel: "kill_task",
}
}
fn tools_renamed() -> SubagentToolNames<'static> {
SubagentToolNames {
poll: "get_command_or_subagent_output",
cancel: "kill_command_or_subagent",
}
}
#[test]
fn empty_state_is_none() {
assert!(
format_active_agent_reminder(
&ActiveAgentReminderState::default(),
Some(&tools_native())
)
.is_none()
);
}
#[test]
fn missing_tool_names_omits_subagent_section_only() {
let agents = [RunningSubagent {
subagent_id: "sa-1",
subagent_type: None,
description: Some("x"),
elapsed_secs: 1,
}];
let state = ActiveAgentReminderState {
running_subagents: &agents,
..Default::default()
};
assert!(format_active_agent_reminder(&state, None).is_none());
let cmds = [BackgroundTask {
task_id: "bg-1",
command: "npm run dev",
status: "running",
tool_name: Some("run_terminal_command"),
}];
let state = ActiveAgentReminderState {
running_commands: &cmds,
..Default::default()
};
let out = format_active_agent_reminder(&state, None).expect("reminder");
assert!(out.contains("## Running Background Tasks"));
assert!(!out.contains("## Running Subagents"));
}
#[test]
fn renders_chat_style_subagent_ids_verbatim() {
let agents = [
RunningSubagent {
subagent_id: "019ea7f0-cb66-7aa2-9a09-488a3a795795",
subagent_type: None,
description: Some("deploy staging"),
elapsed_secs: 42,
},
RunningSubagent {
subagent_id: "sa-2",
subagent_type: None,
description: None,
elapsed_secs: 5,
},
];
let state = ActiveAgentReminderState {
running_subagents: &agents,
..Default::default()
};
let out = format_active_agent_reminder(&state, Some(&tools_native())).expect("reminder");
assert!(out.starts_with("<system-reminder>"));
assert!(out.ends_with("</system-reminder>"));
assert!(out.contains("subagent_id: `019ea7f0-cb66-7aa2-9a09-488a3a795795`"));
assert!(out.contains("task: \"deploy staging\" (running for 42s)"));
assert!(out.contains("subagent_id: `sa-2` (running for 5s)"));
assert!(!out.contains("task-019ea7f0"));
assert!(!out.contains("type:"));
}
#[test]
fn renders_build_style_subagent_with_type() {
let agents = [RunningSubagent {
subagent_id: "sub-1",
subagent_type: Some("explore"),
description: Some("find files"),
elapsed_secs: 5,
}];
let state = ActiveAgentReminderState {
running_subagents: &agents,
..Default::default()
};
let out = format_active_agent_reminder(&state, Some(&tools_renamed())).expect("reminder");
assert!(out.contains(
"- subagent_id: `sub-1`, type: `explore`, task: \"find files\" (running for 5s)"
));
}
#[test]
fn uses_renamed_tool_names_verbatim() {
let agents = [RunningSubagent {
subagent_id: "sa-1",
subagent_type: None,
description: Some("x"),
elapsed_secs: 1,
}];
let state = ActiveAgentReminderState {
running_subagents: &agents,
..Default::default()
};
let out = format_active_agent_reminder(&state, Some(&tools_renamed())).expect("reminder");
assert!(out.contains("get_command_or_subagent_output"));
assert!(!out.contains("get_task_output"));
}
#[test]
fn renders_background_tasks() {
let cmds = [
BackgroundTask {
task_id: "019f1723-a9f0-76f2-98ae-56af965922f6",
command: "npm run dev",
status: "running",
tool_name: Some("run_terminal_command"),
},
BackgroundTask {
task_id: "bg-2",
command: "cargo watch -x test",
status: "running",
tool_name: None,
},
];
let state = ActiveAgentReminderState {
running_commands: &cmds,
..Default::default()
};
let out = format_active_agent_reminder(&state, None).expect("reminder");
assert!(out.contains(
"- \"019f1723-a9f0-76f2-98ae-56af965922f6\": `npm run dev` (running, run_terminal_command)"
));
assert!(out.contains("- \"bg-2\": `cargo watch -x test` (running)"));
}
#[test]
fn renders_todo_list_without_tool_names() {
let todos = [
TodoItem {
id: "1",
content: "scaffold the app",
status: TodoStatus::Completed,
},
TodoItem {
id: "2",
content: "wire the API",
status: TodoStatus::InProgress,
},
TodoItem {
id: "3",
content: "write tests",
status: TodoStatus::Pending,
},
];
let state = ActiveAgentReminderState {
todos: &todos,
..Default::default()
};
let out = format_active_agent_reminder(&state, None).expect("reminder");
assert!(out.contains("- [in_progress] 2: wire the API"));
assert!(out.contains("- [pending] 3: write tests"));
assert!(out.contains("(1 completed)"));
assert!(!out.contains("scaffold the app"));
}
#[test]
fn only_completed_todos_is_none() {
let todos = [TodoItem {
id: "1",
content: "done",
status: TodoStatus::Completed,
}];
let state = ActiveAgentReminderState {
todos: &todos,
..Default::default()
};
assert!(format_active_agent_reminder(&state, None).is_none());
}
#[test]
fn section_order_background_todo_subagent() {
let cmds = [BackgroundTask {
task_id: "t1",
command: "npm run dev",
status: "running",
tool_name: None,
}];
let todos = [TodoItem {
id: "2",
content: "wire the API",
status: TodoStatus::InProgress,
}];
let agents = [RunningSubagent {
subagent_id: "sa-1",
subagent_type: None,
description: Some("deploy staging"),
elapsed_secs: 1,
}];
let state = ActiveAgentReminderState {
running_commands: &cmds,
todos: &todos,
running_subagents: &agents,
};
let out = format_active_agent_reminder(&state, Some(&tools_native())).expect("reminder");
let bg = out.find("## Running Background Tasks").expect("bg");
let todo = out.find("## TODO List").expect("todo");
let sub = out.find("## Running Subagents").expect("sub");
assert!(bg < todo && todo < sub, "order wrong:\n{out}");
}
#[test]
fn wrap_system_reminder_joins_and_skips_blank() {
let out = wrap_system_reminder(["## A\nx", "", " ", "## B\ny"]).expect("wrapped");
assert_eq!(
out,
"<system-reminder>\n## A\nx\n\n## B\ny\n</system-reminder>"
);
assert!(wrap_system_reminder(std::iter::empty::<&str>()).is_none());
}
#[test]
fn appends_after_blank_line() {
assert_eq!(
append_reminder_block("SUMMARY".to_string(), Some("REMINDER")),
"SUMMARY\n\nREMINDER"
);
}
#[test]
fn append_noop_when_none_or_blank() {
assert_eq!(
append_reminder_block("SUMMARY".to_string(), None),
"SUMMARY"
);
assert_eq!(
append_reminder_block("SUMMARY".to_string(), Some(" \n\t ")),
"SUMMARY"
);
}
}

View file

@ -0,0 +1,184 @@
//! The `CompactionSampler` seam — the LLM call that produces summaries —
//! plus its output and error types (shared failure classification).
use std::time::Duration;
use async_trait::async_trait;
use crate::prompt::CompactionPrompt;
// ---------------------------------------------------------------------------
// Sampler output + error types
// ---------------------------------------------------------------------------
/// Raw text captured from a compaction LLM call, split by channel.
///
/// Used by both intra- and inter-compaction. Intra-compaction uses only
/// `.response`; inter-compaction also persists `.thinking` for audit/debug.
#[derive(Debug, Default, Clone)]
pub struct LlmCompactionOutput {
/// Text from the response channel — the actual compaction summary.
pub response: String,
/// Text from the thinking channel — the model's chain-of-thought reasoning.
/// Stored for audit/debug only; never fed back into a conversation.
pub thinking: String,
}
/// Error types for compaction sampling, allowing callers to distinguish
/// deterministic failures (never retry) from transient ones.
///
/// Harnesses should prefer the structured variants ([`Self::Build`],
/// [`Self::Start`], [`Self::EmptyResponse`]) so the shared retry policy can
/// classify without string matching. [`Self::Other`] remains for samplers
/// that only surface an opaque error; the orchestrator falls back to
/// matching the literal messages produced by the Grok chat sampler —
/// keep those literals in sync (the `compaction_sample_error_to_intra*`
/// tests guard the mapping).
#[derive(Debug)]
pub enum CompactionSampleError {
/// The sampler hit its end-to-end timeout. Transient.
Timeout {
timeout_secs: u64,
collected_bytes: usize,
},
/// Sampler construction failed (bad config, unknown model). Deterministic.
Build(String),
/// The sampling call could not be started.
///
/// Classification is asymmetric for pre-migration parity: the *inter*
/// retry policy ([`Self::is_deterministic`]) treats it as deterministic
/// (no retry), while the *intra* orchestrator maps it to
/// `IntraCompactionError::SamplerStart` which its retry loop treats as
/// transient.
Start(String),
/// The model produced no response-channel content. Transient.
EmptyResponse,
/// Anything else — classified by string matching for backward
/// compatibility with samplers that pre-date the structured variants.
Other(anyhow::Error),
}
impl std::fmt::Display for CompactionSampleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Timeout {
timeout_secs,
collected_bytes,
} => write!(
f,
"Compaction sampling timed out after {}s (collected {} bytes so far)",
timeout_secs, collected_bytes
),
Self::Build(msg) => write!(f, "Compaction sampler build failed: {}", msg),
Self::Start(msg) => write!(f, "Compaction sampler start failed: {}", msg),
// Keep the "no response channel content" literal — the intra
// orchestrator's `Other(_)` fallback string-matches it.
Self::EmptyResponse => {
write!(f, "Compaction sampler returned no response channel content")
}
Self::Other(e) => write!(f, "{}", e),
}
}
}
impl From<anyhow::Error> for CompactionSampleError {
fn from(e: anyhow::Error) -> Self {
Self::Other(e)
}
}
impl CompactionSampleError {
/// Whether this error is deterministic — retrying with the same input
/// will produce the same failure.
pub fn is_deterministic(&self) -> bool {
match self {
Self::Timeout { .. } | Self::EmptyResponse => false,
Self::Build(_) | Self::Start(_) => true,
Self::Other(err) => {
let msg = err.to_string();
msg.contains("Failed to build AgenticScheduler")
|| msg.contains("Failed to start compaction sample")
}
}
}
}
// ---------------------------------------------------------------------------
// Sampler trait
// ---------------------------------------------------------------------------
/// Interface for the LLM call that produces compaction summaries.
///
/// Used by both intra-compaction (steps/history) and inter-compaction.
/// Implemented by each harness's sampler adapter; grok-build wires its own
/// transport.
///
/// Returns [`LlmCompactionOutput`] containing both response and thinking
/// channel text. Intra-compaction uses only `.response`; inter-compaction
/// also persists `.thinking` for audit/debug.
#[async_trait]
pub trait CompactionSampler: Send + Sync {
/// The harness's conversation item type.
type Item;
/// Run an LLM compaction call on the given items.
///
/// Implementations should:
/// - Build a synthetic conversation from the items + prompt.
/// - Honor the `timeout`.
/// - Collect both response and thinking channel text.
async fn sample_compaction(
&self,
turns: &[Self::Item],
prompt: &CompactionPrompt,
timeout: Duration,
) -> Result<LlmCompactionOutput, CompactionSampleError>;
}
#[cfg(test)]
mod tests {
use super::*;
/// Pins the inter-compaction retry classification for every variant —
/// `Start` is intentionally deterministic here (no inter retry) even
/// though the intra orchestrator retries its `SamplerStart` mapping.
/// See the doc on [`CompactionSampleError::Start`] before "fixing" this.
#[test]
fn is_deterministic_classification() {
assert!(
!CompactionSampleError::Timeout {
timeout_secs: 1,
collected_bytes: 0
}
.is_deterministic()
);
assert!(!CompactionSampleError::EmptyResponse.is_deterministic());
assert!(CompactionSampleError::Build("bad config".into()).is_deterministic());
assert!(CompactionSampleError::Start("no stream".into()).is_deterministic());
// Legacy string-matching fallback.
assert!(
CompactionSampleError::Other(anyhow::anyhow!(
"Failed to build AgenticScheduler: config error"
))
.is_deterministic()
);
assert!(
CompactionSampleError::Other(anyhow::anyhow!(
"Failed to start compaction sample: stream error"
))
.is_deterministic()
);
assert!(
!CompactionSampleError::Other(anyhow::anyhow!("transient stream error"))
.is_deterministic()
);
}
/// The `EmptyResponse` Display must keep the "no response channel
/// content" literal the intra `Other(_)` fallback string-matches.
#[test]
fn empty_response_display_keeps_match_literal() {
let msg = CompactionSampleError::EmptyResponse.to_string();
assert!(msg.contains("no response channel content"), "got: {msg}");
}
}

View file

@ -0,0 +1,279 @@
//! Turn selection for compaction.
//!
//! Walks the item list backward to find a split point: keep the newest items
//! whose cumulative token count fits the target budget, compact everything
//! older.
//!
//! The split point must respect a critical invariant: an assistant item with
//! tool requests and the subsequent tool-result items that satisfy those
//! requests must stay together. Splitting between them would produce orphan
//! tool results in the next prompt, which the model API rejects with a 400.
//!
//! This is the harness-agnostic core: it operates over any slice of
//! [`CompactionItem`], so both Grok chat (`GrokTurn`) and grok-build
//! (`ConversationItem`) share one implementation.
use crate::item::CompactionItem;
/// Output of [`select_turns_to_compact`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SplitPlan {
/// Compact items at indices `0..split_idx`. Keep `split_idx..total`.
pub split_idx: usize,
/// Sum of `item_token_counts[..split_idx]`.
pub tokens_to_compact: u32,
}
/// Decide where to split the items for compaction.
///
/// Algorithm:
/// 1. Walk backward from the newest item, accumulating "keep" tokens.
/// 2. The candidate split index is the first one where adding more would
/// exceed `target_tokens`.
/// 3. **Snap forward** to a safe boundary: if the split would orphan tool
/// results, walk forward until past the matching tool-result items.
/// 4. Return `None` if the resulting compactable region's token count is
/// below `min_compactable` — not worth running the LLM.
///
/// # Tool-pair boundary safety
///
/// `items` is the agent's running state. A typical sequence:
///
/// ```text
/// [Assistant(tool_request_A, tool_request_B),
/// Tool(A_result),
/// Tool(B_result),
/// Assistant(response_text),
/// Assistant(tool_request_C),
/// Tool(C_result),
/// ...]
/// ```
///
/// A safe split point is one where everything **before** the split is
/// self-contained (no dangling tool requests waiting for results that live
/// after the split).
///
/// The rule we enforce: the split index must not fall in the middle of a
/// `[Assistant-with-tool-requests, Tool, Tool, ...]` run. If the candidate
/// split lands on a tool-result item, walk it forward until we pass the last
/// tool-result item following the most recent assistant-with-tool-requests.
pub fn select_turns_to_compact<T: CompactionItem>(
item_token_counts: &[u32],
items: &[T],
target_tokens: u32,
min_compactable: u32,
) -> Option<SplitPlan> {
debug_assert_eq!(
item_token_counts.len(),
items.len(),
"token counts and items must have the same length"
);
let total = items.len();
if total == 0 {
return None;
}
// Step 1: Walk backward, sum "keep" tokens until target is reached.
// Find the highest split_idx such that sum(item_token_counts[split_idx..]) ≤ target_tokens.
let mut kept = 0u32;
let mut split_idx = total; // start with "compact nothing", will move down
for i in (0..total).rev() {
let count = item_token_counts[i];
if kept.saturating_add(count) > target_tokens {
// Adding this item would exceed the budget — split here.
split_idx = i + 1;
break;
}
kept = kept.saturating_add(count);
split_idx = i;
}
// If the whole list fits within the budget, nothing to compact.
if split_idx == 0 {
return None;
}
// Step 2: Snap the split forward to a safe boundary.
let safe_split_idx = snap_to_safe_boundary(items, split_idx);
// After snapping forward we might have eaten everything.
if safe_split_idx >= total {
return None;
}
// Step 3: Compute tokens to compact and check the minimum.
let tokens_to_compact: u32 = item_token_counts[..safe_split_idx]
.iter()
.copied()
.fold(0u32, u32::saturating_add);
if tokens_to_compact < min_compactable {
return None;
}
Some(SplitPlan {
split_idx: safe_split_idx,
tokens_to_compact,
})
}
/// If `candidate` lands on a tool-result item, advance forward past all
/// tool-result items in the same tool-pair run. The "run" is delimited by the
/// previous assistant item (with tool requests) and the next non-tool item.
///
/// In effect: ensure the split lands either right before an assistant, user,
/// system, or developer item — never between an assistant-with-tool-requests
/// and its tool results.
fn snap_to_safe_boundary<T: CompactionItem>(items: &[T], candidate: usize) -> usize {
let total = items.len();
if candidate >= total {
return total;
}
// If candidate is not a tool-result item, no snap needed.
if !items[candidate].is_tool_result() {
return candidate;
}
// Candidate is a tool-result item. Find the run of contiguous tool-result
// items (starting from the assistant-with-tool-requests that preceded
// them) and advance to just past the last one in that run.
let mut idx = candidate;
while idx < total && items[idx].is_tool_result() {
idx += 1;
}
idx
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item::CompactionRole;
/// Minimal mock implementing [`CompactionItem`] for selection tests.
struct MockItem {
role: CompactionRole,
}
impl MockItem {
fn user() -> Self {
Self {
role: CompactionRole::User,
}
}
fn assistant() -> Self {
Self {
role: CompactionRole::Assistant,
}
}
fn tool() -> Self {
Self {
role: CompactionRole::Tool,
}
}
}
impl CompactionItem for MockItem {
fn role(&self) -> CompactionRole {
self.role
}
fn text(&self) -> Option<String> {
None
}
fn has_tool_requests(&self) -> bool {
false
}
fn is_compaction_summary(&self) -> bool {
false
}
fn attachment_refs(&self) -> Vec<crate::item::CompactionFileRef> {
Vec::new()
}
}
#[test]
fn empty_returns_none() {
let items: Vec<MockItem> = vec![];
assert!(select_turns_to_compact(&[], &items, 100, 10).is_none());
}
#[test]
fn all_fits_in_budget_returns_none() {
let items = vec![MockItem::user(), MockItem::assistant()];
let counts = vec![10, 20];
assert!(select_turns_to_compact(&counts, &items, 1000, 5).is_none());
}
#[test]
fn splits_at_correct_index() {
// Total 100; target 30 → keep last few that fit in 30.
let items = vec![
MockItem::user(),
MockItem::assistant(),
MockItem::user(),
MockItem::assistant(),
];
let counts = vec![40, 30, 20, 10]; // keep last two (sum 30)
let plan = select_turns_to_compact(&counts, &items, 30, 5).expect("should split");
assert_eq!(plan.split_idx, 2);
assert_eq!(plan.tokens_to_compact, 70);
}
#[test]
fn below_min_compactable_returns_none() {
let items = vec![MockItem::user(), MockItem::assistant()];
let counts = vec![5, 100];
// Would split after index 0, but 5 < min_compactable (10).
assert!(select_turns_to_compact(&counts, &items, 50, 10).is_none());
}
#[test]
fn snaps_past_tool_results() {
// Layout: [User, Assistant-text, Assistant-with-tools, Tool, Tool, Assistant-text]
// If the naïve split lands on a Tool, snap forward past all Tools.
let items = vec![
MockItem::user(),
MockItem::assistant(),
MockItem::assistant(), // pretend this had tool_requests
MockItem::tool(),
MockItem::tool(),
MockItem::assistant(),
];
let counts = vec![10, 10, 10, 50, 50, 10];
// Target 60 → walking back: keep 10 (idx 5), keep 50 (idx 4)
// → 60 used. Adding idx 3 (50) overflows.
// Naïve split = 4. But items[4] is Tool → snap forward.
// Walk forward: items[4]=Tool, items[5]=Assistant → snap to 5.
let plan = select_turns_to_compact(&counts, &items, 60, 5).expect("should split");
assert_eq!(plan.split_idx, 5);
assert_eq!(plan.tokens_to_compact, 10 + 10 + 10 + 50 + 50);
}
#[test]
fn snap_does_not_advance_when_already_safe() {
let items = vec![
MockItem::user(),
MockItem::assistant(),
MockItem::user(), // safe split here
MockItem::assistant(),
];
let counts = vec![50, 50, 10, 10];
// Target 30 → keep last two (sum 20).
// Naïve split = 2. items[2] = User → safe, no snap needed.
let plan = select_turns_to_compact(&counts, &items, 30, 5).expect("should split");
assert_eq!(plan.split_idx, 2);
}
#[test]
fn snap_walks_to_end_returns_none() {
// Pathological: split would need to snap past all items.
let items = vec![MockItem::assistant(), MockItem::tool(), MockItem::tool()];
let counts = vec![10, 50, 50];
// Target 0 → naïve split = 1 (items[1] is Tool).
// Snap forward: items[1]=Tool, items[2]=Tool, idx=3=total.
// Return None — nothing left to keep.
assert!(select_turns_to_compact(&counts, &items, 0, 5).is_none());
}
}

View file

@ -0,0 +1,12 @@
//! Steps compaction — prompt content for compacting accumulated step
//! turns (tool calls + assistant responses) within a single agent turn.
//!
//! Parallel to [`crate::history`] (the history-compaction content): this is the
//! *steps* side. The orchestration that uses it lives in
//! [`crate::intra_compaction`] (the `Steps` target / `StepsOnly` mode), and the
//! turn selection it shares with the History target is the crate-root
//! [`select`](crate::select) primitive (not steps-specific).
pub mod prompt;
pub use prompt::format_compaction_prompt;

View file

@ -0,0 +1,31 @@
//! Prompt construction for **steps** compaction.
//!
//! The step-level intra-compaction prompt: short and focused on summarising
//! tool-call history mid-task. Parallel to [`crate::history::prompt`] (the
//! history-compaction prompts); templates live in the crate-root `templates/`.
use crate::prompt::CompactionPrompt;
/// Build the standard prompt for step-level intra-compaction.
///
/// The prompts are short and focused on summarising tool-call history
/// mid-task — the assistant has already done several steps of work and
/// we need to free up context so it can continue.
pub fn format_compaction_prompt() -> CompactionPrompt {
CompactionPrompt {
system: include_str!("../templates/intra_compaction_system.txt").to_string(),
user: include_str!("../templates/intra_compaction_user.txt").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_are_non_empty() {
let p = format_compaction_prompt();
assert!(!p.system.trim().is_empty(), "system prompt empty");
assert!(!p.user.trim().is_empty(), "user prompt empty");
}
}

View file

@ -0,0 +1,91 @@
Your task is to create a detailed summary of the Grok Chat conversation so far, paying close attention to the user's explicit requests and all previous actions as Grok (built by xAI).
This summary should be thorough in capturing technical details, code patterns, architectural decisions, tool chains, and verification steps that would be essential for continuing development, research, or complex tasks without losing context.
Important Clarification on Terminology (Broad File Definition):
Throughout this prompt, the term "file", "files", "file IDs", "file names", and "Files and Code Sections / Artifacts" are defined broadly. They explicitly include:
- Regular files and code files
- Attachments
- Images (uploaded images, generated images, viewed images, etc.)
- rendered image or content outputs
- Any other file-like content, media objects, visual artifacts, or structured content blocks that have appeared in the conversation history (including but not limited to uploads, generations, render components, or persistent references).
Only include information that is visible in the direct user-Grok conversation history (user messages + Grok's responses, reasoning, tool calls, and tool outputs). Do not include any internal team communication, chatroom messages, or multi-agent interactions.
Use your internal thinking channel to chronologically analyze each message and section of the conversation before producing the final summary, and ensure you've covered all necessary points.
During that analysis, thoroughly identify:
- The user's explicit requests and evolving intents
- Grok's approach to addressing them: reasoning steps, specific tool calls (including parallel calls), parameters, results, and how they were interpreted/synthesized (truth-seeking emphasis)
- Key decisions, technical concepts, code patterns, and architectural choices
- Specific details like:
- file IDs, attachment IDs, image references/URLs, render_result IDs (if any)
- file names, attachment names, image captions/descriptions, render component details (if any)
- full code snippets (especially recent ones or those executed in REPL)
- function signatures
- file edits / diffs
- tool call details (e.g., code_execution snippets, web_search queries, browse_page instructions, X search operators)
- render components used (if any)
- Errors encountered (tool failures, code exec errors, search limitations, reasoning issues) and how they were diagnosed/fixed
- Pay special attention to specific user feedback, especially if the user told you to do something differently, corrected facts, or changed direction.
Double-check for technical accuracy and completeness, addressing each required element thoroughly.
Your final summary must contain the following sections, in order:
1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail, including any evolution over the conversation.
2. Key Technical Concepts: List all important technical concepts, technologies, frameworks, and Grok-specific tool patterns discussed.
3. Tool Usage & Verification: Summarize significant tool calls (code_execution REPL state, web_search, browse_page, X tools, etc.), key information retrieved/verified, cross-referencing steps, and how they influenced decisions or responses.
4. Files, Attachments, Images, Render Results & Code Artifacts: Enumerate all specific file-like artifacts (broadly defined as above: files, attachments, images, render_results, etc.), code sections, or REPL executions examined, modified, or created. Pay special attention to the most recent messages and include full code snippets, image descriptions/references, render outputs, or attachment details where applicable, plus a summary of why this artifact is important for continuation.
5. Errors and Fixes: List all errors encountered (tool-related or otherwise), how you fixed them, and specific user feedback (especially "do something differently").
6. Problem Solving: Document problems solved, tool-assisted solutions, and any ongoing troubleshooting efforts.
7. All User Messages: List ALL user messages that are not tool results (verbatim or high-fidelity summary). These are critical for understanding feedback and intent changes.
Here's an example of how your output should be structured:
<example>
1. Primary Request and Intent:
[Detailed description]
2. Key Technical Concepts:
- [Concept 1]
- [Concept 2]
- [...]
3. Tool Usage & Verification:
- [Key tool calls and verification steps]
4. Files, Attachments, Images, Render Results & Code Artifacts:
- [Artifact 1 (broadly defined file/attachment/image/render etc.)]
- [file/attachment/image/render name and ID]
- [Summary of importance]
- [Changes or execution results]
- [Important Code Snippet / Image reference / Render details]
5. Errors and Fixes:
- [Error 1]: [How fixed] [User feedback]
6. Problem Solving:
[Description]
7. All User Messages:
- [Detailed non tool use user message]
- [...]
</example>
Output the summary directly using the section headings above. Do not wrap the output in any XML tags or other markup — emit the seven sections as plain text.
There may be additional summarization instructions provided in the included context. If so, follow these instructions when creating the above summary. Examples of instructions include:
<example>
## Compact Instructions
When summarizing focus on tool outputs, REPL state, code changes, test results, and recent user feedback/corrections. Include critical code snippets and tool calls verbatim.
</example>
<example>
# Summary instructions
When using compact mode — prioritize most recent tool results, executed code diffs, and exact user instructions on direction changes.
</example>

View file

@ -0,0 +1,91 @@
Your task is to create a detailed summary of the Grok Chat conversation so far, paying close attention to the user's explicit requests and all previous actions as Grok (built by xAI).
This summary should be thorough in capturing technical details, code patterns, architectural decisions, tool chains, and verification steps that would be essential for continuing development, research, or complex tasks without losing context.
Important Clarification on Terminology (Broad File Definition):
Throughout this prompt, the term "file", "files", "file IDs", "file names", and "Files and Code Sections / Artifacts" are defined broadly. They explicitly include:
- Regular files and code files
- Attachments
- Images (uploaded images, generated images, viewed images, etc.)
- rendered image or content outputs
- Any other file-like content, media objects, visual artifacts, or structured content blocks that have appeared in the conversation history (including but not limited to uploads, generations, render components, or persistent references).
Only include information that is visible in the direct user-Grok conversation history (user messages + Grok's responses, reasoning, tool calls, and tool outputs). Do not include any internal team communication, chatroom messages, or multi-agent interactions.
Use your internal thinking channel to chronologically analyze each message and section of the conversation before producing the final summary, and ensure you've covered all necessary points.
During that analysis, thoroughly identify:
- The user's explicit requests and evolving intents
- Grok's approach to addressing them: reasoning steps, specific tool calls (including parallel calls), parameters, results, and how they were interpreted/synthesized (truth-seeking emphasis)
- Key decisions, technical concepts, code patterns, and architectural choices
- Specific details like:
- file IDs, attachment IDs, image references/URLs, render_result IDs (if any)
- file names, attachment names, image captions/descriptions, render component details (if any)
- full code snippets (especially recent ones or those executed in REPL)
- function signatures
- file edits / diffs
- tool call details (e.g., code_execution snippets, web_search queries, browse_page instructions, X search operators)
- render components used (if any)
- Errors encountered (tool failures, code exec errors, search limitations, reasoning issues) and how they were diagnosed/fixed
- Pay special attention to specific user feedback, especially if the user told you to do something differently, corrected facts, or changed direction.
Double-check for technical accuracy and completeness, addressing each required element thoroughly.
Your final summary must contain the following sections, in order:
1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail, including any evolution over the conversation.
2. Key Technical Concepts: List all important technical concepts, technologies, frameworks, and Grok-specific tool patterns discussed.
3. Tool Usage & Verification: Summarize significant tool calls (code_execution REPL state, web_search, browse_page, X tools, etc.), key information retrieved/verified, cross-referencing steps, and how they influenced decisions or responses.
4. Files, Attachments, Images, Render Results & Code Artifacts: Enumerate all specific file-like artifacts (broadly defined as above: files, attachments, images, render_results, etc.), code sections, or REPL executions examined, modified, or created. Pay special attention to the most recent messages and include full code snippets, image descriptions/references, render outputs, or attachment details where applicable, plus a summary of why this artifact is important for continuation.
5. Errors and Fixes: List all errors encountered (tool-related or otherwise), how you fixed them, and specific user feedback (especially "do something differently").
6. Problem Solving: Document problems solved, tool-assisted solutions, and any ongoing troubleshooting efforts.
7. All User Messages: List ALL user messages that are not tool results (verbatim or high-fidelity summary). These are critical for understanding feedback and intent changes.
Here's an example of how your output should be structured:
<example>
1. Primary Request and Intent:
[Detailed description]
2. Key Technical Concepts:
- [Concept 1]
- [Concept 2]
- [...]
3. Tool Usage & Verification:
- [Key tool calls and verification steps]
4. Files, Attachments, Images, Render Results & Code Artifacts:
- [Artifact 1 (broadly defined file/attachment/image/render etc.)]
- [file/attachment/image/render name and ID]
- [Summary of importance]
- [Changes or execution results]
- [Important Code Snippet / Image reference / Render details]
5. Errors and Fixes:
- [Error 1]: [How fixed] [User feedback]
6. Problem Solving:
[Description]
7. All User Messages:
- [Detailed non tool use user message]
- [...]
</example>
Output the summary directly using the section headings above. Do not wrap the output in any XML tags or other markup — emit the seven sections as plain text.
There may be additional summarization instructions provided in the included context. If so, follow these instructions when creating the above summary. Examples of instructions include:
<example>
## Compact Instructions
When summarizing focus on tool outputs, REPL state, code changes, test results, and recent user feedback/corrections. Include critical code snippets and tool calls verbatim.
</example>
<example>
# Summary instructions
When using compact mode — prioritize most recent tool results, executed code diffs, and exact user instructions on direction changes.
</example>

View file

@ -0,0 +1,3 @@
You are summarizing the tool-call history of an AI assistant that is partway through answering a user's question.
The assistant has made several tool calls (web searches, file reads, code execution, etc.) and accumulated tool results that are now taking up too much context window space. Your summary will replace those tool calls + results, so the assistant can continue its work with the same effective knowledge but less context overhead.

View file

@ -0,0 +1,67 @@
Your task is to create a detailed summary of the tool-call history above, paying close attention to preserving all information the assistant needs to continue its current task without losing context.
This summary should be thorough in capturing technical details, code patterns, data points, and intermediate results that would be essential for continuing the current work.
CRITICAL: If the tool-call history contains a previous compaction summary (marked with "ConversationCompaction" or similar markers), you MUST incorporate ALL information from that previous summary into your new summary. Previous summaries contain essential context from earlier steps that would otherwise be lost.
Use your internal thinking channel to chronologically review each tool call and its result before producing the final summary, and ensure you've covered all necessary points.
During that analysis, thoroughly identify:
- What was searched, read, or executed and why
- Key findings, data points, and outcomes
- Specific details like:
- file paths, URLs, IDs, error messages
- full code snippets (especially recent ones)
- function signatures and configuration details
- tool call parameters and results
- Errors encountered and how they were resolved
- Double-check for completeness — every piece of data the assistant gathered must be preserved.
Your final summary must contain the following sections, in order:
1. Task and Intent: What the assistant is trying to accomplish for the user, including the current sub-goal.
2. Key Findings: Facts, data points (numbers, dates, IDs, URLs), schema details, and any other information gathered from tool calls. Preserve specific data verbatim.
3. Files and Code: Enumerate specific file paths examined, modified, or created. Include key code snippets, function signatures, and configuration details verbatim, plus a summary of why each file is important.
4. Errors and Fixes: All errors encountered, how each was resolved, including specific error messages verbatim.
5. Actions Taken: Successful modifications, commands run, and their outcomes.
6. Current Progress: What has been completed and what remains to be done.
Here's an example of how your output should be structured:
<example>
1. Task and Intent:
[Detailed description of what the assistant is working on]
2. Key Findings:
- [Finding 1 with specific data verbatim]
- [Finding 2]
- [...]
3. Files and Code:
- [file path 1]
- [Summary of importance]
- [Key code snippet or changes]
- [file path 2]
- [...]
4. Errors and Fixes:
- [Error message verbatim]: [How fixed]
5. Actions Taken:
- [Action 1]: [Outcome]
- [...]
6. Current Progress:
[What is done, what remains]
</example>
Output the summary directly using the section headings above. Do not wrap the output in any XML tags or other markup — emit the six sections as plain text.
IMPORTANT:
- Do NOT call any tools. Output the summary text only.
- Preserve specific data verbatim — URLs, file paths, code snippets, error messages, ID strings.
- Write in the same language as the conversation. If the conversation is primarily in Chinese, write the summary in Chinese (keep technical terms, file paths, and code in English).
- Do not invent information that is not in the tool-call history.

View file

@ -0,0 +1,24 @@
//! Token-count seam.
//!
//! Budgeting math in the shared engine needs a *trusted* token count, but the
//! two harnesses disagree on how to produce one:
//!
//! - Grok chat has a real tokenizer (`TextTokenizer` / `ImageTokenizer`) and
//! counts whole turns via `GrokTurn::get_num_tokens`.
//! - grok-build estimates with `bytes / 4`.
//!
//! Rather than bake either policy into the shared crate, callers supply an
//! [`ItemTokenCounter`]. This keeps the engine deterministic and testable
//! while letting each harness plug in its own counting strategy.
//!
//! There is intentionally **no** blanket `Arc` forwarding here: each harness
//! implements the counter directly for the item type its algorithms run on
//! (Grok chat: `ItemTokenCounter<Arc<GrokTurn>>`), so exactly one mechanism
//! is in play.
/// Counts tokens for a single conversation item on behalf of the shared
/// budgeting logic.
pub trait ItemTokenCounter<T: ?Sized>: Send + Sync {
/// Trusted token count of `item`.
fn count_item_tokens(&self, item: &T) -> u32;
}