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,28 @@
[package]
license = "Apache-2.0"
name = "xai-chat-state"
version = "0.1.0"
edition.workspace = true
description = "Actor-based chat state management for xAI agents"
[features]
default-bazel = []
[dependencies]
indexmap = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
strum = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt", "macros"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
xai-grok-compaction = { path = "../../common/xai-grok-compaction" }
xai-grok-sampling-types = { path = "../xai-grok-sampling-types" }
xai-token-estimation = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
[lints]
workspace = true

View file

@ -0,0 +1,391 @@
//! ChatStateActor — runs in a dedicated tokio task and owns all chat state.
//!
//! This module is organized into submodules by responsibility:
//! - `state`: Internal state types (ChatState)
//! - `mutations`: State mutation handlers (push_user_message, replace_conversation, etc.)
//! - `queries`: Read-only query handlers (get_conversation, snapshot, etc.)
mod mutations;
mod queries;
pub(crate) mod request_builder;
pub mod state;
#[cfg(test)]
mod tests;
use tokio::sync::mpsc;
use tracing::debug;
use crate::commands::ChatStateCommand;
use crate::events::ChatStateEvent;
use crate::handle::ChatStateHandle;
use crate::persistence::ChatPersistence;
use crate::types::{PruningConfig, TurnCapture};
use state::ChatState;
use xai_grok_sampling_types::{ConversationItem, SamplingConfig};
/// The actor that owns all chat state.
/// Runs in a dedicated tokio task and processes commands sequentially.
pub struct ChatStateActor {
/// Internal state — conversation, tokens, config, etc.
state: ChatState,
/// Pruning configuration for tool-result trimming.
pruning_config: PruningConfig,
/// Persistence implementation — owned exclusively, called with `&mut self`.
persistence: Box<dyn ChatPersistence>,
/// Channel to receive commands from handles.
cmd_rx: mpsc::UnboundedReceiver<ChatStateCommand>,
/// Channel to send events to the session main loop.
event_tx: mpsc::UnboundedSender<ChatStateEvent>,
/// Cancellation token for graceful shutdown.
cancellation_token: tokio_util::sync::CancellationToken,
}
impl ChatStateActor {
/// Send an event to subscribers, logging if the channel is closed.
fn send_event(&self, event: ChatStateEvent) {
if self.event_tx.send(event).is_err() {
debug!("ChatState event channel closed, event dropped");
}
}
/// Spawn the actor and return a handle to communicate with it.
pub fn spawn(
initial_conversation: Vec<ConversationItem>,
sampling_config: SamplingConfig,
persistence: Box<dyn ChatPersistence>,
event_tx: mpsc::UnboundedSender<ChatStateEvent>,
cancellation_token: tokio_util::sync::CancellationToken,
) -> ChatStateHandle {
Self::spawn_with_pruning(
initial_conversation,
sampling_config,
PruningConfig::default(),
persistence,
event_tx,
cancellation_token,
)
}
/// Spawn the actor with a custom pruning config.
pub fn spawn_with_pruning(
initial_conversation: Vec<ConversationItem>,
sampling_config: SamplingConfig,
pruning_config: PruningConfig,
persistence: Box<dyn ChatPersistence>,
event_tx: mpsc::UnboundedSender<ChatStateEvent>,
cancellation_token: tokio_util::sync::CancellationToken,
) -> ChatStateHandle {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let actor = ChatStateActor {
state: ChatState::new(initial_conversation, sampling_config),
pruning_config,
persistence,
cmd_rx,
event_tx,
cancellation_token,
};
tokio::spawn(actor.run());
ChatStateHandle::new(cmd_tx)
}
/// Main actor loop — processes commands until shutdown or cancellation.
async fn run(mut self) {
loop {
tokio::select! {
biased;
_ = self.cancellation_token.cancelled() => {
debug!("ChatStateActor shutting down via cancellation");
break;
}
cmd = self.cmd_rx.recv() => {
let Some(cmd) = cmd else {
debug!("ChatStateActor shutting down: all handles dropped");
break;
};
self.handle_command(cmd);
}
}
}
}
/// Dispatch a command to the appropriate mutation or query handler.
fn handle_command(&mut self, cmd: ChatStateCommand) {
match cmd {
// ═══ Mutations ═══
ChatStateCommand::PushUserMessage { item } => {
self.push_user_message(item);
}
ChatStateCommand::PushUserMessageAndAck { item, reply } => {
self.push_user_message(item);
let _ = reply.send(());
}
ChatStateCommand::PushUserMessageWithRepairReason { item, reason } => {
self.push_user_message_with_repair_reason(item, reason);
}
ChatStateCommand::PushAssistantResponse { item } => {
self.push_message(item);
}
ChatStateCommand::PushToolResult { item } => {
self.push_message(item);
}
ChatStateCommand::RecordTokenUsage { total_tokens } => {
self.record_token_usage(total_tokens);
}
ChatStateCommand::RecordLastTurnUsage { usage } => {
self.record_last_turn_usage(usage);
}
ChatStateCommand::RecordModelCallUsage {
model_id,
usage,
api_duration_ms,
cost_usd_ticks,
} => {
self.record_model_call_usage(model_id, &usage, api_duration_ms, cost_usd_ticks);
}
ChatStateCommand::RecordSubagentUsage {
by_model,
attribute_to_prompt,
incomplete,
reply,
} => {
self.record_subagent_usage(&by_model, attribute_to_prompt, incomplete);
let _ = reply.send(());
}
ChatStateCommand::MarkUsageIncomplete {
prompt,
session,
reply,
} => {
self.mark_usage_incomplete(prompt, session);
let _ = reply.send(());
}
ChatStateCommand::IncrementPromptIndex => {
self.increment_prompt_index();
}
ChatStateCommand::UpdateSamplingConfig { config } => {
self.state.sampling_config = config;
}
ChatStateCommand::RecordAgentEditedPath { path } => {
self.state.agent_edited_paths.insert(path);
}
ChatStateCommand::RecordStreamStart { timestamp_ms } => {
self.state.stream_start_ms = Some(timestamp_ms);
}
ChatStateCommand::RecordTurnStart { timestamp_ms } => {
self.state.turn_start_ms = Some(timestamp_ms);
}
ChatStateCommand::ReplaceConversation {
items,
is_compaction,
} => {
self.replace_conversation(items, is_compaction);
}
ChatStateCommand::RepairHistory {
dry_run,
turn_active,
reply,
} => {
// Checked here so refusal and mutation are serialized; a
// `false` at processing time means pre-turn state (see the
// command's doc).
let blocked = turn_active
.as_ref()
.map(|f| f.load(std::sync::atomic::Ordering::SeqCst))
.unwrap_or(false);
let result = if blocked {
Err(crate::commands::RepairHistoryBlocked)
} else {
Ok(self.repair_history(dry_run))
};
let _ = reply.send(result);
}
ChatStateCommand::ReplaceSystemHead { prompt, reply } => {
let changed = self.replace_system_head(&prompt);
let _ = reply.send(changed);
}
ChatStateCommand::CachePromptText { text } => {
self.state.prompt_texts.push(text);
}
ChatStateCommand::RecordCompactionAt { prompt_index } => {
self.state.last_compaction_prompt_index = Some(prompt_index);
}
ChatStateCommand::Flush => {
self.persistence.flush();
}
ChatStateCommand::UpdateCredentials { credentials } => {
self.state.credentials = credentials;
}
ChatStateCommand::RestoreSnapshot(snapshot) => {
self.restore_snapshot(*snapshot);
}
ChatStateCommand::BeginTurnCapture => {
self.state.turn_capture = Some(state::TurnCaptureState {
turn_start_offset: self.state.conversation.len(),
pre_replacement_messages: Vec::new(),
compaction_occurred: false,
});
}
ChatStateCommand::AppendHarnessTraceItems { items } => {
self.state.harness_trace_buffer.extend(items);
}
ChatStateCommand::FlushHarnessTraceTurn => {
self.state.seal_harness_trace_turn();
}
ChatStateCommand::RepairDanglingAfterHarnessHalt { class } => {
self.repair_dangling_after_harness_halt(class);
}
// ═══ Queries ═══
//
// Read queries are pure reads — repair only at write boundaries:
// `ChatState::new()` (startup) and `push_user_message()` (new turn).
// `BuildConversationRequest` retains the guard because it is only
// ever issued by the agent loop between turns, never by background tasks.
ChatStateCommand::BuildConversationRequest {
tool_definitions,
memory_reminder,
persist_memory_reminder,
trace,
conv_id,
req_id,
reply,
} => {
self.ensure_conversation_integrity();
let request = self.build_conversation_request(
tool_definitions,
memory_reminder,
persist_memory_reminder,
trace,
conv_id,
req_id,
);
let _ = reply.send(request);
}
ChatStateCommand::GetConversation { reply } => {
tracing::debug!(
conversation_len = self.state.conversation.len(),
"ChatState: cloning full conversation for GetConversation"
);
let _ = reply.send(self.state.conversation.clone());
}
ChatStateCommand::GetPromptIndex { reply } => {
let _ = reply.send(self.state.prompt_index);
}
ChatStateCommand::GetLastCompactionPromptIndex { reply } => {
let _ = reply.send(self.state.last_compaction_prompt_index);
}
ChatStateCommand::GetTotalTokens { reply } => {
let _ = reply.send(self.state.total_tokens);
}
ChatStateCommand::GetLastTurnUsage { reply } => {
let _ = reply.send(self.state.last_turn_usage.clone());
}
ChatStateCommand::GetPromptUsage { reply } => {
let _ = reply.send(self.state.prompt_usage.clone());
}
ChatStateCommand::GetSessionUsage { reply } => {
let _ = reply.send(self.state.session_usage.clone());
}
ChatStateCommand::GetEstimatedTotalTokens { reply } => {
let _ =
reply.send(self.state.total_tokens + self.state.estimated_tokens_since_model);
}
ChatStateCommand::GetSamplingConfig { reply } => {
let _ = reply.send(self.state.sampling_config.clone());
}
ChatStateCommand::GetAgentEditedPaths { reply } => {
let _ = reply.send(self.state.agent_edited_paths.clone());
}
ChatStateCommand::GetNotificationMeta { reply } => {
let _ = reply.send(self.get_notification_meta());
}
ChatStateCommand::Snapshot { reply } => {
tracing::debug!(
conversation_len = self.state.conversation.len(),
"ChatState: cloning full state for Snapshot"
);
let _ = reply.send(self.snapshot());
}
ChatStateCommand::TruncateToPromptIndex {
target_prompt_index,
reply,
} => {
self.truncate_to_prompt_index(target_prompt_index);
self.state.turn_capture = None;
self.state.prompt_usage = None;
// `harness_trace_buffer` / `harness_trace_turns` intentionally
// survive a rewind: the goal planner / verifier subagents
// genuinely ran, so their sealed trace turns stay uploadable as
// siblings even when the live turn that triggered them is undone.
let _ = reply.send(());
}
ChatStateCommand::CheckAutoCompactNeeded {
threshold_percent,
reply,
} => {
let _ = reply.send(self.check_auto_compact_needed(threshold_percent));
}
ChatStateCommand::GetCredentials { reply } => {
let _ = reply.send(self.state.credentials.clone());
}
ChatStateCommand::GetLastModelMetadata { reply } => {
let _ = reply.send(self.get_last_model_metadata());
}
ChatStateCommand::TakeTurnMessages { reply } => {
let result = self.state.turn_capture.take().map(|cap| {
let mut messages = cap.pre_replacement_messages;
messages.extend(
Self::turn_tail(&self.state.conversation, cap.turn_start_offset)
.iter()
.cloned(),
);
TurnCapture {
messages,
compaction_occurred: cap.compaction_occurred,
}
});
let _ = reply.send(result);
}
ChatStateCommand::TakeHarnessTraceTurns { reply } => {
// Defensive seal: a phase that recorded items but never flushed
// still rides its own turn rather than stranding.
self.state.seal_harness_trace_turn();
let _ = reply.send(std::mem::take(&mut self.state.harness_trace_turns));
}
// ─── Narrow targeted queries ──────────────────────────────────
ChatStateCommand::GetConversationLen { reply } => {
let _ = reply.send(self.get_conversation_len());
}
ChatStateCommand::HasDanglingToolCalls { reply } => {
let _ = reply.send(self.has_dangling_tool_calls());
}
ChatStateCommand::GetLastAssistantText { reply } => {
let _ = reply.send(self.get_last_assistant_text());
}
ChatStateCommand::GetFirstUserText { reply } => {
let _ = reply.send(self.get_first_user_text());
}
ChatStateCommand::GetConversationItemAt { index, reply } => {
let _ = reply.send(self.get_conversation_item_at(index));
}
ChatStateCommand::GetLastUserQueryText { reply } => {
let _ = reply.send(self.get_last_user_query_text());
}
ChatStateCommand::GetConversationCounts { reply } => {
let _ = reply.send(self.get_conversation_counts());
}
ChatStateCommand::GetSystemMessage { reply } => {
let _ = reply.send(self.get_system_message());
}
ChatStateCommand::GetEstimatedMessagesTokens { reply } => {
let _ = reply.send(state::estimate_messages_tokens(&self.state.conversation));
}
}
}
}

View file

@ -0,0 +1,532 @@
//! Mutation handlers for the ChatStateActor.
use xai_grok_sampling_types::{
ContentPart, ConversationItem, DanglingToolCallReason, dedup_duplicate_tool_results,
repair_dangling_tool_calls,
};
use super::ChatStateActor;
use super::request_builder::HARD_CLEAR_PLACEHOLDER;
use crate::events::ChatStateEvent;
use crate::types::ChatStateSnapshot;
/// Static string label for tracing on `ConversationItem` (avoids pulling
/// the `Role` enum into the format string).
fn item_kind_str(item: &ConversationItem) -> &'static str {
match item {
ConversationItem::System(_) => "system",
ConversationItem::User(_) => "user",
ConversationItem::Assistant(_) => "assistant",
ConversationItem::ToolResult(_) => "tool_result",
ConversationItem::BackendToolCall(_) => "backend_tool_call",
ConversationItem::Reasoning(_) => "reasoning",
}
}
impl ChatStateActor {
/// Repair any dangling tool calls in the conversation and persist the fix.
///
/// A "dangling" tool call is an assistant message with tool call IDs that
/// lack matching `ToolResult` entries. This can happen when:
/// - The user cancels (Ctrl+C) mid-tool-execution in a live session
/// - The process crashes between pushing the assistant and tool results
/// - The tokio task is aborted at an `.await` point
///
/// This method repairs the state in-place and persists the fix to disk.
/// It is idempotent — calling it on a clean conversation is a cheap no-op
/// (single forward scan, no allocations).
///
/// Only call at write boundaries where the previous turn is definitively
/// over (`ChatState::new()`, `push_user_message()`, `BuildConversationRequest`).
/// Do NOT call from read handlers — background tasks run concurrently with
/// tool execution and would misidentify in-flight calls as dangling.
pub(super) fn ensure_conversation_integrity(&mut self) {
self.ensure_conversation_integrity_with_reason(DanglingToolCallReason::UserCancelled);
}
/// Like [`Self::ensure_conversation_integrity`] but takes an explicit reason.
pub(super) fn ensure_conversation_integrity_with_reason(
&mut self,
reason: DanglingToolCallReason,
) {
// In-place integrity repair can add/remove items ahead of an active capture's
// boundary, so snapshot + rebase the offset like the replace/restore paths.
self.snapshot_turn_slice();
let deduped = dedup_duplicate_tool_results(&mut self.state.conversation);
if deduped > 0 {
tracing::info!(
deduped_count = deduped,
"Removed duplicate tool results in conversation"
);
}
let repaired = repair_dangling_tool_calls(&mut self.state.conversation, reason);
if repaired > 0 || deduped > 0 {
tracing::info!(
repaired_count = repaired,
"Repaired dangling tool calls in conversation"
);
self.persistence.replace_history(&self.state.conversation);
}
self.rebase_turn_capture_offset();
}
/// Repair dangling tool calls after a harness-initiated halt.
pub(super) fn repair_dangling_after_harness_halt(&mut self, class: &'static str) {
self.ensure_conversation_integrity_with_reason(DanglingToolCallReason::HarnessHalted {
class,
});
}
/// Out-of-band history repair (`x.ai/session/repair`): run
/// [`crate::compaction_utils::repair_history`] and persist changes via
/// [`Self::replace_conversation`]. Unlike
/// [`Self::ensure_conversation_integrity`], this also removes orphaned
/// `ToolResult`s — the shape that bricks a session with provider 400s.
/// `dry_run` only reports.
pub(super) fn repair_history(
&mut self,
dry_run: bool,
) -> crate::compaction_utils::HistoryRepairReport {
if dry_run {
let mut copy = self.state.conversation.clone();
return crate::compaction_utils::repair_history(&mut copy);
}
let mut items = std::mem::take(&mut self.state.conversation);
let report = crate::compaction_utils::repair_history(&mut items);
if report.changed() {
tracing::warn!(
duplicates_removed = report.duplicates_removed,
stripped_tool_result_ids = ?report.stripped_tool_result_ids,
synthetic_results_inserted = report.synthetic_results_inserted,
"History repair modified the conversation"
);
// Full replace: persists atomically and re-bases token estimates.
self.replace_conversation(items, false);
} else {
// Nothing changed — put the conversation back untouched.
self.state.conversation = items;
}
report
}
/// Push any conversation item (user, assistant, or tool result) and persist it.
pub(super) fn push_message(&mut self, item: ConversationItem) {
let count_in_delta = !matches!(item, ConversationItem::Assistant(_));
if count_in_delta {
let estimated_tokens = super::state::estimate_item_tokens(&item);
self.state.estimated_tokens_since_model += estimated_tokens;
tracing::debug!(
item_kind = item_kind_str(&item),
estimated_tokens_delta = estimated_tokens,
estimated_total = self.state.total_tokens + self.state.estimated_tokens_since_model,
model_reported_total = self.state.total_tokens,
"ChatState: push_message updated estimated_tokens_since_model"
);
}
self.persistence.persist_message(&item);
self.state.conversation.push(item);
}
/// Push a user message, ensuring conversation integrity first.
///
/// When the user cancels a turn while the model was executing parallel
/// tool calls, the conversation may have dangling tool call IDs. This
/// method repairs them before appending the new message so the on-disk
/// and in-memory state stay consistent.
///
/// Also runs [`prune_retained_conversation`] to eagerly hard-clear very
/// old tool results from the in-memory state, bounding long-session
/// retained memory without waiting for the context-window threshold.
pub(super) fn push_user_message(&mut self, item: ConversationItem) {
self.push_user_message_with_repair_reason(item, DanglingToolCallReason::UserCancelled);
}
/// Like [`Self::push_user_message`] but takes an explicit repair reason.
pub(super) fn push_user_message_with_repair_reason(
&mut self,
item: ConversationItem,
reason: DanglingToolCallReason,
) {
self.ensure_conversation_integrity_with_reason(reason);
let estimated_tokens = super::state::estimate_item_tokens(&item);
self.state.estimated_tokens_since_model += estimated_tokens;
tracing::debug!(
item_kind = item_kind_str(&item),
estimated_tokens_delta = estimated_tokens,
estimated_total = self.state.total_tokens + self.state.estimated_tokens_since_model,
model_reported_total = self.state.total_tokens,
"ChatState: push_user_message updated estimated_tokens_since_model"
);
self.persistence.persist_message(&item);
self.state.conversation.push(item);
self.prune_retained_conversation();
}
/// Eagerly hard-clear tool results from very old turns in the retained
/// in-memory conversation, freeing the actual string bytes.
///
/// Unlike the API-copy pruning in `build_conversation_request` (which runs
/// on a *clone* only when context > 50% full), this operates on
/// `self.state.conversation` directly and runs after every user turn.
///
/// # What this does
///
/// Only **hard-clears** are applied (no soft-trim). Soft-trimming is a
/// context-management operation that changes what the model sees;
/// hard-clearing is a memory-management operation that replaces content
/// that is so old the model should not need it again. The threshold is
/// controlled by `PruningConfig::hard_clear_age_turns`.
///
/// # Retained-memory measurement
///
/// When any clearing occurs, a `tracing::debug!` event reports:
/// - `hard_cleared` — number of tool results cleared
/// - `bytes_freed` — approximate bytes recovered (sum of content lengths)
/// - `conversation_len` — total item count after the pass
///
/// # Synthetic User items and turn-age accuracy
///
/// The shell can inject synthetic `User` items mid-turn (e.g. system
/// corrective warnings) without calling `increment_prompt_index`. These
/// do not represent real user turns. The backward scan here counts every
/// `User` item as a turn boundary, so synthetic items would normally cause
/// old tool results to appear older than they really are.
///
/// This is compensated by raising the effective clearing threshold by the
/// number of synthetic User items (`total_user_items - prompt_index`).
/// The result: a tool result is never cleared before `hard_clear_age_turns`
/// REAL turns have elapsed, even in sessions with many synthetic messages.
///
/// # Replay / rewind correctness
///
/// `updates.jsonl` is **never touched**, so cross-compaction
/// `replay_to_prompt` is unaffected. The pruned `chat_history.jsonl`
/// on disk mirrors the in-memory state — both lose old bulk content but
/// `updates.jsonl` retains the original data for replay.
pub(super) fn prune_retained_conversation(&mut self) -> usize {
if !self.pruning_config.enabled {
return 0;
}
// Fast exit: not enough turns have elapsed for any hard-clear to apply.
if self.state.prompt_index < self.pruning_config.hard_clear_age_turns {
return 0;
}
// Compute how many synthetic User items exist (system reminders, etc.).
// Synthetic User items are NOT real user turns — they are injected by the
// shell mid-turn and do not increment `prompt_index`. The naive backward
// scan counts every User item as a turn boundary, so synthetic items make
// old tool results appear older than they really are and can cause
// premature hard-clears.
//
// Fix: raise the effective clearing threshold by the number of synthetic
// User items. This guarantees a tool result is never cleared before
// `hard_clear_age_turns` REAL turns have elapsed, regardless of how many
// synthetic messages the session contains.
let total_user_items = self
.state
.conversation
.iter()
.filter(|i| matches!(i, ConversationItem::User(_)))
.count();
let synthetic_count = total_user_items.saturating_sub(self.state.prompt_index);
let effective_threshold = self
.pruning_config
.hard_clear_age_turns
.saturating_add(synthetic_count);
let before_bytes = self.conversation_content_bytes();
let mut cleared = 0usize;
let mut turn_from_end: usize = 0;
let mut seen_first_user = false;
for i in (0..self.state.conversation.len()).rev() {
if matches!(&self.state.conversation[i], ConversationItem::User(_)) {
if seen_first_user {
turn_from_end += 1;
}
seen_first_user = true;
continue;
}
let ConversationItem::ToolResult(tr) = &mut self.state.conversation[i] else {
continue;
};
if turn_from_end < effective_threshold {
continue;
}
if tr.content.as_ref() != HARD_CLEAR_PLACEHOLDER {
tr.content = std::sync::Arc::<str>::from(HARD_CLEAR_PLACEHOLDER);
cleared += 1;
}
}
if cleared > 0 {
let after_bytes = self.conversation_content_bytes();
tracing::debug!(
hard_cleared = cleared,
bytes_freed = before_bytes.saturating_sub(after_bytes),
conversation_len = self.state.conversation.len(),
"ChatState: in-memory tool-result prune"
);
self.persistence.replace_history(&self.state.conversation);
}
cleared
}
/// Approximate byte footprint of all string content in the conversation.
///
/// Used for before/after measurement logging when pruning runs.
/// Sums the byte lengths of all string fields; does not allocate.
fn conversation_content_bytes(&self) -> usize {
self.state
.conversation
.iter()
.map(|item| match item {
ConversationItem::System(s) => s.content.len(),
ConversationItem::User(u) => u
.content
.iter()
.map(|p| match p {
ContentPart::Text { text } => text.len(),
ContentPart::Image { url } => url.len(),
})
.sum::<usize>(),
ConversationItem::Assistant(a) => a.content.len(),
ConversationItem::ToolResult(tr) => tr.content.len(),
ConversationItem::BackendToolCall(b) => b.text_summary().len(),
ConversationItem::Reasoning(r) => {
xai_grok_sampling_types::reasoning_item_text(r).len()
+ r.encrypted_content.as_deref().map(str::len).unwrap_or(0)
}
})
.sum()
}
/// Record accumulated token usage and emit an event.
pub(super) fn record_token_usage(&mut self, total_tokens: u64) {
self.state.estimated_tokens_since_model = 0;
self.state.estimate_at_last_response =
super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.total_tokens = total_tokens;
self.send_event(ChatStateEvent::TokensUpdated { total_tokens });
}
/// Stash the per-turn `TokenUsage` from the most recent model response.
/// No event is emitted — this slot is read on demand at `PromptResponse`
/// construction time, not pushed to subscribers.
pub(super) fn record_last_turn_usage(&mut self, usage: xai_grok_sampling_types::TokenUsage) {
self.state.last_turn_usage = Some(usage);
}
pub(super) fn record_model_call_usage(
&mut self,
model_id: Option<String>,
usage: &xai_grok_sampling_types::TokenUsage,
api_duration_ms: Option<u64>,
cost_usd_ticks: Option<i64>,
) {
let model_key = match model_id.as_deref() {
Some(id) if !id.is_empty() => id,
_ => self.state.sampling_config.model.as_str(),
}
.to_owned();
self.state
.prompt_usage
.get_or_insert_default()
.record_main_loop_call(&model_key, usage, api_duration_ms, cost_usd_ticks);
self.state.session_usage.record_main_loop_call(
&model_key,
usage,
api_duration_ms,
cost_usd_ticks,
);
}
pub(super) fn record_subagent_usage(
&mut self,
by_model: &[(String, crate::usage::UsageTotals)],
attribute_to_prompt: bool,
incomplete: bool,
) {
if by_model.is_empty() && !incomplete {
return;
}
if attribute_to_prompt {
self.state
.prompt_usage
.get_or_insert_default()
.record_subagent(by_model, incomplete);
}
// The session ledger always folds, even when the usage is not
// attributable to the open prompt (its pin may belong to an earlier
// prompt). Reporting that gap is the coordinator's sticky flag's job —
// never mark a different live prompt's ledger.
self.state
.session_usage
.record_subagent(by_model, incomplete);
}
pub(super) fn mark_usage_incomplete(&mut self, prompt: bool, session: bool) {
if prompt {
self.state
.prompt_usage
.get_or_insert_default()
.mark_incomplete();
}
if session {
self.state.session_usage.mark_incomplete();
}
}
pub(super) fn increment_prompt_index(&mut self) {
self.state.prompt_usage = None;
self.state.prompt_index += 1;
self.send_event(ChatStateEvent::PromptIndexChanged {
new_index: self.state.prompt_index,
});
}
/// Replace the entire conversation, persist, re-estimate `total_tokens`,
/// and emit reset + token-update events.
///
/// Compaction replaces carry the provider-side overhead forward as a
/// *ratio* (`base_estimate × provider_total ÷ estimate_at_last_response`,
/// capped at the pre-compaction total; `base_estimate` when that estimate is
/// 0) so the reseed neither springs back nor over-counts (see
/// `COMPACTION.md`).
pub(super) fn replace_conversation(
&mut self,
items: Vec<ConversationItem>,
is_compaction: bool,
) {
self.snapshot_turn_slice();
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
cap.compaction_occurred = true;
}
let pre_replace_total = self.state.total_tokens;
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
// the planner/verifier subagents ran, so their sealed trace turns survive
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
self.persistence.replace_history(&items);
let base_estimate = super::state::estimate_conversation_tokens(&items);
let mut estimated_tokens =
if is_compaction && pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
(base_estimate as f64 * ratio).round() as u64
} else {
base_estimate
};
// Compaction must never appear to increase usage.
if is_compaction && pre_replace_total > 0 {
estimated_tokens = estimated_tokens.min(pre_replace_total);
}
self.state.conversation = items;
self.state.estimated_tokens_since_model = 0;
self.state.total_tokens = estimated_tokens;
self.state.estimate_at_last_response =
super::state::estimate_conversation_tokens(&self.state.conversation);
self.rebase_turn_capture_offset();
self.send_event(ChatStateEvent::ConversationReset {
new_len: self.state.conversation.len(),
});
self.send_event(ChatStateEvent::TokensUpdated {
total_tokens: estimated_tokens,
});
}
/// Atomically swap the leading `System` message with `prompt` (or insert one
/// if absent), persisting when changed. Runs inside the actor's command loop
/// so it serializes with turn pushes — no lost-update race on a mid-turn
/// reconnect. Returns whether the conversation changed.
///
/// The conversation is cloned (items are `Arc`-backed, so the clone is
/// shallow) rather than `mem::take`n: `replace_conversation` snapshots the
/// in-flight turn-capture tail from `state.conversation` before swapping,
/// so the state must stay intact until then.
pub(super) fn replace_system_head(&mut self, prompt: &str) -> bool {
if let Some(ConversationItem::System(sys)) = self.state.conversation.first()
&& crate::conversation_util::canonical_system_prompt_eq(sys.content.as_ref(), prompt)
{
return false;
}
let mut conversation = self.state.conversation.clone();
let changed =
crate::conversation_util::replace_or_insert_system_head(&mut conversation, prompt);
debug_assert!(changed, "head mismatch must produce a change");
self.replace_conversation(conversation, false);
changed
}
/// Restore all state fields from a snapshot.
pub(super) fn restore_snapshot(&mut self, snap: ChatStateSnapshot) {
self.snapshot_turn_slice();
// Harness trace buffers are transient (not part of the snapshot) and
// intentionally survive a restore — see `replace_conversation`.
self.state.conversation = snap.conversation;
self.rebase_turn_capture_offset();
self.state.sampling_config = snap.sampling_config;
self.state.prompt_index = snap.prompt_index;
self.state.total_tokens = snap.total_tokens;
self.state.estimated_tokens_since_model = 0;
self.state.estimate_at_last_response = if snap.estimate_at_last_response > 0 {
snap.estimate_at_last_response
} else {
super::state::estimate_conversation_tokens(&self.state.conversation)
};
self.state.agent_edited_paths = snap.agent_edited_paths;
self.state.prompt_texts = snap.prompt_texts;
self.state.stream_start_ms = snap.stream_start_ms;
self.state.turn_start_ms = snap.turn_start_ms;
self.state.last_compaction_prompt_index = snap.last_compaction_prompt_index;
self.state.credentials = snap.credentials;
// Drop abandoned prompt billing; session ledger is lifetime.
self.state.prompt_usage = None;
}
/// If turn capture is active, append the current turn's tail items into
/// `pre_replacement_messages` before an in-place mutation shifts or drops them.
pub(super) fn snapshot_turn_slice(&mut self) {
if let Some(cap) = &mut self.state.turn_capture {
cap.pre_replacement_messages
.extend_from_slice(Self::turn_tail(
&self.state.conversation,
cap.turn_start_offset,
));
}
}
/// Re-base an active turn capture's start offset to the current conversation
/// length after an in-place mutation, keeping the tail slice valid.
pub(super) fn rebase_turn_capture_offset(&mut self) {
if let Some(cap) = &mut self.state.turn_capture {
cap.turn_start_offset = self.state.conversation.len();
}
}
/// Fail-safe `conversation[offset..]` for turn capture: a capture accounting
/// slip must never abort the user's session (a raw index here SIGABRT-crashed
/// a live CLI), so an out-of-range offset yields an empty slice — loud in dev
/// via `debug_assert!`, with a prod breadcrumb via `error!`.
pub(super) fn turn_tail(
conversation: &[ConversationItem],
offset: usize,
) -> &[ConversationItem] {
debug_assert!(
offset <= conversation.len(),
"turn_start_offset {offset} > len {}",
conversation.len()
);
conversation.get(offset..).unwrap_or_else(|| {
tracing::error!(
offset,
len = conversation.len(),
"turn-capture offset past conversation end; trace tail dropped"
);
&[]
})
}
}

View file

@ -0,0 +1,227 @@
//! Query handlers for the ChatStateActor.
use super::ChatStateActor;
use crate::compaction_utils::extract_last_user_query;
use crate::events::ChatStateEvent;
use crate::types::{AutoCompactTrigger, ChatStateSnapshot, ConversationCounts, NotificationMeta};
impl ChatStateActor {
/// Build a notification meta from current timing state.
pub(super) fn get_notification_meta(&self) -> NotificationMeta {
NotificationMeta {
stream_start_ms: self.state.stream_start_ms,
turn_start_ms: self.state.turn_start_ms,
}
}
/// Take a full snapshot of the actor's state.
pub(super) fn snapshot(&self) -> ChatStateSnapshot {
ChatStateSnapshot {
conversation: self.state.conversation.clone(),
sampling_config: self.state.sampling_config.clone(),
prompt_index: self.state.prompt_index,
total_tokens: self.state.total_tokens,
estimate_at_last_response: self.state.estimate_at_last_response,
agent_edited_paths: self.state.agent_edited_paths.clone(),
prompt_texts: self.state.prompt_texts.clone(),
stream_start_ms: self.state.stream_start_ms,
turn_start_ms: self.state.turn_start_ms,
last_compaction_prompt_index: self.state.last_compaction_prompt_index,
credentials: self.state.credentials.clone(),
}
}
/// Truncate conversation to a target prompt index (rewind).
///
/// Walks the conversation to find the Nth `User` item (where N =
/// `target_prompt_index`), truncates everything from that point onward,
/// truncates `prompt_texts` to match, persists, and emits `ConversationReset`.
///
/// Prompt index semantics:
/// - 0 = no user turns have started (only system message, if any)
/// - 1 = one user turn completed
/// - N = N user turns completed
///
/// Truncating to `target_prompt_index = 1` keeps only items up to (but not
/// including) the 2nd `User` message.
pub(super) fn truncate_to_prompt_index(&mut self, target_prompt_index: usize) {
if target_prompt_index >= self.state.prompt_index {
// Nothing to truncate — already at or before the target.
return;
}
// Find the conversation position of the Nth User item.
// Items before that position are kept; from that position onward removed.
let mut user_count = 0;
let mut truncate_at = self.state.conversation.len();
for (i, item) in self.state.conversation.iter().enumerate() {
if matches!(item, xai_grok_sampling_types::ConversationItem::User(_)) {
if user_count == target_prompt_index {
truncate_at = i;
break;
}
user_count += 1;
}
}
self.state.conversation.truncate(truncate_at);
self.state.prompt_texts.truncate(target_prompt_index);
self.state.prompt_index = target_prompt_index;
self.state.total_tokens =
super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.estimated_tokens_since_model = 0;
self.state.estimate_at_last_response = self.state.total_tokens;
self.persistence.replace_history(&self.state.conversation);
self.send_event(ChatStateEvent::ConversationReset {
new_len: self.state.conversation.len(),
});
}
/// Check if auto-compact is needed based on token utilization.
///
/// Returns `Some(AutoCompactTrigger)` if `total_tokens` exceeds
/// `context_window * threshold_percent / 100`, otherwise `None`.
pub(super) fn check_auto_compact_needed(
&self,
threshold_percent: u8,
) -> Option<AutoCompactTrigger> {
let context_window = self.state.sampling_config.context_window;
let cw = context_window.get();
if xai_token_estimation::exceeds_threshold(self.state.total_tokens, cw, threshold_percent) {
let utilization_percent =
xai_token_estimation::usage_percentage_truncated_u8(self.state.total_tokens, cw);
Some(AutoCompactTrigger {
total_tokens: self.state.total_tokens,
context_window,
utilization_percent,
})
} else {
None
}
}
pub(super) fn get_last_model_metadata(&self) -> crate::commands::ModelMetadata {
self.state
.conversation
.iter()
.rev()
.find_map(|item| {
if let xai_grok_sampling_types::ConversationItem::Assistant(a) = item {
Some(crate::commands::ModelMetadata {
resolved_model_id: a.model_id.clone(),
model_fingerprint: a.model_fingerprint.clone(),
})
} else {
None
}
})
.unwrap_or_default()
}
// ─── Narrow targeted queries ─────────────────────────────────────────────
/// Return the number of items in the conversation.
pub(super) fn get_conversation_len(&self) -> usize {
self.state.conversation.len()
}
/// Whether the conversation has any assistant tool call without a matching
/// `ToolResult` (the dangling-tool-call repair would fire on the next build).
pub(super) fn has_dangling_tool_calls(&self) -> bool {
xai_grok_sampling_types::has_dangling_tool_calls(&self.state.conversation)
}
/// Return the text content of the last assistant message with non-empty text.
///
/// Walks the conversation backwards and returns the first `Assistant` item
/// whose `content` field is non-empty after trimming. Returns `None` when
/// no such item exists.
pub(super) fn get_last_assistant_text(&self) -> Option<String> {
self.state.conversation.iter().rev().find_map(|item| {
if let xai_grok_sampling_types::ConversationItem::Assistant(a) = item
&& !a.content.trim().is_empty()
{
return Some(a.content.as_ref().to_owned());
}
None
})
}
/// Return the text of the **first content part** of the first `User` message,
/// if and only if that part is `ContentPart::Text`.
///
/// Matches the original call-site semantics exactly: if the first user
/// message leads with a non-text part (e.g. an image in a multimodal
/// prompt), this returns `None` rather than scanning further parts.
/// Callers that need "any text part" rather than "first-part-is-text"
/// should use `get_conversation()` directly.
pub(super) fn get_first_user_text(&self) -> Option<String> {
self.state.conversation.iter().find_map(|item| {
if let xai_grok_sampling_types::ConversationItem::User(u) = item {
// Only return text if the first part is Text — behaviour-preserving
// w.r.t. the original `content.first().and_then(|p| if Text { … })`.
u.content.first().and_then(|part| {
if let xai_grok_sampling_types::ContentPart::Text { text } = part {
Some(text.as_ref().to_owned())
} else {
None
}
})
} else {
None
}
})
}
/// Return the conversation item at `index`, or `None` if out of bounds.
pub(super) fn get_conversation_item_at(
&self,
index: usize,
) -> Option<xai_grok_sampling_types::ConversationItem> {
self.state.conversation.get(index).cloned()
}
/// Return the processed text of the last user query (metadata tags stripped).
///
/// Delegates to [`extract_last_user_query`] so the caller does not need a
/// full conversation clone.
pub(super) fn get_last_user_query_text(&self) -> Option<String> {
extract_last_user_query(&self.state.conversation)
}
/// Return conversation item counts by role without cloning any items.
pub(super) fn get_conversation_counts(&self) -> ConversationCounts {
let mut counts = ConversationCounts {
total: self.state.conversation.len(),
..Default::default()
};
for item in &self.state.conversation {
match item {
xai_grok_sampling_types::ConversationItem::User(_) => counts.user += 1,
xai_grok_sampling_types::ConversationItem::Assistant(_) => {
counts.assistant += 1;
}
xai_grok_sampling_types::ConversationItem::ToolResult(_) => {
counts.tool_result += 1;
}
xai_grok_sampling_types::ConversationItem::System(_) => {}
xai_grok_sampling_types::ConversationItem::BackendToolCall(_) => {}
xai_grok_sampling_types::ConversationItem::Reasoning(_) => {}
}
}
counts
}
/// Return the first `System` message in the conversation, or `None`.
pub(super) fn get_system_message(&self) -> Option<xai_grok_sampling_types::ConversationItem> {
self.state
.conversation
.iter()
.find(|item| matches!(item, xai_grok_sampling_types::ConversationItem::System(_)))
.cloned()
}
}

View file

@ -0,0 +1,865 @@
//! ConversationRequest assembly — image compaction, pruning, repair, memory injection.
use xai_grok_sampling_types::{
ContentPart, ConversationItem, ConversationRequest, ToolSpec, TraceContext,
};
use super::ChatStateActor;
use crate::events::ChatStateEvent;
use crate::types::PruningConfig;
/// Placeholder inserted when a tool result is hard-cleared.
///
/// `pub(super)` so that `mutations.rs` can use the same string when it
/// hard-clears tool results in the retained in-memory conversation.
pub(super) const HARD_CLEAR_PLACEHOLDER: &str = "[Tool result omitted — too old]";
/// Separator inserted between head and tail in soft-trimmed results.
const SOFT_TRIM_SEPARATOR: &str = "\n\n[…trimmed…]\n\n";
impl ChatStateActor {
/// Build a `ConversationRequest` from the current actor state.
///
/// 1. Evict oldest inline images when the inline-image bytes near 50 MB
/// 2. Prune old tool results if over 50% context utilization
/// 3. Optionally persist the memory reminder into actor state
/// 4. Inject memory reminder into the request clone (if needed)
/// 5. Assemble and return the `ConversationRequest`
///
/// # Repair invariant
///
/// The `BuildConversationRequest` command handler calls
/// `ensure_conversation_integrity()` on the actor's own conversation
/// **before** this function runs. The clone therefore starts from an
/// already-repaired state, so there is no need to run
/// `dedup_duplicate_tool_results` / `repair_dangling_tool_calls` on the
/// clone — those would be O(n) no-ops.
pub(super) fn build_conversation_request(
&mut self,
tool_definitions: Vec<ToolSpec>,
memory_reminder: Option<String>,
persist_memory_reminder: bool,
trace: Option<Box<dyn TraceContext>>,
conv_id: String,
req_id: String,
) -> ConversationRequest {
let needs_prune = should_prune(
self.state.total_tokens,
self.state.sampling_config.context_window,
);
let mut memory_reminder = memory_reminder;
if let Some(reminder) = memory_reminder.as_deref()
&& persist_memory_reminder
{
// A live in-place inject can prepend a `System` item, shifting indices
// under an active capture; snapshot + rebase like the other mutators.
self.snapshot_turn_slice();
let injected = inject_memory_reminder(&mut self.state.conversation, reminder);
if injected {
self.persistence.replace_history(&self.state.conversation);
memory_reminder = None;
}
self.rebase_turn_capture_offset();
}
// Measure the exact serialized body and evict only once it approaches
// the 50 MB ceiling. `conversation_body_bytes` is wire-accurate yet
// cheap — it skips the multi-MB base64 escape scan (see its docs) — so
// it runs inline on every turn with no blocking-thread offload.
// Eviction rewrites earlier turns and busts the KV-cache prefix, so we
// only pay it when the body is actually near the limit (the original
// behavior — evicting every turn — caused chronic cache misses).
let body_bytes = conversation_body_bytes(&self.state.conversation);
let inline_images = inline_image_count(&self.state.conversation);
let needs_image_compaction = body_bytes >= IMAGE_COMPACT_TRIGGER_BYTES;
let needs_mutation = needs_prune || memory_reminder.is_some() || needs_image_compaction;
// Only allocate the mutable working copy when a mutation path is taken.
let mut eviction: Option<ImageEvictionOutcome> = None;
let items = if needs_mutation {
let mut items = self.state.conversation.clone();
// Step 1: When the body nears the 50 MB ceiling, evict oldest
// images down to the low-water mark (not just under the trigger).
// Reclaiming a batch frees headroom for many subsequent image
// turns, so the prefix is rewritten once and then stays cache-warm
// — instead of re-triggering and re-busting the cache every turn.
if needs_image_compaction {
eviction = Some(compact_images_to_byte_budget(
&mut items,
body_bytes,
IMAGE_COMPACT_RECLAIM_TARGET_BYTES,
));
}
// Step 2: Prune old tool results if context is > 50% utilized
if needs_prune {
prune_conversation(&mut items, &self.pruning_config);
}
// Step 3: Inject memory reminder into the system message
if let Some(reminder) = memory_reminder {
inject_memory_reminder(&mut items, &reminder);
}
items
} else {
// Hot path: no pruning, no memory reminder, no old images —
// clone directly into the request without any intermediate mutation passes.
self.state.conversation.clone()
};
// Per-turn image-budget record for local verification. Emitted on the
// ChatState event channel (chat-state can't reach the shell's unified
// log directly); the session consumer writes it to the local log file.
// Only on image-bearing turns to avoid noise.
if inline_images > 0 {
self.send_event(ChatStateEvent::ImageBudget {
body_bytes,
trigger_bytes: IMAGE_COMPACT_TRIGGER_BYTES,
reclaim_target_bytes: IMAGE_COMPACT_RECLAIM_TARGET_BYTES,
inline_images,
needs_image_compaction,
evicted: eviction.as_ref().map_or(0, |o| o.evicted),
body_bytes_after: eviction.as_ref().map_or(body_bytes, |o| o.body_bytes_after),
});
}
// Step 4: Assemble request
ConversationRequest {
items,
tools: tool_definitions,
hosted_tools: vec![],
tool_choice: None,
model: Some(self.state.sampling_config.model.clone()),
temperature: self.state.sampling_config.temperature,
max_output_tokens: self.state.sampling_config.max_completion_tokens,
top_p: self.state.sampling_config.top_p,
x_grok_conv_id: Some(conv_id),
x_grok_req_id: Some(req_id),
x_grok_session_id: None,
x_grok_turn_idx: None,
x_grok_agent_id: None,
x_grok_deployment_id: None,
x_grok_user_id: None,
trace,
reasoning_effort: self.state.sampling_config.reasoning_effort,
json_schema: None,
}
}
}
// ============================================================================
// Pruning (standalone functions, no actor state needed)
// ============================================================================
/// Check whether pruning should run based on context utilization.
///
/// Returns `true` when `total_tokens` exceeds 50% of `context_window`.
pub(crate) fn should_prune(total_tokens: u64, context_window: std::num::NonZeroU64) -> bool {
total_tokens > context_window.get() / 2
}
/// Prune old, large tool results from the conversation in place.
///
/// Turn age is estimated by walking backward through the conversation and
/// counting `User` items to determine which "turn" each tool result belongs to.
pub(crate) fn prune_conversation(conversation: &mut [ConversationItem], config: &PruningConfig) {
if !config.enabled {
return;
}
let mut turn_from_end: usize = 0;
let mut seen_first_user = false;
for i in (0..conversation.len()).rev() {
if matches!(&conversation[i], ConversationItem::User(_)) {
if seen_first_user {
turn_from_end += 1;
}
seen_first_user = true;
continue;
}
let ConversationItem::ToolResult(tool_result) = &mut conversation[i] else {
continue;
};
// Never prune recent turns.
if turn_from_end < config.keep_last_n_turns {
continue;
}
// Hard clear: very old tool results → replace entirely.
if turn_from_end >= config.hard_clear_age_turns {
if tool_result.content.as_ref() != HARD_CLEAR_PLACEHOLDER {
tool_result.content = std::sync::Arc::<str>::from(HARD_CLEAR_PLACEHOLDER);
}
continue;
}
// Soft trim: large tool results → keep head + tail.
let content_len = tool_result.content.chars().count();
if content_len > config.soft_trim_threshold {
let head = safe_char_slice(&tool_result.content, 0, config.soft_trim_head);
let tail = safe_char_slice_tail(&tool_result.content, config.soft_trim_tail);
tool_result.content =
std::sync::Arc::<str>::from(format!("{head}{SOFT_TRIM_SEPARATOR}{tail}"));
}
}
}
// ============================================================================
// Image size-gated compaction (request-copy only)
// ============================================================================
/// Replaces an inline image evicted to keep the request body under the proxy's
/// 50 MB limit. Phrased so the model treats the image as gone rather than
/// describing it from memory — a silently-stripped image otherwise induces
/// confident hallucination of its contents.
const IMAGE_COMPACT_PLACEHOLDER: &str = "[An earlier image was removed to keep the request within its size limit and is no longer visible. Do not describe or reason about its contents from memory; ask the user to re-share it if you need to see it again.]";
/// Hard request-body ceiling enforced by the inference proxy
/// (nginx `proxy-body-size`). Bodies larger than this are rejected with HTTP
/// 413 — or a connection reset before the response is written. Inline image
/// `data:` URLs (base64) are the dominant term in this size.
const MAX_REQUEST_BYTES: usize = 50 * 1024 * 1024;
/// Evict old images once the serialized body reaches this size.
///
/// We gate on the exact body (see [`conversation_body_bytes`]) — system prompt,
/// all message text, tool results, and image `data:` URLs are all counted
/// precisely. This sits 3 MB below [`MAX_REQUEST_BYTES`] as headroom for the
/// only parts of the wire request the body measurement does **not** include:
/// - **tool definitions** — sent alongside the conversation but not part of it
/// (tool JSON schemas + MCP tools); this is the bulk of the gap.
/// - the request envelope and sampling params.
/// - the small delta between our internal `ContentPart` JSON and the public-API
/// wire format (the dominant base64 image bytes are identical in both).
///
/// The uncounted remainder is only sub-MB to low-MB in practice, so 3 MB covers
/// it without needlessly sacrificing image capacity. The sampler's reactive 413
/// image-strip is the final backstop if this is ever under-estimated.
///
/// Below this threshold every image stays in place so the KV-cache prefix is
/// byte-stable across turns; eviction rewrites earlier turns and busts the
/// prefix cache, so we only pay that cost when a 413 is actually near.
pub(crate) const IMAGE_COMPACT_TRIGGER_BYTES: usize = MAX_REQUEST_BYTES - 3 * 1024 * 1024;
/// Low-water mark that eviction reclaims down to once it fires (hysteresis).
///
/// Eviction is **gated** at [`IMAGE_COMPACT_TRIGGER_BYTES`] but **reclaims** to
/// this strictly lower mark. Evicting only enough to clear the trigger means
/// the next image-bearing turn re-crosses it and evicts again — rewriting the
/// prefix and busting the KV cache on essentially every turn once the body sits
/// at the ceiling. Dropping to half the hard limit instead frees ~25 MB of
/// headroom, so the prefix is rewritten once and then stays stable (cache-warm)
/// across many turns until the headroom is consumed again. The oldest images
/// (least useful) are sacrificed in a batch rather than one-per-turn — a
/// high-water trigger paired with a lower reclaim mark (classic hysteresis).
pub(crate) const IMAGE_COMPACT_RECLAIM_TARGET_BYTES: usize = MAX_REQUEST_BYTES / 2;
// Hysteresis invariant: eviction is gated at the trigger but reclaims to a
// strictly lower mark, so one batch eviction buys many cache-warm turns rather
// than re-triggering (and re-busting the prompt cache) every turn at the
// ceiling. Enforced at compile time so the two constants can't drift together.
const _: () = assert!(IMAGE_COMPACT_RECLAIM_TARGET_BYTES < IMAGE_COMPACT_TRIGGER_BYTES);
/// An [`std::io::Write`] sink that counts bytes instead of storing them. Lets
/// us measure a `serde_json` encoding's length without allocating the full
/// (potentially tens-of-MB) output buffer.
#[derive(Default)]
struct ByteCounter(usize);
impl std::io::Write for ByteCounter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// Exact JSON-serialized byte length of any value, measured through a
/// [`ByteCounter`] so no encoded buffer is allocated. JSON quoting and string
/// escaping are captured precisely (not estimated from field lengths).
fn serialized_json_bytes<T: serde::Serialize + ?Sized>(value: &T) -> usize {
let mut counter = ByteCounter::default();
if let Err(err) = serde_json::to_writer(&mut counter, value) {
// Serializing in-memory state to a byte sink is infallible in
// practice; if it ever fails, fall back to the bytes counted so far
// (a lower bound) rather than forcing a needless compaction.
tracing::warn!(%err, "failed to measure serialized size");
}
counter.0
}
/// Serialized JSON frame of one image content part with an empty URL —
/// `{"type":"image","url":""}`. The real payload adds exactly `url.len()` on
/// top: an inline base64 `data:` URL contains no JSON-escaped characters, so
/// its encoded length equals its raw length. Identical in our internal JSON and
/// on the public-API wire (the base64 bytes are the same in both).
const IMAGE_PART_FRAME_BYTES: usize = r#"{"type":"image","url":""}"#.len();
/// Exact serialized size of a single inline image part (frame + raw URL bytes).
fn image_part_bytes(url: &str) -> usize {
IMAGE_PART_FRAME_BYTES + url.len()
}
/// Count of inline images in the conversation — for observability only.
fn inline_image_count(conversation: &[ConversationItem]) -> usize {
conversation
.iter()
.filter_map(|item| match item {
ConversationItem::User(u) => Some(u),
_ => None,
})
.flat_map(|u| u.content.iter())
.filter(|p| matches!(p, ContentPart::Image { .. }))
.count()
}
/// Outcome of [`compact_images_to_byte_budget`], surfaced for logging and
/// local verification.
pub(crate) struct ImageEvictionOutcome {
/// Number of inline images replaced with the placeholder.
pub evicted: usize,
/// Estimated serialized body size after eviction (`current_bytes` minus the
/// net bytes freed) — at or below `target_bytes` once enough images go.
pub body_bytes_after: usize,
}
/// Exact serialized size of the conversation body — the figure the inference
/// proxy weighs against its 50 MB limit — computed **without** scanning the
/// multi-MB base64 image payloads.
///
/// `serde_json` escape-scans every byte of every string, so encoding the real
/// conversation would walk tens of MB of base64 on every turn. Instead we
/// serialize a copy with image URLs blanked (cheap: only the small non-image
/// content — system prompt, message text, tool results — is scanned, and it is
/// measured *exactly*, escaping included) and add back each URL's raw length.
/// Because base64 never escapes, that length is its exact serialized
/// contribution, so the result is byte-for-byte the true body size.
///
/// The blanking copy is cheap: image data lives behind `Arc<str>`, so cloning
/// only bumps refcounts and the blanked clone drops them without copying bytes.
fn conversation_body_bytes(conversation: &[ConversationItem]) -> usize {
let mut blanked = conversation.to_vec();
let mut image_url_bytes = 0usize;
for item in &mut blanked {
if let ConversationItem::User(user) = item {
for part in &mut user.content {
if let ContentPart::Image { url } = part {
image_url_bytes += url.len();
*url = std::sync::Arc::<str>::from("");
}
}
}
}
serialized_json_bytes(&blanked) + image_url_bytes
}
/// Replace the oldest inline images with [`IMAGE_COMPACT_PLACEHOLDER`] until
/// the serialized request body drops back to `target_bytes`, keeping the
/// newest images. `current_bytes` is the already-measured whole-body size (see
/// [`conversation_body_bytes`]); each eviction drops `running` by the image
/// part's exact serialized size minus the placeholder that replaces it, so it
/// tracks the true body byte-for-byte as images are removed.
///
/// Operates on a mutable slice — intended for the request *copy* so the stored
/// conversation is never modified.
///
/// ## Cache behavior
///
/// Eviction is **oldest-first**, which is sticky by construction: because we
/// always retain the newest images, an image only transitions image →
/// placeholder as *newer/larger* payloads push the body past the limit, never
/// placeholder → image within a stable prefix. (Token compaction removes old
/// turns wholesale and can free room to restore a previously-evicted image,
/// but that already rewrites the prefix and invalidates the server-side prompt
/// cache, so the restore is free.)
///
/// The caller gates eviction at [`IMAGE_COMPACT_TRIGGER_BYTES`] but passes the
/// lower [`IMAGE_COMPACT_RECLAIM_TARGET_BYTES`] as `target_bytes`, so one
/// eviction reclaims a batch of the oldest images and frees headroom for many
/// later image turns. This turns "rewrite the prefix on essentially every turn
/// once at the ceiling" into one larger, rare rewrite followed by a long
/// cache-warm stretch — the prefix-cache cost of dropping the oldest (least
/// useful) image is paid infrequently instead of per turn.
///
/// This replaces the previous policy — strip every image older than the most
/// recent user turn on *every* request — which (a) busted the prompt-cache
/// prefix on the turn after any image, and (b) dropped images the model still
/// needed one turn later, causing it to hallucinate their contents.
pub(crate) fn compact_images_to_byte_budget(
conversation: &mut [ConversationItem],
current_bytes: usize,
target_bytes: usize,
) -> ImageEvictionOutcome {
if current_bytes <= target_bytes {
return ImageEvictionOutcome {
evicted: 0,
body_bytes_after: current_bytes,
};
}
// The text part each evicted image is replaced with. Measured once: every
// eviction shrinks the body by the image part's bytes and grows it back by
// this placeholder's bytes, so the net saving is `image - placeholder`.
let placeholder = ContentPart::Text {
text: std::sync::Arc::<str>::from(IMAGE_COMPACT_PLACEHOLDER),
};
let placeholder_bytes = serialized_json_bytes(&placeholder);
// (item_idx, part_idx, exact serialized image-part bytes) for every inline
// image, oldest-first.
let mut images: Vec<(usize, usize, usize)> = Vec::new();
for (i, item) in conversation.iter().enumerate() {
if let ConversationItem::User(user) = item {
for (j, part) in user.content.iter().enumerate() {
if let ContentPart::Image { url } = part {
images.push((i, j, image_part_bytes(url)));
}
}
}
}
// Evict oldest-first until the body fits again, keeping the newest images.
let mut running = current_bytes;
let mut evicted = 0usize;
for &(i, j, image_bytes) in &images {
if running <= target_bytes {
break;
}
if let ConversationItem::User(user) = &mut conversation[i]
&& let Some(part) = user.content.get_mut(j)
{
*part = placeholder.clone();
// Net body saving: the image part leaves, the placeholder takes its
// slot. Everything else (siblings, commas, brackets) is untouched,
// so this is the exact change in the serialized body size.
running = running.saturating_sub(image_bytes.saturating_sub(placeholder_bytes));
evicted += 1;
}
}
ImageEvictionOutcome {
evicted,
body_bytes_after: running,
}
}
// ============================================================================
// Memory reminder injection
// ============================================================================
use crate::types::MEMORY_CONTEXT_OPEN_TAG;
/// Upsert a memory reminder into the conversation's system message.
///
/// If the first item is a `System` message, any previously injected memory
/// reminder section is replaced in-place; otherwise the reminder is appended.
/// If no system message exists, a new `System` item is prepended.
///
/// Returns `true` when the conversation was changed.
pub(super) fn inject_memory_reminder(items: &mut Vec<ConversationItem>, reminder: &str) -> bool {
let reminder = reminder.trim();
if reminder.is_empty() {
return false;
}
if let Some(ConversationItem::System(sys)) = items.first_mut() {
upsert_memory_reminder_text(&mut sys.content, reminder)
} else {
items.insert(0, ConversationItem::system(reminder));
true
}
}
fn upsert_memory_reminder_text(system_prompt: &mut std::sync::Arc<str>, reminder: &str) -> bool {
let existing_start = system_prompt
.find(MEMORY_CONTEXT_OPEN_TAG)
.map(|idx| system_prompt[..idx].trim_end_matches('\n').len());
let updated: String = if let Some(prefix_len) = existing_start {
let prefix = system_prompt[..prefix_len].trim_end_matches('\n');
if prefix.is_empty() {
reminder.to_string()
} else {
format!("{prefix}\n\n{reminder}")
}
} else if system_prompt.trim_end() == reminder {
system_prompt.as_ref().to_owned()
} else if system_prompt.is_empty() {
reminder.to_string()
} else {
format!("{}\n\n{reminder}", system_prompt.trim_end_matches('\n'))
};
if system_prompt.as_ref() == updated.as_str() {
false
} else {
*system_prompt = std::sync::Arc::<str>::from(updated);
true
}
}
// ============================================================================
// String helpers
// ============================================================================
fn safe_char_slice(s: &str, start: usize, count: usize) -> String {
s.chars().skip(start).take(count).collect()
}
fn safe_char_slice_tail(s: &str, count: usize) -> String {
let total = s.chars().count();
if count >= total {
return s.to_string();
}
s.chars().skip(total - count).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_prune_gating() {
use std::num::NonZeroU64;
let cw = NonZeroU64::new(10000).unwrap();
assert!(!should_prune(1000, cw)); // 10%
assert!(should_prune(6000, cw)); // 60%
assert!(!should_prune(5000, cw)); // 50% exact (> not >=)
}
#[test]
fn prune_disabled_is_noop() {
let mut conv = vec![ConversationItem::tool_result("c1", "x".repeat(10_000))];
let config = PruningConfig {
enabled: false,
..Default::default()
};
prune_conversation(&mut conv, &config);
if let ConversationItem::ToolResult(ref tr) = conv[0] {
assert_eq!(tr.content.len(), 10_000);
}
}
#[test]
fn inject_memory_into_existing_system() {
let mut items = vec![
ConversationItem::system("You are helpful."),
ConversationItem::user("hi"),
];
inject_memory_reminder(&mut items, "Remember: user likes rust");
if let ConversationItem::System(ref sys) = items[0] {
assert!(sys.content.contains("Remember: user likes rust"));
assert!(sys.content.starts_with("You are helpful."));
}
assert_eq!(items.len(), 2); // no new item added
}
#[test]
fn inject_memory_prepends_when_no_system() {
let mut items = vec![ConversationItem::user("hi")];
inject_memory_reminder(&mut items, "Remember: user likes rust");
assert_eq!(items.len(), 2);
assert!(matches!(&items[0], ConversationItem::System(_)));
}
// -- image size-gated compaction tests --
/// A user message with a small fixed inline image.
fn user_with_image(text: &str) -> ConversationItem {
let mut item = ConversationItem::user(text);
item.add_image("data:image/png;base64,iVBORw0KGgo=");
item
}
/// A user message carrying an inline image whose `data:` URL is exactly
/// `url_bytes` long (must be >= the data-URL prefix length).
fn user_with_image_of_bytes(text: &str, url_bytes: usize) -> ConversationItem {
const PREFIX: &str = "data:image/png;base64,";
let pad = url_bytes.saturating_sub(PREFIX.len());
let mut item = ConversationItem::user(text);
item.add_image(format!("{PREFIX}{}", "A".repeat(pad)));
item
}
fn has_image(item: &ConversationItem) -> bool {
matches!(
item,
ConversationItem::User(u)
if u.content.iter().any(|p| matches!(p, ContentPart::Image { .. }))
)
}
fn has_placeholder(item: &ConversationItem) -> bool {
matches!(
item,
ConversationItem::User(u) if u.content.iter().any(|p| matches!(
p,
ContentPart::Text { text } if text.as_ref() == IMAGE_COMPACT_PLACEHOLDER
))
)
}
// Images are sized ~100 KB so the ~235 B placeholder that replaces an
// evicted image is negligible: each eviction frees ~one image's bytes.
const TEST_IMG_BYTES: usize = 100_000;
#[test]
fn no_eviction_when_at_or_below_target() {
// Multiple old image turns are *retained* when the body already fits —
// the key behavior change from the old "strip everything but newest".
let mut conv = vec![
ConversationItem::system("sys"),
user_with_image_of_bytes("first", TEST_IMG_BYTES),
ConversationItem::assistant("a"),
user_with_image_of_bytes("second", TEST_IMG_BYTES),
user_with_image_of_bytes("third", TEST_IMG_BYTES),
];
// current < target: nothing to do.
compact_images_to_byte_budget(&mut conv, 300_000, 400_000);
assert_eq!(conv.iter().filter(|i| has_image(i)).count(), 3);
}
#[test]
fn evicts_oldest_until_under_target() {
let mut conv = vec![
user_with_image_of_bytes("oldest", TEST_IMG_BYTES),
user_with_image_of_bytes("middle", TEST_IMG_BYTES),
user_with_image_of_bytes("newest", TEST_IMG_BYTES),
];
// current 300k, target 250k: evicting the oldest (~100 KB) fits.
compact_images_to_byte_budget(&mut conv, 300_000, 250_000);
assert!(has_placeholder(&conv[0]), "oldest evicted");
assert!(has_image(&conv[1]), "middle kept");
assert!(has_image(&conv[2]), "newest kept");
}
#[test]
fn evicts_more_oldest_for_lower_target() {
let mut conv = vec![
user_with_image_of_bytes("oldest", TEST_IMG_BYTES),
user_with_image_of_bytes("middle", TEST_IMG_BYTES),
user_with_image_of_bytes("newest", TEST_IMG_BYTES),
];
// current 300k, target 150k: must drop the two oldest to fit.
compact_images_to_byte_budget(&mut conv, 300_000, 150_000);
assert!(has_placeholder(&conv[0]));
assert!(has_placeholder(&conv[1]));
assert!(has_image(&conv[2]), "newest kept");
}
#[test]
fn eviction_reclaims_batch_to_low_water_mark() {
// Mirror production: a body sitting just over the trigger, made of many
// equal images, is reclaimed in one pass down to the low-water mark —
// dropping a *batch* of the oldest, not just the one image needed to
// clear the trigger. This is the hysteresis that keeps the prefix
// cache-warm for the following turns.
let img_bytes = 1_000_000usize; // ~1 MB url each
let n = (IMAGE_COMPACT_TRIGGER_BYTES / img_bytes) + 2; // body just over trigger
let mut conv: Vec<ConversationItem> = (0..n)
.map(|i| user_with_image_of_bytes(&format!("i{i}"), img_bytes))
.collect();
let current = n * img_bytes;
assert!(current > IMAGE_COMPACT_TRIGGER_BYTES);
compact_images_to_byte_budget(&mut conv, current, IMAGE_COMPACT_RECLAIM_TARGET_BYTES);
let kept = conv.iter().filter(|i| has_image(i)).count();
let evicted = conv.iter().filter(|i| has_placeholder(i)).count();
// Clearing only the trigger would evict ~3 images; reclaiming to the
// low-water mark (~half the ceiling) must evict far more.
assert!(
evicted > n / 4,
"expected batch eviction to the low-water mark, only {evicted}/{n} evicted"
);
// Oldest-first stops at the mark, so the most recent image survives.
assert!(kept > 0);
assert!(
has_image(conv.last().unwrap()),
"most recent image must be retained"
);
}
#[test]
fn evicts_all_when_target_below_one_image() {
let mut conv = vec![
user_with_image_of_bytes("a", TEST_IMG_BYTES),
user_with_image_of_bytes("b", TEST_IMG_BYTES),
];
compact_images_to_byte_budget(&mut conv, 200_000, 50_000);
assert!(has_placeholder(&conv[0]));
assert!(has_placeholder(&conv[1]));
}
#[test]
fn eviction_keeps_newest_and_is_idempotent() {
let mut conv = vec![
user_with_image_of_bytes("i0", TEST_IMG_BYTES),
user_with_image_of_bytes("i1", TEST_IMG_BYTES),
user_with_image_of_bytes("i2", TEST_IMG_BYTES),
user_with_image_of_bytes("i3", TEST_IMG_BYTES),
];
// current 400k, target 250k: drop the two oldest, keep the newest two.
compact_images_to_byte_budget(&mut conv, 400_000, 250_000);
assert!(has_placeholder(&conv[0]) && has_placeholder(&conv[1]));
assert!(has_image(&conv[2]) && has_image(&conv[3]));
// Re-running with the now-smaller body is a no-op (sticky): the two
// surviving images already fit.
compact_images_to_byte_budget(&mut conv, 200_000, 250_000);
assert!(has_placeholder(&conv[0]) && has_placeholder(&conv[1]));
assert!(has_image(&conv[2]) && has_image(&conv[3]));
}
#[test]
fn evicted_image_uses_honest_placeholder() {
let mut conv = vec![user_with_image_of_bytes("x", TEST_IMG_BYTES)];
compact_images_to_byte_budget(&mut conv, 100_000, 10);
assert!(has_placeholder(&conv[0]));
}
// -- conversation_body_bytes tests --
#[test]
fn conversation_body_bytes_empty_is_json_array() {
// serde encodes an empty slice as "[]" (2 bytes).
assert_eq!(conversation_body_bytes(&[]), 2);
}
#[test]
fn conversation_body_bytes_matches_serde_json_exactly() {
// The blank-and-add-URLs measurement must equal a full serde_json
// encode byte-for-byte — including non-image content and string
// escaping. The `"` in the system text is escaped by serde; the
// measurement must account for it.
let conv = vec![
ConversationItem::system("system \"quoted\" prompt"),
user_with_image("look"),
ConversationItem::assistant("a longer assistant reply with text"),
ConversationItem::user("plain follow-up turn"),
];
let expected = serde_json::to_vec(&conv).unwrap().len();
assert_eq!(conversation_body_bytes(&conv), expected);
}
#[test]
fn conversation_body_bytes_matches_serde_json_with_large_image() {
// Exact even for a multi-KB base64 payload — the scan we deliberately
// skip still lands on the same byte count.
let conv = vec![user_with_image_of_bytes("big", 50_000)];
let expected = serde_json::to_vec(&conv).unwrap().len();
assert_eq!(conversation_body_bytes(&conv), expected);
}
#[test]
fn conversation_body_bytes_small_image_is_below_trigger() {
// A normal small inline image must not trip the 50 MB gate — the case
// the cache-miss fix preserves.
let conv = vec![
user_with_image("old"),
ConversationItem::assistant("reply"),
ConversationItem::user("current"),
];
assert!(conversation_body_bytes(&conv) < IMAGE_COMPACT_TRIGGER_BYTES);
}
#[test]
fn conversation_body_bytes_large_image_reaches_trigger() {
let conv = vec![user_with_image_of_bytes("big", IMAGE_COMPACT_TRIGGER_BYTES)];
assert!(conversation_body_bytes(&conv) >= IMAGE_COMPACT_TRIGGER_BYTES);
}
// -- edge cases: exactness, boundaries, ordering --
#[test]
fn body_bytes_parity_multi_image_unicode_escaping() {
// The gate is only as correct as this equality. Exercise multiple
// images in one turn, multibyte unicode (passed through, not escaped),
// and chars serde *does* escape (`"`, `\`, control).
let mut turn = ConversationItem::user("two pics 🚀 with \"quotes\" and \\ slash");
turn.add_image("data:image/png;base64,AAAA");
turn.add_image("data:image/png;base64,BBBBBB");
let conv = vec![
ConversationItem::system("sys 日本語 \t control"),
turn,
ConversationItem::assistant("reply"),
ConversationItem::user("plain follow-up"),
];
assert_eq!(
conversation_body_bytes(&conv),
serde_json::to_vec(&conv).unwrap().len()
);
}
#[test]
fn no_eviction_when_exactly_at_target() {
// The no-op guard is `current <= target`; pin the inclusive boundary.
let mut conv = vec![user_with_image_of_bytes("a", TEST_IMG_BYTES)];
compact_images_to_byte_budget(&mut conv, 250_000, 250_000);
assert!(has_image(&conv[0]), "exactly at target must not evict");
}
#[test]
fn terminates_when_placeholder_exceeds_image() {
// Tiny images: each "saving" saturates to 0, but the loop must still
// terminate and replace every image when the target is unreachable.
let mut conv = vec![
user_with_image_of_bytes("a", 40),
user_with_image_of_bytes("b", 40),
];
compact_images_to_byte_budget(&mut conv, 1_000, 10);
assert!(has_placeholder(&conv[0]) && has_placeholder(&conv[1]));
}
#[test]
fn evicts_oldest_image_parts_first() {
// `has_image`/`has_placeholder` are per-item, so count actual image
// parts to verify oldest-first ordering across parts within a turn.
fn image_parts(conv: &[ConversationItem]) -> usize {
conv.iter()
.filter_map(|i| match i {
ConversationItem::User(u) => Some(u),
_ => None,
})
.flat_map(|u| u.content.iter())
.filter(|p| matches!(p, ContentPart::Image { .. }))
.count()
}
let mut newest = ConversationItem::user("newest turn");
newest.add_image(format!(
"data:image/png;base64,{}",
"A".repeat(TEST_IMG_BYTES)
));
newest.add_image(format!(
"data:image/png;base64,{}",
"B".repeat(TEST_IMG_BYTES)
));
let mut conv = vec![user_with_image_of_bytes("oldest", TEST_IMG_BYTES), newest];
assert_eq!(image_parts(&conv), 3);
// ~300k body, reclaim to 150k: drop the two oldest, keep the newest.
compact_images_to_byte_budget(&mut conv, 300_000, 150_000);
assert_eq!(image_parts(&conv), 1, "newest image survives");
assert!(has_placeholder(&conv[0]), "oldest turn evicted");
assert!(has_image(&conv[1]), "newest turn keeps an image");
}
#[test]
fn escaped_remote_url_is_a_lower_bound_only() {
// base64 `data:` URLs are exact; a remote URL with a JSON-escaped char
// under-counts by the escape bytes. Pin that documented bound so the
// measurement can't silently drift past it.
let mut item = ConversationItem::user("");
item.add_image(r#"https://example.com/a"b"#);
let conv = vec![item];
assert!(conversation_body_bytes(&conv) <= serde_json::to_vec(&conv).unwrap().len());
}
}

View file

@ -0,0 +1,402 @@
//! Internal state types for the ChatStateActor.
use std::collections::BTreeSet;
use xai_grok_sampling_types::{
ConversationItem, DanglingToolCallReason, SamplingConfig, TokenUsage,
dedup_duplicate_tool_results, repair_dangling_tool_calls,
};
use crate::types::Credentials;
use crate::usage::UsageLedger;
/// Bytes/4 estimate of the system prompt portion of a [`ConversationItem`].
/// Returns 0 for non-system items so callers can pipe through whatever they
/// have without unwrapping.
pub fn estimate_system_message_tokens(item: &ConversationItem) -> u64 {
match item {
ConversationItem::System(s) => xai_token_estimation::estimate_tokens(&s.content),
_ => 0,
}
}
/// Bytes/4 estimate of one tool definition (name + description + the
/// JSON-serialized parameters).
pub fn estimate_tool_definition_tokens(td: &xai_grok_sampling_types::ToolDefinition) -> u64 {
let name_len = td.function.name.len();
let desc_len = td.function.description.as_deref().map_or(0, |d| d.len());
let params_len = td.function.parameters.to_string().len();
((name_len + desc_len + params_len) as u64) / xai_token_estimation::BYTES_PER_TOKEN
}
/// Sum [`estimate_tool_definition_tokens`] across a slice.
pub fn estimate_tool_definitions_tokens(tds: &[xai_grok_sampling_types::ToolDefinition]) -> u64 {
tds.iter().map(estimate_tool_definition_tokens).sum()
}
/// Bytes/4 estimate for a single [`ConversationItem`].
///
/// Images are counted at [`xai_token_estimation::IMAGE_TOKEN_ESTIMATE`] each.
/// Shared by [`estimate_conversation_tokens`] and [`estimate_messages_tokens`]
/// so the per-variant arithmetic stays in one place.
pub fn estimate_item_tokens(item: &ConversationItem) -> u64 {
use xai_grok_sampling_types::ContentPart;
match item {
ConversationItem::System(s) => xai_token_estimation::estimate_tokens(&s.content),
ConversationItem::User(u) => {
let mut bytes: usize = 0;
let mut images: u64 = 0;
for p in &u.content {
match p {
ContentPart::Text { text } => bytes += text.len(),
ContentPart::Image { .. } => images += 1,
}
}
(bytes as u64) / xai_token_estimation::BYTES_PER_TOKEN
+ xai_token_estimation::estimate_image_tokens(images)
}
ConversationItem::Assistant(a) => {
let bytes = a.content.len()
+ a.tool_calls
.iter()
.map(|tc| tc.arguments.len())
.sum::<usize>();
(bytes as u64) / xai_token_estimation::BYTES_PER_TOKEN
}
ConversationItem::ToolResult(tr) => xai_token_estimation::estimate_tokens(&tr.content),
ConversationItem::BackendToolCall(b) => {
xai_token_estimation::estimate_tokens(&b.text_summary())
}
ConversationItem::Reasoning(r) => {
// Summary + content text follow the standard bytes-per-token
// estimate; encrypted blobs are base64 and don't survive
// tokenization 1:1, so estimate at len/4 as well.
let text_bytes = xai_grok_sampling_types::reasoning_item_text(r).len();
let enc_bytes = r.encrypted_content.as_deref().map(str::len).unwrap_or(0);
((text_bytes + enc_bytes) as u64) / xai_token_estimation::BYTES_PER_TOKEN
}
}
}
/// Estimate token footprint: text bytes / 4, images at the per-image
/// constant defined by [`xai_token_estimation::IMAGE_TOKEN_ESTIMATE`].
pub fn estimate_conversation_tokens(items: &[ConversationItem]) -> u64 {
items.iter().map(estimate_item_tokens).sum()
}
/// grok-build's [`ItemTokenCounter`](xai_grok_compaction::ItemTokenCounter)
/// for the shared compaction engine: the bytes/4 estimate grok-build already
/// uses to drive its compaction triggers, exposed through the seam so the
/// shared budgeting math gets the *same* trusted count.
///
/// Where another host plugs a real BPE tokenizer into the same seam,
/// grok-build estimates instead, reusing [`estimate_item_tokens`] so the
/// per-variant arithmetic (images, reasoning blobs, tool-call args) stays in
/// one place.
pub struct EstimatedItemTokenCounter;
impl xai_grok_compaction::ItemTokenCounter<ConversationItem> for EstimatedItemTokenCounter {
fn count_item_tokens(&self, item: &ConversationItem) -> u32 {
// The estimate is a `u64`; a single item never approaches `u32::MAX`
// tokens, but saturate rather than wrap if one somehow does.
estimate_item_tokens(item).try_into().unwrap_or(u32::MAX)
}
}
/// Bytes/4 estimate of every non-system item in `items`.
pub fn estimate_messages_tokens(items: &[ConversationItem]) -> u64 {
items
.iter()
.filter(|i| !matches!(i, ConversationItem::System(_)))
.map(estimate_item_tokens)
.sum()
}
/// Internal mutable state for the ChatStateActor.
///
/// All fields are owned exclusively by the actor task — no locks needed.
pub(crate) struct ChatState {
/// The full conversation history.
pub conversation: Vec<ConversationItem>,
/// Current sampling configuration (model, context window, etc.).
pub sampling_config: SamplingConfig,
/// Current prompt index (incremented per user turn).
pub prompt_index: usize,
/// Cached prompt texts for rewind preview.
pub prompt_texts: Vec<String>,
/// Accumulated token usage.
pub total_tokens: u64,
/// Timestamp when the current stream started (epoch ms).
pub stream_start_ms: Option<i64>,
/// Timestamp when the current turn started (epoch ms).
pub turn_start_ms: Option<i64>,
/// File paths the agent has edited.
pub agent_edited_paths: BTreeSet<String>,
/// Prompt index at which the last compaction occurred.
pub last_compaction_prompt_index: Option<usize>,
/// Opaque credential secrets (api key, optional extra auth, client version).
/// Stored opaquely — the actor never interprets them.
pub credentials: Credentials,
/// Bytes/4 estimate of tokens added since the last `record_token_usage`.
/// Used by `check_preflight_overflow` to detect context window overflows
/// between model responses.
pub estimated_tokens_since_model: u64,
/// Bytes/4 estimate of the conversation as of the last `record_token_usage`
/// (or last reseed). `total_tokens estimate_at_last_response` is the
/// provider-side overhead carried across compaction.
pub estimate_at_last_response: u64,
/// Per-turn token usage from the most recent model response.
/// Stashed by `record_last_turn_usage()` and read at `PromptResponse`
/// construction to enrich `_meta` with `inputTokens` / `outputTokens` /
/// `cachedReadTokens`. `None` means no model turn has completed yet
/// in this session (or this is a freshly restored session that did not
/// persist last_turn_usage). Always overwritten by the most recent turn —
/// historical turns are not retained here.
pub last_turn_usage: Option<TokenUsage>,
/// Billing for the open prompt (cleared on next prompt; not persisted).
pub prompt_usage: Option<UsageLedger>,
/// Lifetime session billing (not persisted).
pub session_usage: UsageLedger,
/// Offset-based turn capture state. `Some` = capture active, `None` = inactive.
/// Cleared on `TakeTurnMessages` (consumed), `BeginTurnCapture` (new turn),
/// and `TruncateToPromptIndex` (rewind abandons the turn).
pub(super) turn_capture: Option<TurnCaptureState>,
/// Accumulator for the in-progress harness-subagent trace phase (the goal
/// planner at `setup_goal`, or one verifier skeptic panel). Synthetic
/// `task` pairs recorded via `AppendHarnessTraceItems` land here;
/// `FlushHarnessTraceTurn` seals the accumulated items into one entry of
/// `harness_trace_turns`. Independent of `turn_capture` (the planner runs
/// ahead of `BeginTurnCapture`) and never enters the live `conversation`.
pub(super) harness_trace_buffer: Vec<ConversationItem>,
/// Sealed harness trace turns awaiting drain by the agent, which uploads
/// each as its own sibling `turn_{N}` artifact so orchestrators can
/// discover harness subagents via their `<subagent_result>` footer.
/// Drained by `TakeHarnessTraceTurns` at the end of the user-facing turn.
pub(super) harness_trace_turns: Vec<Vec<ConversationItem>>,
}
/// Tracks which conversation items belong to the current turn without
/// cloning every pushed item into a side buffer.
///
/// Instead of duplicating each `ConversationItem` on push, we record the
/// conversation length at capture start (`turn_start_offset`). At take
/// time, `conversation[turn_start_offset..]` gives us the turn's items
/// with a single bulk clone.
///
/// When `replace_conversation` or `restore_snapshot` replaces the vec
/// mid-turn, we snapshot `conversation[turn_start_offset..]` into
/// `pre_replacement_messages` before the old vec is dropped, and reset
/// the offset to the new vec's length.
pub(super) struct TurnCaptureState {
/// Index into `conversation` where this turn's messages start.
pub turn_start_offset: usize,
/// Messages saved from before a conversation replacement (compaction,
/// snapshot restore). Extended (not replaced) if multiple replacements
/// occur in one turn.
pub pre_replacement_messages: Vec<ConversationItem>,
/// Whether compaction occurred during this capture.
pub compaction_occurred: bool,
}
impl ChatState {
/// Create a new `ChatState` with the given conversation and sampling config,
/// all other fields defaulted.
///
/// Repairs any dangling tool calls in the initial conversation. This handles
/// the race condition where the process was killed mid-tool-execution and
/// `chat_history.jsonl` has an assistant message with tool call IDs that
/// lack matching `ToolResult` entries. Without this, the in-memory state
/// would carry broken conversation history until the next `build_request`.
pub fn new(mut conversation: Vec<ConversationItem>, sampling_config: SamplingConfig) -> Self {
let deduped = dedup_duplicate_tool_results(&mut conversation);
if deduped > 0 {
tracing::info!(
deduped_count = deduped,
"Removed duplicate tool results in initial conversation"
);
}
let repaired =
repair_dangling_tool_calls(&mut conversation, DanglingToolCallReason::UserCancelled);
if repaired > 0 {
tracing::info!(
repaired_count = repaired,
"Repaired dangling tool calls in initial conversation (likely from a previous crash)"
);
}
let initial_tokens = estimate_conversation_tokens(&conversation);
Self {
conversation,
sampling_config,
prompt_index: 0,
prompt_texts: Vec::new(),
total_tokens: initial_tokens,
stream_start_ms: None,
turn_start_ms: None,
agent_edited_paths: BTreeSet::new(),
last_compaction_prompt_index: None,
credentials: Credentials::default(),
estimated_tokens_since_model: 0,
estimate_at_last_response: initial_tokens,
last_turn_usage: None,
prompt_usage: None,
session_usage: UsageLedger::default(),
turn_capture: None,
harness_trace_buffer: Vec::new(),
harness_trace_turns: Vec::new(),
}
}
/// Seal the items accumulated since the last flush into one harness trace
/// turn. No-op when nothing was recorded since the last seal. Shared by the
/// explicit `FlushHarnessTraceTurn` (one call per harness phase) and the
/// defensive seal in `TakeHarnessTraceTurns`.
pub(super) fn seal_harness_trace_turn(&mut self) {
if !self.harness_trace_buffer.is_empty() {
let turn = std::mem::take(&mut self.harness_trace_buffer);
self.harness_trace_turns.push(turn);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_sampling_config() -> SamplingConfig {
SamplingConfig {
base_url: "https://api.example.com".to_string(),
model: "test-model".to_string(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
stream_tool_calls: None,
}
}
#[test]
fn estimated_item_token_counter_matches_estimate_item_tokens() {
use xai_grok_compaction::ItemTokenCounter;
let counter = EstimatedItemTokenCounter;
let items = vec![
ConversationItem::system("you are a helpful assistant"),
ConversationItem::user("fix the login bug in auth.rs"),
ConversationItem::assistant("let me look at the file"),
ConversationItem::tool_result("tc1", "fn login() {}"),
];
for item in &items {
assert_eq!(
u64::from(counter.count_item_tokens(item)),
estimate_item_tokens(item),
"counter must report the same trusted count as estimate_item_tokens"
);
}
}
#[test]
fn new_state_has_correct_defaults() {
let state = ChatState::new(vec![], test_sampling_config());
assert_eq!(state.prompt_index, 0);
assert_eq!(state.total_tokens, 0); // empty conversation → 0
assert!(state.conversation.is_empty());
assert!(state.agent_edited_paths.is_empty());
assert!(state.prompt_texts.is_empty());
assert!(state.stream_start_ms.is_none());
assert!(state.turn_start_ms.is_none());
assert!(state.last_compaction_prompt_index.is_none());
}
#[test]
fn new_state_preserves_initial_conversation() {
let items = vec![
ConversationItem::system("sys"),
ConversationItem::user("hello"),
];
let state = ChatState::new(items, test_sampling_config());
assert_eq!(state.conversation.len(), 2);
}
#[test]
fn new_state_estimates_tokens_from_conversation() {
// 4000 bytes of text per item, bytes / 4 = 1000 tokens each
let items = vec![
ConversationItem::system("x".repeat(4000).as_str()),
ConversationItem::user("y".repeat(4000).as_str()),
ConversationItem::assistant("z".repeat(4000).as_str()),
ConversationItem::tool_result("call-1", "w".repeat(4000).as_str()),
];
let state = ChatState::new(items, test_sampling_config());
assert_eq!(state.total_tokens, 4000); // 4 * (4000/4)
}
#[test]
fn estimate_system_message_tokens_only_counts_system_items() {
let sys = ConversationItem::system("a".repeat(400));
assert_eq!(estimate_system_message_tokens(&sys), 100);
let user = ConversationItem::user("hello");
assert_eq!(estimate_system_message_tokens(&user), 0);
let asst = ConversationItem::assistant("hi");
assert_eq!(estimate_system_message_tokens(&asst), 0);
let tr = ConversationItem::tool_result("call-1", "x".repeat(4000).as_str());
assert_eq!(estimate_system_message_tokens(&tr), 0);
}
#[test]
fn estimate_tool_definition_tokens_counts_name_desc_params() {
// Empty parameters serialize to "null" (4 bytes) in the JSON-string len
let td = xai_grok_sampling_types::ToolDefinition::function(
"search",
Some("find a file"),
serde_json::json!({}),
);
// name=6 + desc=11 + params=`{}`.len()=2 = 19, /4 = 4
assert_eq!(estimate_tool_definition_tokens(&td), 4);
}
#[test]
fn estimate_messages_tokens_excludes_system_and_sums_rest() {
// 4000 bytes per item -> 1000 tokens each.
let items = vec![
ConversationItem::system("x".repeat(4000).as_str()),
ConversationItem::user("y".repeat(4000).as_str()),
ConversationItem::assistant("z".repeat(4000).as_str()),
ConversationItem::tool_result("call-1", "w".repeat(4000).as_str()),
];
// Total = 4000 (4 items * 1000), system = 1000, messages = 3000.
assert_eq!(estimate_conversation_tokens(&items), 4000);
assert_eq!(estimate_messages_tokens(&items), 3000);
}
#[test]
fn estimate_messages_tokens_zero_when_only_system() {
let items = vec![ConversationItem::system("x".repeat(4000).as_str())];
assert_eq!(estimate_messages_tokens(&items), 0);
}
#[test]
fn estimate_messages_tokens_zero_for_empty() {
assert_eq!(estimate_messages_tokens(&[]), 0);
}
#[test]
fn estimate_tool_definitions_tokens_sums_across_slice() {
let a = xai_grok_sampling_types::ToolDefinition::function(
"a",
None::<&str>,
serde_json::json!({}),
);
let b = xai_grok_sampling_types::ToolDefinition::function(
"b",
None::<&str>,
serde_json::json!({}),
);
let single = estimate_tool_definition_tokens(&a);
assert_eq!(estimate_tool_definitions_tokens(&[a, b]), single * 2);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,485 @@
//! Commands sent to the ChatStateActor.
use std::collections::BTreeSet;
use tokio::sync::oneshot;
use xai_grok_sampling_types::{
ConversationItem, ConversationRequest, DanglingToolCallReason, SamplingConfig, TokenUsage,
ToolSpec, TraceContext,
};
use crate::types::{
AutoCompactTrigger, ChatStateSnapshot, ConversationCounts, Credentials, NotificationMeta,
TurnCapture,
};
#[derive(Debug, Clone, Default)]
pub struct ModelMetadata {
pub resolved_model_id: Option<String>,
pub model_fingerprint: Option<String>,
}
/// Refusal reply for [`ChatStateCommand::RepairHistory`]: a turn was in
/// flight, and in-flight tool calls must not be treated as dangling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RepairHistoryBlocked;
impl std::fmt::Display for RepairHistoryBlocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"cannot repair history while a turn is in flight; stop the turn first"
)
}
}
impl std::error::Error for RepairHistoryBlocked {}
/// Commands sent to the ChatStateActor via mpsc channel.
pub enum ChatStateCommand {
// ═══ Mutations (fire-and-forget) ═══
/// Push a user message into the conversation.
PushUserMessage { item: ConversationItem },
/// Push a user message and acknowledge once the chat-state actor has
/// accepted and processed it.
PushUserMessageAndAck {
item: ConversationItem,
reply: oneshot::Sender<()>,
},
/// Push a user message with an explicit dangling-repair reason.
PushUserMessageWithRepairReason {
item: ConversationItem,
reason: DanglingToolCallReason,
},
/// Record the assistant's response (text + tool calls).
PushAssistantResponse { item: ConversationItem },
/// Record a tool result.
PushToolResult { item: ConversationItem },
/// Record accumulated token usage from a streaming response.
RecordTokenUsage { total_tokens: u64 },
/// Stash the per-turn `TokenUsage` from the most recent model response.
/// Overwrites any previously stashed value.
RecordLastTurnUsage { usage: TokenUsage },
RecordModelCallUsage {
model_id: Option<String>,
usage: TokenUsage,
api_duration_ms: Option<u64>,
cost_usd_ticks: Option<i64>,
},
/// Subagent usage into session (and prompt when attributable). Replies when applied.
RecordSubagentUsage {
by_model: Vec<(String, crate::usage::UsageTotals)>,
attribute_to_prompt: bool,
/// Nested subagent bill may under-count.
incomplete: bool,
reply: oneshot::Sender<()>,
},
/// Mark open prompt and/or session ledgers incomplete.
MarkUsageIncomplete {
prompt: bool,
session: bool,
reply: oneshot::Sender<()>,
},
/// Increment prompt_index (called at start of each user turn).
IncrementPromptIndex,
/// Update the sampling config (e.g., model switch).
UpdateSamplingConfig { config: SamplingConfig },
/// Track that the agent edited a file path.
RecordAgentEditedPath { path: String },
/// Record stream timing metadata.
RecordStreamStart { timestamp_ms: i64 },
/// Record turn timing metadata.
RecordTurnStart { timestamp_ms: i64 },
/// Replace conversation history.
ReplaceConversation {
items: Vec<ConversationItem>,
is_compaction: bool,
},
/// Out-of-band history repair (`x.ai/session/repair`): run
/// [`crate::compaction_utils::repair_history`] and persist when changed;
/// `dry_run` only reports.
///
/// `turn_active` (the session's shared flag, set at turn start BEFORE the
/// turn pushes anything here) is re-checked inside the command handler:
/// a caller-side check alone races turn start, whereas at processing time
/// the command is either refused or runs on pre-turn state with the
/// turn's pushes serialized after it.
RepairHistory {
dry_run: bool,
turn_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
reply: oneshot::Sender<
Result<crate::compaction_utils::HistoryRepairReport, RepairHistoryBlocked>,
>,
},
/// Atomically align the leading `System` message with `prompt` (inserting
/// one if absent), persisting the conversation. Executed inside the actor so
/// it serializes with concurrent turn pushes (`PushAssistantResponse` /
/// `PushToolResult`) — a mid-turn reconnect cannot lose those updates the
/// way a read-modify-write via `GetConversation` + `ReplaceConversation`
/// would. Replies `true` iff the conversation changed (no-op when the head
/// already matches modulo trailing newlines). A changed head goes through
/// `replace_conversation`, which re-bases `total_tokens` to a fresh static
/// estimate — acceptable because a changed head invalidates the KV prefix
/// anyway.
ReplaceSystemHead {
prompt: String,
reply: oneshot::Sender<bool>,
},
/// Cache prompt text for rewind preview.
CachePromptText { text: String },
/// Record compaction boundary for rewind.
RecordCompactionAt { prompt_index: usize },
/// Flush pending persistence writes to disk (end of turn).
Flush,
/// Update opaque credential secrets held by the actor.
UpdateCredentials { credentials: Credentials },
/// Restore from a snapshot.
RestoreSnapshot(Box<ChatStateSnapshot>),
/// Start capturing turn messages. Clears any previous buffer.
BeginTurnCapture,
/// Append synthetic `task` pairs for a harness-spawned subagent (goal
/// planner / verifier skeptic) to the in-progress harness trace phase.
/// Accumulated independently of the live `conversation` and of
/// `turn_capture`; sealed into a standalone trace turn by
/// `FlushHarnessTraceTurn`.
AppendHarnessTraceItems { items: Vec<ConversationItem> },
/// Seal the harness items accumulated since the last flush into one
/// standalone trace turn. Issued once per harness phase (after the planner,
/// after each verifier panel). No-op when nothing was recorded.
FlushHarnessTraceTurn,
/// Repair dangling tool calls after a harness-initiated halt.
RepairDanglingAfterHarnessHalt { class: &'static str },
// ═══ Queries (request/response via oneshot) ═══
/// Build a ConversationRequest ready to send to the API.
/// Clones the conversation, prunes old tool results, repairs dangling
/// tool calls, injects memory reminder, and assembles the request.
BuildConversationRequest {
tool_definitions: Vec<ToolSpec>,
memory_reminder: Option<String>,
persist_memory_reminder: bool,
trace: Option<Box<dyn TraceContext>>,
conv_id: String,
req_id: String,
reply: oneshot::Sender<ConversationRequest>,
},
/// Get a clone of the full conversation.
GetConversation {
reply: oneshot::Sender<Vec<ConversationItem>>,
},
/// Get current prompt index.
GetPromptIndex { reply: oneshot::Sender<usize> },
/// Get the prompt index at which the last compaction occurred.
/// `Some` means the context currently holds a compaction summary.
GetLastCompactionPromptIndex {
reply: oneshot::Sender<Option<usize>>,
},
/// Get total accumulated tokens.
GetTotalTokens { reply: oneshot::Sender<u64> },
/// Retrieve the most recent stashed per-turn `TokenUsage`. Returns
/// `None` until at least one `RecordLastTurnUsage` has been processed.
GetLastTurnUsage {
reply: oneshot::Sender<Option<TokenUsage>>,
},
GetPromptUsage {
reply: oneshot::Sender<Option<crate::usage::UsageLedger>>,
},
GetSessionUsage {
reply: oneshot::Sender<crate::usage::UsageLedger>,
},
/// `total_tokens` + bytes/4 delta from tool results since last model response.
GetEstimatedTotalTokens { reply: oneshot::Sender<u64> },
/// Bytes/4 estimate of all non-system conversation items.
GetEstimatedMessagesTokens { reply: oneshot::Sender<u64> },
/// Get sampling config.
GetSamplingConfig {
reply: oneshot::Sender<SamplingConfig>,
},
/// Get the set of agent-edited file paths.
GetAgentEditedPaths {
reply: oneshot::Sender<BTreeSet<String>>,
},
/// Get notification meta (timing info).
GetNotificationMeta {
reply: oneshot::Sender<NotificationMeta>,
},
/// Snapshot state for forking or rewind.
Snapshot {
reply: oneshot::Sender<ChatStateSnapshot>,
},
/// Truncate conversation to a target prompt index (for rewind).
TruncateToPromptIndex {
target_prompt_index: usize,
reply: oneshot::Sender<()>,
},
/// Check if auto-compact is needed (returns token info).
CheckAutoCompactNeeded {
threshold_percent: u8,
reply: oneshot::Sender<Option<AutoCompactTrigger>>,
},
/// Get credential secrets.
GetCredentials { reply: oneshot::Sender<Credentials> },
GetLastModelMetadata {
reply: oneshot::Sender<ModelMetadata>,
},
/// Take the accumulated turn messages and end the capture.
/// Returns `None` if no capture was active.
TakeTurnMessages {
reply: oneshot::Sender<Option<TurnCapture>>,
},
/// Drain the sealed harness trace turns (goal planner + verifier panels).
/// Each `Vec` is one turn's synthetic `task` pairs, uploaded by the agent
/// as its own sibling `turn_{N}` artifact. Seals a trailing un-flushed
/// accumulator before draining.
TakeHarnessTraceTurns {
reply: oneshot::Sender<Vec<Vec<ConversationItem>>>,
},
// ═══ Narrow targeted queries (avoid full-conversation clone) ═══
/// Get the number of items in the conversation.
/// Cheaper than `GetConversation` when only the length is needed.
GetConversationLen { reply: oneshot::Sender<usize> },
/// Whether any assistant tool call lacks a matching `ToolResult` (i.e. the
/// dangling-tool-call repair would fire on the next request build).
/// Cheaper than `GetConversation` when only this predicate is needed.
HasDanglingToolCalls { reply: oneshot::Sender<bool> },
/// Get the text content of the last assistant message with non-empty text.
/// Returns `None` if no such message exists.
/// Cheaper than `GetConversation` when only the final assistant response is needed.
GetLastAssistantText {
reply: oneshot::Sender<Option<String>>,
},
/// Get the text of the first `Text` content part in the first `User` message.
/// Returns `None` if the conversation has no user messages or the first user
/// message has no text content part.
/// Cheaper than `GetConversation` when only the initial user query is needed.
GetFirstUserText {
reply: oneshot::Sender<Option<String>>,
},
/// Get a single conversation item by index (0-based).
/// Returns `None` if the index is out of bounds.
/// Cheaper than `GetConversation` when only one item is needed.
GetConversationItemAt {
index: usize,
reply: oneshot::Sender<Option<ConversationItem>>,
},
/// Get the processed text of the last user query (metadata tags stripped).
///
/// Equivalent to `extract_last_user_query(&conversation)` but without
/// cloning the full conversation on the caller side.
GetLastUserQueryText {
reply: oneshot::Sender<Option<String>>,
},
/// Get item counts for the conversation by role.
///
/// Returns a `ConversationCounts` struct without cloning any items.
/// Suitable for telemetry / logging that only needs totals.
GetConversationCounts {
reply: oneshot::Sender<ConversationCounts>,
},
/// Get the first `System` message in the conversation, if any.
///
/// Cheaper than `GetConversation` when only the system prompt is needed
/// (e.g. for compaction setup or error guards).
GetSystemMessage {
reply: oneshot::Sender<Option<ConversationItem>>,
},
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify that every command variant is constructible (compile-time check).
#[test]
fn command_variants_are_constructible() {
// Mutations
let _ = ChatStateCommand::PushUserMessage {
item: ConversationItem::user("hello"),
};
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::PushUserMessageAndAck {
item: ConversationItem::user("hello"),
reply: tx,
};
let _ = ChatStateCommand::PushAssistantResponse {
item: ConversationItem::assistant("hi"),
};
let _ = ChatStateCommand::PushToolResult {
item: ConversationItem::tool_result("call-1", "result"),
};
let _ = ChatStateCommand::RecordTokenUsage { total_tokens: 100 };
let _ = ChatStateCommand::IncrementPromptIndex;
let _ = ChatStateCommand::UpdateSamplingConfig {
config: SamplingConfig {
base_url: String::new(),
model: String::new(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
stream_tool_calls: None,
},
};
let _ = ChatStateCommand::RecordAgentEditedPath {
path: "src/main.rs".to_string(),
};
let _ = ChatStateCommand::RecordStreamStart {
timestamp_ms: 12345,
};
let _ = ChatStateCommand::RecordTurnStart {
timestamp_ms: 12345,
};
let _ = ChatStateCommand::ReplaceConversation {
items: vec![],
is_compaction: false,
};
let _ = ChatStateCommand::CachePromptText {
text: "prompt".to_string(),
};
let _ = ChatStateCommand::RecordCompactionAt { prompt_index: 0 };
let _ = ChatStateCommand::Flush;
// Queries
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetConversation { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetPromptIndex { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetLastCompactionPromptIndex { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetTotalTokens { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetEstimatedTotalTokens { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetSamplingConfig { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetAgentEditedPaths { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::BuildConversationRequest {
tool_definitions: vec![],
memory_reminder: None,
persist_memory_reminder: false,
trace: None,
conv_id: String::new(),
req_id: String::new(),
reply: tx,
};
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetNotificationMeta { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::Snapshot { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::TruncateToPromptIndex {
target_prompt_index: 0,
reply: tx,
};
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::CheckAutoCompactNeeded {
threshold_percent: 85,
reply: tx,
};
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetLastModelMetadata { reply: tx };
let _ = ChatStateCommand::BeginTurnCapture;
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::TakeTurnMessages { reply: tx };
// Narrow targeted queries
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetConversationLen { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetLastAssistantText { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetFirstUserText { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetConversationItemAt {
index: 0,
reply: tx,
};
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetLastUserQueryText { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetConversationCounts { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetSystemMessage { reply: tx };
let (tx, _rx) = oneshot::channel();
let _ = ChatStateCommand::GetEstimatedMessagesTokens { reply: tx };
}
}

View file

@ -0,0 +1,149 @@
//! Compaction mode — how much structure the model gets to recover detail the
//! lossy summary dropped. In `xai-chat-state` so flag resolution and the
//! transcript-hint builder share one definition.
use crate::compaction_transcript::CompactionDetail;
/// How compaction exposes pre-compaction history to the model afterwards.
/// `Segments` carries its verbatim detail level inline, since detail is
/// meaningful only there.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum CompactionMode {
/// Summary only — no pointer back to pre-compaction history. Default.
#[default]
Summary,
/// Summary + pointer to the full raw `updates.jsonl`.
Transcript,
/// Summary + a `compaction/` folder of clean per-segment markdown.
Segments(CompactionDetail),
}
impl CompactionMode {
/// Parse the mode word (case-insensitive); unknown → `None` so the caller
/// falls back. `segments` gets the default detail — callers override it via
/// [`CompactionMode::with_segment_detail`] once detail is resolved.
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"summary" => Some(Self::Summary),
"transcript" => Some(Self::Transcript),
"segments" => Some(Self::Segments(CompactionDetail::default())),
_ => None,
}
}
/// Replace the detail level if this is `Segments`, else unchanged. Lets the
/// resolver attach the separately-resolved `GROK_COMPACTION_DETAIL`.
pub fn with_segment_detail(self, detail: CompactionDetail) -> Self {
match self {
Self::Segments(_) => Self::Segments(detail),
other => other,
}
}
pub fn segment_detail(self) -> Option<CompactionDetail> {
match self {
Self::Segments(d) => Some(d),
_ => None,
}
}
/// Whether this mode persists the `compaction/` segment store.
pub fn writes_segments(self) -> bool {
matches!(self, Self::Segments(_))
}
/// Transcript hint for the summary, given the one `location` this mode points
/// at (raw transcript path or `compaction/` folder). `None` if the mode adds
/// no pointer (`Summary`) or the location is absent.
pub fn transcript_hint(self, location: Option<&str>) -> Option<String> {
use crate::compaction_transcript::INDEX_FILE;
let loc = location?;
Some(match self {
Self::Summary => return None,
Self::Transcript => format!(
"\n\nIf you need specific details from before compaction \
(like exact code snippets, error messages, or content you \
generated), read the full transcript at: {loc}"
),
// Wording mirrors the segment-store continuation note.
Self::Segments(_) => format!(
"\n\nFull verbatim rollouts of previous segments are available \
at {loc}/segment_*.md. See {loc}/{INDEX_FILE} for a table of \
contents. Use read_file or grep to recover specific details \
(exact code, file paths, tool outputs) if this summary is \
insufficient. Do NOT modify these files."
),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `parse` is the public string contract (config + CLI): case-insensitive,
/// unknown ⇒ `None` so the caller falls back.
#[test]
fn parse_maps_names_and_rejects_unknown() {
assert_eq!(
CompactionMode::parse("summary"),
Some(CompactionMode::Summary)
);
assert_eq!(
CompactionMode::parse("transcript"),
Some(CompactionMode::Transcript)
);
// `segments` parses with the default detail; the resolver overrides it.
assert_eq!(
CompactionMode::parse(" SEGMENTS "),
Some(CompactionMode::Segments(CompactionDetail::default()))
);
assert_eq!(CompactionMode::parse("nonsense"), None);
assert_eq!(CompactionMode::default(), CompactionMode::Summary);
}
/// Detail is only attached to `Segments`; other modes ignore the override.
#[test]
fn with_segment_detail_only_affects_segments() {
assert_eq!(
CompactionMode::Segments(CompactionDetail::Verbose)
.with_segment_detail(CompactionDetail::Minimal),
CompactionMode::Segments(CompactionDetail::Minimal)
);
assert_eq!(
CompactionMode::Summary.with_segment_detail(CompactionDetail::Minimal),
CompactionMode::Summary
);
assert_eq!(
CompactionMode::Segments(CompactionDetail::Balanced).segment_detail(),
Some(CompactionDetail::Balanced)
);
assert_eq!(CompactionMode::Transcript.segment_detail(), None);
}
/// Contract: no hint for `Summary`, and never point the model at nothing.
#[test]
fn transcript_hint_needs_a_location() {
let segments = CompactionMode::Segments(CompactionDetail::default());
assert!(
CompactionMode::Summary
.transcript_hint(Some("/s/updates.jsonl"))
.is_none()
);
assert!(CompactionMode::Transcript.transcript_hint(None).is_none());
assert!(segments.transcript_hint(None).is_none());
assert!(
CompactionMode::Transcript
.transcript_hint(Some("/s/updates.jsonl"))
.unwrap()
.contains("/s/updates.jsonl")
);
assert!(
segments
.transcript_hint(Some("/s/compaction"))
.unwrap()
.contains("/s/compaction")
);
}
}

View file

@ -0,0 +1,821 @@
//! Pure rendering of a compacted segment into self-contained markdown, aligned
//! with the Python compaction implementation (`render_segment_to_markdown` /
//! `compute_turn_stats`; INDEX built incrementally via [`INDEX_HEADER`] +
//! [`render_index_row`]). No I/O.
//! Not byte-identical — the data models differ (Python `Turn`/channels vs our
//! [`ConversationItem`]) — but headers, sections, detail levels, and INDEX
//! columns match.
use std::sync::OnceLock;
use regex::Regex;
use xai_grok_sampling_types::ConversationItem;
/// Layout of the per-session segment store — single source of the path
/// convention (writer, index parser, and transcript-hint builder all use these).
pub const COMPACTION_DIR: &str = "compaction";
pub const INDEX_FILE: &str = "INDEX.md";
const SEGMENT_PREFIX: &str = "segment_";
/// Whole-turn-boundary truncation cap for one segment's verbatim section.
const SEGMENT_MAX_BYTES: usize = 512 * 1024;
const TRUNCATION_NOTICE: &str =
"\n\n[... TRUNCATED at {limit} bytes, {omitted} turns omitted ...]\n";
/// Per-turn text/arg caps for the `balanced` detail level (chars, like the Python implementation).
const BALANCED_TEXT_CHARS: usize = 2000;
const BALANCED_RESPONSE_CHARS: usize = 500;
/// Trailing chars of the last assistant message kept for the stats excerpt.
const LAST_RESPONSE_EXCERPT_CHARS: usize = 500;
/// Approx markdown overhead charged per turn in the verbose-size estimate.
const PER_TURN_OVERHEAD_BYTES: usize = 64;
/// How much per-turn detail lands in the verbatim section. Mirrors the Python
/// `compaction_persist_detail`. `Verbose` is the default.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum CompactionDetail {
/// Stats + summary only, no verbatim turns.
None,
/// One-line tool-call signature per turn.
Minimal,
/// Tool calls + truncated responses + full text.
Balanced,
/// Full verbatim turns.
#[default]
Verbose,
}
impl CompactionDetail {
/// Case-insensitive; unknown → `None` so the caller falls back to default.
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"balanced" => Some(Self::Balanced),
"verbose" => Some(Self::Verbose),
_ => None,
}
}
}
/// Role label per item, mapped onto the Python `Turn` role vocabulary
/// (`System`/`Human`/`Assistant`/`Function`). Model-side items with no Python
/// analog (`BackendToolCall`, `Reasoning`) fold into `Assistant`.
fn role_label(item: &ConversationItem) -> &'static str {
match item {
ConversationItem::System(_) => "System",
ConversationItem::User(_) => "Human",
ConversationItem::Assistant(_) => "Assistant",
ConversationItem::ToolResult(_) => "Function",
ConversationItem::BackendToolCall(_) => "Assistant",
ConversationItem::Reasoning(_) => "Assistant",
}
}
/// INDEX.md title + table header, written once when the file is created.
pub const INDEX_HEADER: &str = "# Compaction Segment Index\n\n\
| Segment | File | Turns | Approx bytes | Keywords |\n\
|---|---|---|---|---|\n";
/// Zero-padded segment number, e.g. `007`. The single source of the pad width.
fn segment_label(index: u64) -> String {
format!("{index:03}")
}
/// Flat per-segment filename, e.g. `segment_007.md` (matches the Python implementation).
pub fn segment_filename(index: u64) -> String {
format!("{SEGMENT_PREFIX}{}.md", segment_label(index))
}
/// Parse a segment index out of a `segment_NNN.md` filename, if it matches.
pub fn parse_segment_index(filename: &str) -> Option<u64> {
filename
.strip_prefix(SEGMENT_PREFIX)?
.strip_suffix(".md")?
.parse()
.ok()
}
/// A read of the `compaction/` store; `Display` (snake_case) is the telemetry
/// label on `compaction.segment_read`.
#[derive(Debug, PartialEq, Eq, strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum CompactionArtifact {
Segment(u64),
Index,
Dir,
}
impl CompactionArtifact {
pub fn segment_index(&self) -> Option<u64> {
match self {
Self::Segment(index) => Some(*index),
_ => None,
}
}
}
/// Anchors on the `compaction/` component, not the session dir, so relative
/// reads still match (a same-named file elsewhere is acceptable noise).
pub fn classify_compaction_path(path: &str) -> Option<CompactionArtifact> {
// Allocation-free: match the `compaction` component directly rather than
// building `"compaction/"` / `"/compaction"` patterns each call.
let trimmed = path.trim_end_matches('/');
if trimmed == COMPACTION_DIR
|| trimmed
.strip_suffix(COMPACTION_DIR)
.is_some_and(|prefix| prefix.ends_with('/'))
{
return Some(CompactionArtifact::Dir);
}
let rest = path
.rsplit_once(COMPACTION_DIR)
.and_then(|(_, after)| after.strip_prefix('/'))?;
if let Some(index) = parse_segment_index(rest) {
Some(CompactionArtifact::Segment(index))
} else if rest == INDEX_FILE {
Some(CompactionArtifact::Index)
} else {
None
}
}
/// Truncate to ≤ `max` chars, appending `marker` if cut (char-based, like
/// the Python `text[:n]`). Char boundaries are respected so we never panic.
fn truncate_chars(s: &str, max: usize, marker: &str) -> String {
match s.char_indices().nth(max) {
Some((byte_idx, _)) => format!("{}{marker}", &s[..byte_idx]),
None => s.to_string(),
}
}
/// Insert thousands separators (mirrors Python's `{:,}`).
fn with_thousands(n: usize) -> String {
let digits = n.to_string();
let bytes = digits.as_bytes();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
}
out
}
/// One JSON tool-arg value rendered for a `- key: value` line.
fn arg_value_plain(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn tool_args(arguments: &str) -> serde_json::Map<String, serde_json::Value> {
match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
}
}
/// Keys checked (in order) to attribute a tool call to a target file/dir.
const FILE_ARG_KEYS: [&str; 4] = ["target_file", "file_path", "path", "target_directory"];
/// Walk-once statistics for the always-on `## Turn statistics` block.
struct TurnStats {
turn_count: usize,
/// Role → count, kept sorted by role name.
role_counts: Vec<(&'static str, usize)>,
/// Tool name → count.
tool_counts: Vec<(String, usize)>,
unique_files: Vec<String>,
tool_error_count: usize,
verbose_byte_estimate: usize,
last_assistant_excerpt: String,
}
fn compute_turn_stats(items: &[ConversationItem]) -> TurnStats {
use std::collections::BTreeMap;
use std::collections::BTreeSet;
let mut role_counts: BTreeMap<&'static str, usize> = BTreeMap::new();
let mut tool_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut unique_files: BTreeSet<String> = BTreeSet::new();
let mut tool_error_count = 0;
let mut last_assistant = String::new();
let mut verbose_byte_estimate = 0;
for item in items {
*role_counts.entry(role_label(item)).or_insert(0) += 1;
verbose_byte_estimate += PER_TURN_OVERHEAD_BYTES;
match item {
ConversationItem::Assistant(a) => {
verbose_byte_estimate += a.content.len();
if !a.content.is_empty() {
last_assistant = a.content.to_string();
}
for tc in &a.tool_calls {
*tool_counts.entry(tc.name.clone()).or_insert(0) += 1;
let args = tool_args(&tc.arguments);
for key in FILE_ARG_KEYS {
if let Some(serde_json::Value::String(v)) = args.get(key)
&& !v.is_empty()
{
unique_files.insert(v.clone());
break;
}
}
verbose_byte_estimate += args
.iter()
.map(|(k, v)| 32 + k.len() + arg_value_plain(v).len())
.sum::<usize>();
}
}
ConversationItem::ToolResult(t) => {
verbose_byte_estimate += t.content.len();
if t.content.starts_with("Error") || t.content.contains("Failed tool validation") {
tool_error_count += 1;
}
}
other => verbose_byte_estimate += other.text_content().len(),
}
}
let excerpt = {
let n = last_assistant.chars().count();
let tail: String = last_assistant
.chars()
.skip(n.saturating_sub(LAST_RESPONSE_EXCERPT_CHARS))
.collect();
tail.trim().to_string()
};
let mut tool_counts: Vec<(String, usize)> = tool_counts.into_iter().collect();
// Descending count for at-a-glance scanning; name breaks ties.
tool_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
TurnStats {
turn_count: items.len(),
role_counts: role_counts.into_iter().collect(),
tool_counts,
unique_files: unique_files.into_iter().collect(),
tool_error_count,
verbose_byte_estimate,
last_assistant_excerpt: excerpt,
}
}
fn render_stats_block(stats: &TurnStats) -> String {
use std::fmt::Write as _;
let mut out = String::from("## Turn statistics\n\n");
let rc = stats
.role_counts
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(out, "- Turns: {} ({rc})", stats.turn_count);
let tc = if stats.tool_counts.is_empty() {
"(none)".to_string()
} else {
stats
.tool_counts
.iter()
.map(|(name, n)| format!("{name} ({n})"))
.collect::<Vec<_>>()
.join(", ")
};
let _ = writeln!(out, "- Tools used: {tc}");
let uf = &stats.unique_files;
let uf_str = if uf.is_empty() {
"(none)".to_string()
} else if uf.len() <= 8 {
uf.join(", ")
} else {
format!("{}, ... and {} more", uf[..5].join(", "), uf.len() - 5)
};
let _ = writeln!(out, "- Unique target files ({}): {uf_str}", uf.len());
let _ = writeln!(out, "- Tool errors: {}", stats.tool_error_count);
let _ = writeln!(
out,
"- Verbose-render size estimate: {} B",
with_thousands(stats.verbose_byte_estimate)
);
if !stats.last_assistant_excerpt.is_empty() {
let oneline = truncate_chars(&stats.last_assistant_excerpt.replace('\n', " "), 300, "");
let _ = writeln!(out, "- Last assistant response excerpt: \"{oneline}\"");
}
out.push('\n');
out
}
/// One verbatim turn: role header, text, and `[tool_request: …]` arg lines.
fn render_turn_verbose(item: &ConversationItem, index: usize) -> String {
let mut parts = vec![format!("### Turn {index} ({})", role_label(item))];
match item {
ConversationItem::Assistant(a) => {
if !a.content.is_empty() {
parts.push(a.content.to_string());
}
for tc in &a.tool_calls {
parts.push(format!("[tool_request: {}]", tc.name));
for (k, v) in tool_args(&tc.arguments) {
parts.push(format!("- {k}: {}", arg_value_plain(&v)));
}
}
}
ConversationItem::ToolResult(t) => {
parts.push("[tool_response]".to_string());
if !t.content.is_empty() {
parts.push(t.content.to_string());
}
}
other => {
let txt = other.text_content();
if !txt.is_empty() {
parts.push(txt);
}
}
}
parts.join("\n") + "\n"
}
/// One balanced turn: full text (capped) + truncated tool-call args/responses.
fn render_turn_balanced(item: &ConversationItem, index: usize) -> String {
let mut parts = vec![format!("### Turn {index} ({})", role_label(item))];
match item {
ConversationItem::Assistant(a) => {
if !a.content.is_empty() {
parts.push(truncate_chars(
&a.content,
BALANCED_TEXT_CHARS,
"... [truncated]",
));
}
for tc in &a.tool_calls {
parts.push(format!("[tool_request: {}]", tc.name));
for (k, v) in tool_args(&tc.arguments) {
let v = truncate_chars(
&arg_value_plain(&v),
BALANCED_RESPONSE_CHARS,
"... [truncated]",
);
parts.push(format!("- {k}: {v}"));
}
}
}
ConversationItem::ToolResult(t) => {
parts.push("[tool_response]".to_string());
if !t.content.is_empty() {
parts.push(truncate_chars(
&t.content,
BALANCED_RESPONSE_CHARS,
"... [truncated]",
));
}
}
other => {
let txt = other.text_content();
if !txt.is_empty() {
parts.push(txt);
}
}
}
parts.join("\n") + "\n"
}
/// One-line tool-call signature per turn, no response bodies.
fn render_turn_signature(item: &ConversationItem, index: usize) -> String {
let role = role_label(item);
match item {
ConversationItem::Assistant(a) => {
let sigs: Vec<String> = a
.tool_calls
.iter()
.map(|tc| {
let args = tool_args(&tc.arguments);
let key_arg = [
"target_file",
"file_path",
"path",
"target_directory",
"command",
"pattern",
]
.iter()
.find_map(|k| match args.get(*k) {
Some(serde_json::Value::String(v)) if !v.is_empty() => {
Some(format!("{k}={:?}", truncate_chars(v, 80, "...")))
}
_ => None,
})
.unwrap_or_default();
format!("{}({key_arg})", tc.name)
})
.collect();
let sig_str = if sigs.is_empty() {
"(text only)".to_string()
} else {
sigs.join(" ")
};
format!("### Turn {index} ({role}) {sig_str}\n")
}
ConversationItem::ToolResult(_) => format!("### Turn {index} ({role}) [tool_response]\n"),
_ => format!("### Turn {index} ({role})\n"),
}
}
/// Render one segment: header, metadata, stats, curated summary, and (unless
/// `detail == None`) verbatim turns truncated at a whole-turn boundary before
/// [`SEGMENT_MAX_BYTES`]. `summary` must already be cleaned of analysis tags;
/// `items` is the segment view — tool calls + results kept, images/reasoning
/// stripped (see `compaction_utils::prepare_conversation_for_segment`).
pub fn render_segment_md(
items: &[ConversationItem],
summary: &str,
index: u64,
detail: CompactionDetail,
timestamp: &str,
) -> String {
let header = format!(
"# HISTORICAL -- DO NOT EDIT\n\
# Record of compaction segment {label} (detail={detail}) from this same task.\n\
# Use read_file or grep to look up details, but do not modify.\n\n",
label = segment_label(index),
);
let metadata = format!(
"## Segment metadata\n- Index: {label}\n- Turn count: {count}\n- Timestamp: {timestamp}\n\n",
label = segment_label(index),
count = items.len(),
);
let stats_section = render_stats_block(&compute_turn_stats(items)) + "\n";
let summary_body = summary.trim();
let summary_section = format!(
"## Summary (curated by compaction step)\n\n{}\n\n",
if summary_body.is_empty() {
"(empty)"
} else {
summary_body
},
);
let preamble_head = format!("{header}{metadata}{stats_section}{summary_section}");
if detail == CompactionDetail::None {
return preamble_head;
}
let (turns_header, render_turn): (&str, fn(&ConversationItem, usize) -> String) = match detail {
CompactionDetail::Minimal => ("## Turn signatures\n\n", render_turn_signature),
CompactionDetail::Balanced => ("## Turns (balanced detail)\n\n", render_turn_balanced),
CompactionDetail::Verbose => ("## Verbatim turns\n\n", render_turn_verbose),
CompactionDetail::None => unreachable!("None returns above"),
};
let preamble = format!("{preamble_head}{turns_header}");
// Reserve the preamble, the notice, and slack for its `{limit}`/`{omitted}`
// substitutions so the rendered doc stays under the cap.
let budget = SEGMENT_MAX_BYTES
.saturating_sub(preamble.len() + TRUNCATION_NOTICE.len() + PER_TURN_OVERHEAD_BYTES);
let mut blocks: Vec<String> = Vec::new();
let mut used = 0;
let mut truncated_at: Option<usize> = None;
for (i, item) in items.iter().enumerate() {
let block = render_turn(item, i);
if used + block.len() > budget {
truncated_at = Some(i);
break;
}
used += block.len();
blocks.push(block);
}
let mut body = blocks.join("\n");
if let Some(at) = truncated_at {
let omitted = items.len() - at;
body.push_str(
&TRUNCATION_NOTICE
.replace("{limit}", &SEGMENT_MAX_BYTES.to_string())
.replace("{omitted}", &omitted.to_string()),
);
}
format!("{preamble}{body}")
}
/// One INDEX.md row (with trailing newline). `keywords` are quoted and
/// comma-joined; the columns match [`INDEX_HEADER`].
pub fn render_index_row(
index: u64,
turn_count: usize,
approx_bytes: usize,
keywords: &[String],
) -> String {
let kw = keywords
.iter()
.map(|k| format!("\"{k}\""))
.collect::<Vec<_>>()
.join(", ");
format!(
"| {label} | {file} | {turn_count} | {approx_bytes} | {kw} |\n",
label = segment_label(index),
file = segment_filename(index),
)
}
static SECTION8_START_RE: OnceLock<Regex> = OnceLock::new();
static SECTION_HEADER_RE: OnceLock<Regex> = OnceLock::new();
static KEYWORD_RE: OnceLock<Regex> = OnceLock::new();
/// Stopwords dropped from INDEX keywords (mirrors the Python implementation).
const KEYWORD_STOPWORDS: [&str; 28] = [
"section",
"summary",
"current",
"work",
"errors",
"analysis",
"primary",
"request",
"intent",
"technical",
"concepts",
"pending",
"problem",
"solving",
"include",
"outline",
"describe",
"specific",
"messages",
"feedback",
"snippet",
"snippets",
"session",
"explicit",
"thorough",
"language",
"important",
"convention",
];
/// Best-effort INDEX keywords: identifier-shaped tokens from the summary's
/// "8. Current Work" section (falling back to the whole summary), minus
/// stopwords, deduped, capped at 8. Heuristic only — feeds the INDEX table.
pub fn extract_keywords(summary: &str) -> Vec<String> {
// Rust's regex has no look-ahead, so scope section 8 with two anchored
// matches: its header, then the next `N. Capital` header (or end of text).
// `#{0,6}` tolerates our `## 8. Current Work` markdown headers as well as
// the Python implementation's bare `8. Current Work`.
let start_re =
SECTION8_START_RE.get_or_init(|| Regex::new(r"(?m)^#{0,6}\s*8\.\s+Current Work").unwrap());
let header_re =
SECTION_HEADER_RE.get_or_init(|| Regex::new(r"(?m)^#{0,6}\s*\d+\.\s+[A-Z]").unwrap());
let kw_re =
KEYWORD_RE.get_or_init(|| Regex::new(r"[A-Z][A-Za-z0-9_]{3,}|[a-z][a-z0-9_]{5,}").unwrap());
let text = match start_re.find(summary) {
Some(m) => {
let end = header_re
.find_at(summary, m.end())
.map(|h| h.start())
.unwrap_or(summary.len());
&summary[m.start()..end]
}
None => summary,
};
let mut seen: Vec<String> = Vec::new();
for m in kw_re.find_iter(text) {
let kw = m.as_str();
if KEYWORD_STOPWORDS.contains(&kw.to_ascii_lowercase().as_str()) {
continue;
}
if seen.iter().any(|s| s == kw) {
continue;
}
seen.push(kw.to_string());
if seen.len() >= 8 {
break;
}
}
seen
}
#[cfg(test)]
mod tests {
use super::*;
fn user(text: &str) -> ConversationItem {
ConversationItem::user(text)
}
/// The segment doc carries the Python implementation's skeleton: banner, metadata, stats,
/// curated summary, and a detail-specific verbatim section.
#[test]
fn segment_md_matches_skeleton() {
let md = render_segment_md(
&[user("hello world")],
"Summary: did things.",
7,
CompactionDetail::Verbose,
"2026-01-01T00:00:00Z",
);
assert!(md.starts_with("# HISTORICAL -- DO NOT EDIT\n"));
assert!(
md.contains("# Record of compaction segment 007 (detail=verbose) from this same task.")
);
assert!(md.contains("## Segment metadata\n- Index: 007\n- Turn count: 1\n"));
assert!(md.contains("## Turn statistics"));
assert!(md.contains("## Summary (curated by compaction step)\n\nSummary: did things."));
}
/// Detail level selects the turns section (and `none` omits it entirely).
#[test]
fn detail_levels_select_turns_section() {
let one = [user("hi")];
let none = render_segment_md(&one, "s", 0, CompactionDetail::None, "t");
assert!(!none.contains("## Verbatim turns") && !none.contains("## Turn signatures"));
assert!(
render_segment_md(&one, "s", 0, CompactionDetail::Minimal, "t")
.contains("## Turn signatures")
);
assert!(
render_segment_md(&one, "s", 0, CompactionDetail::Balanced, "t")
.contains("## Turns (balanced detail)")
);
assert!(
render_segment_md(&one, "s", 0, CompactionDetail::Verbose, "t")
.contains("## Verbatim turns")
);
// Stats + summary survive at every level.
for d in [
CompactionDetail::None,
CompactionDetail::Minimal,
CompactionDetail::Balanced,
CompactionDetail::Verbose,
] {
assert!(render_segment_md(&one, "s", 0, d, "t").contains("## Turn statistics"));
}
}
/// Verbatim turns are dropped at a whole-turn boundary once the byte budget
/// is exceeded, with a notice naming how many turns were omitted.
#[test]
fn verbatim_turns_truncate_at_turn_boundary() {
// Each turn renders ~200 KB, so the 3rd turn blows the 512 KB budget.
let big = "x".repeat(200 * 1024);
let items = [user(&big), user(&big), user(&big), user(&big)];
let md = render_segment_md(&items, "s", 0, CompactionDetail::Verbose, "t");
assert!(md.contains("### Turn 0 (Human)"));
assert!(md.contains(&format!("TRUNCATED at {SEGMENT_MAX_BYTES} bytes")));
assert!(md.contains("turns omitted"));
// A whole turn was dropped (4 items, not all rendered).
assert!(md.matches("### Turn ").count() < items.len());
}
/// INDEX header + row match the 5-column Python table; keywords are quoted.
#[test]
fn index_row_matches_columns() {
assert!(INDEX_HEADER.starts_with(
"# Compaction Segment Index\n\n| Segment | File | Turns | Approx bytes | Keywords |"
));
let row = render_index_row(2, 9, 1234, &["Foo".to_string(), "bar_baz".to_string()]);
assert_eq!(
row,
"| 002 | segment_002.md | 9 | 1234 | \"Foo\", \"bar_baz\" |\n"
);
assert_eq!(row.matches('\n').count(), 1);
}
/// Filename ⇄ index round-trips through the flat `segment_NNN.md` name.
#[test]
fn segment_filename_round_trips() {
assert_eq!(segment_filename(5), "segment_005.md");
assert_eq!(parse_segment_index("segment_005.md"), Some(5));
assert_eq!(parse_segment_index("segment_005"), None);
assert_eq!(parse_segment_index("notes.md"), None);
}
/// Store artifacts map to their kind (relative reads included); non-artifacts
/// — even other files under `compaction/` — don't.
#[test]
fn classify_compaction_path_maps_store_artifacts() {
use CompactionArtifact::*;
assert_eq!(
classify_compaction_path("/u/abc/compaction/segment_007.md"),
Some(Segment(7))
);
// Relative read still matches (the substring-anchor behavior).
assert_eq!(
classify_compaction_path("compaction/segment_012.md"),
Some(Segment(12))
);
assert_eq!(
classify_compaction_path("/u/abc/compaction/INDEX.md"),
Some(Index)
);
assert_eq!(classify_compaction_path("/u/abc/compaction"), Some(Dir));
// Not store artifacts — including other files under `compaction/`.
assert_eq!(classify_compaction_path("/repo/src/main.rs"), None);
assert_eq!(classify_compaction_path("compaction/notes.md"), None);
}
// --- Parity with the Python implementation's own test vectors (compaction_utils_test.py) ---
/// Keyword extraction: the Python `TestExtractKeywords` vectors (bare `8.`
/// headers, stopword filtering, dedup, no-section-8 fallback) plus our
/// `## 8.` markdown-header tolerance and out-of-section exclusion.
#[test]
fn extract_keywords_matches_python_vectors_and_markdown_headers() {
let kw = extract_keywords(
"1. Primary Request: ...\n8. Current Work: Just refactored AuthMiddleware in \
handler.py and updated RedisCache integration.\n9. Next Step: ...\n",
);
assert!(kw.iter().any(|k| k == "AuthMiddleware") && kw.iter().any(|k| k == "RedisCache"));
// No section 8 ⇒ fall back to the whole summary.
let kw = extract_keywords("Worked on PostgresAdapter and JwtRefresh.");
assert!(kw.iter().any(|k| k == "PostgresAdapter") && kw.iter().any(|k| k == "JwtRefresh"));
// All-stopword section ⇒ empty; duplicates collapse to one.
assert!(
extract_keywords("8. Current Work: section summary technical concepts.\n").is_empty()
);
let kw = extract_keywords("8. Current Work: SameName SameName Other.\n");
assert_eq!(kw.iter().filter(|k| *k == "SameName").count(), 1);
// Our `## N.` markdown headers: scope to section 8, exclude outside words.
let kw = extract_keywords(
"## 1. Intro\nGenericWord\n\n## 8. Current Work\nEditing CompactionMode here.\n\n\
## 9. Next\nUnrelatedThing",
);
assert!(kw.iter().any(|k| k == "CompactionMode"));
assert!(
!kw.iter()
.any(|k| k == "GenericWord" || k == "UnrelatedThing")
);
}
/// Mirrors `TestComputeTurnStats::test_basic_counts` — same turns, same
/// role/tool/file/error stats (roles mapped User→Human, ToolResult→Function).
#[test]
fn parity_turn_stats_matches_basic_counts() {
use xai_grok_sampling_types::{AssistantItem, ToolCall};
let tc = |name: &str, args: &str| ToolCall {
id: "t".into(),
name: name.to_string(),
arguments: args.into(),
};
let items = vec![
user("Fix the bug"),
ConversationItem::Assistant(AssistantItem {
content: "Done".into(),
tool_calls: vec![
tc("read_file", r#"{"target_file":"src/a.py"}"#),
tc("read_file", r#"{"target_file":"src/b.py"}"#),
tc("grep", r#"{"pattern":"x","path":"src/"}"#),
],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::tool_result("c", "file contents"),
];
let s = compute_turn_stats(&items);
assert_eq!(s.turn_count, 3);
assert_eq!(
s.role_counts,
vec![("Assistant", 1), ("Function", 1), ("Human", 1)]
);
// Descending count, name tie-break.
assert_eq!(
s.tool_counts,
vec![("read_file".to_string(), 2), ("grep".to_string(), 1)]
);
assert_eq!(s.unique_files, vec!["src/", "src/a.py", "src/b.py"]);
assert_eq!(s.tool_error_count, 0);
}
/// Mirrors `test_error_counting` + `test_last_assistant_excerpt`.
#[test]
fn parity_turn_stats_errors_and_excerpt() {
let errs = vec![
ConversationItem::tool_result("a", "Error: not found"),
ConversationItem::tool_result("c", "Failed tool validation: foo"),
ConversationItem::tool_result("e", "success"),
];
assert_eq!(compute_turn_stats(&errs).tool_error_count, 2);
let conv = vec![
ConversationItem::assistant("early"),
user("middle"),
ConversationItem::assistant("the final answer is 42"),
];
assert!(
compute_turn_stats(&conv)
.last_assistant_excerpt
.contains("the final answer is 42")
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,117 @@
//! Pure conversation-shape helpers, kept crate-neutral so both the session
//! layer (`xai-grok-shell`) and the `ChatStateActor` can share one definition
//! of "align the leading System message with a prompt".
use std::sync::Arc;
use xai_grok_sampling_types::conversation::ConversationItem;
/// Equal after trimming trailing `\n`/`\r` from both sides. Used for attach
/// idempotency so a stored head that differs from a client override only by a
/// trailing newline is treated as already matching (cache-friendly no-op).
/// Interior and leading whitespace are significant.
pub fn canonical_system_prompt_eq(a: &str, b: &str) -> bool {
a.trim_end_matches(['\n', '\r']) == b.trim_end_matches(['\n', '\r'])
}
/// Replace the leading `System` message with `prompt`, or insert one at the head
/// if the conversation has no leading `System`. Returns whether the conversation
/// changed; a head already equal to `prompt` (modulo trailing newlines) is left
/// untouched for KV-cache-friendly idempotency.
///
/// Single source of truth for the "align System[0] with the client override"
/// operation, shared by the cold-load pre-apply (on a loaded history `Vec`,
/// before spawn persists it) and the atomic `ChatStateActor` head swap that
/// backs the resident-reconnect path.
#[must_use]
pub fn replace_or_insert_system_head(
conversation: &mut Vec<ConversationItem>,
prompt: &str,
) -> bool {
match conversation.first_mut() {
Some(ConversationItem::System(sys)) => {
if canonical_system_prompt_eq(sys.content.as_ref(), prompt) {
return false;
}
sys.content = Arc::from(prompt);
true
}
_ => {
conversation.insert(0, ConversationItem::system(prompt));
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn system_prompt(conversation: &[ConversationItem]) -> Option<&str> {
conversation.first().and_then(|item| match item {
ConversationItem::System(s) => Some(s.content.as_ref()),
_ => None,
})
}
#[test]
fn canonical_system_prompt_eq_ignores_trailing_newlines() {
assert!(canonical_system_prompt_eq("hello\n", "hello"));
assert!(canonical_system_prompt_eq("hello\r\n", "hello"));
assert!(!canonical_system_prompt_eq("hello", "world"));
}
#[test]
fn canonical_system_prompt_eq_respects_interior_and_leading_whitespace() {
assert!(canonical_system_prompt_eq("a\nb\n", "a\nb"));
assert!(!canonical_system_prompt_eq("a\nb", "ab"));
assert!(!canonical_system_prompt_eq(" hello", "hello"));
}
#[test]
fn replace_or_insert_system_head_replaces_stored_head() {
let mut history = vec![
ConversationItem::system("default system prompt"),
ConversationItem::user("hi"),
];
assert!(replace_or_insert_system_head(
&mut history,
"client override"
));
assert_eq!(system_prompt(&history), Some("client override"));
assert_eq!(history.len(), 2, "must not wipe user turns");
}
#[test]
fn replace_or_insert_system_head_noop_when_unchanged() {
let mut history = vec![
ConversationItem::system("same prompt"),
ConversationItem::user("hi"),
];
assert!(!replace_or_insert_system_head(
&mut history,
"same prompt\n"
));
}
#[test]
fn replace_or_insert_system_head_inserts_when_first_is_not_system() {
let mut history = vec![ConversationItem::user("hi")];
assert!(replace_or_insert_system_head(
&mut history,
"client override"
));
assert_eq!(system_prompt(&history), Some("client override"));
assert_eq!(history.len(), 2, "inserts at head, keeps existing turns");
}
#[test]
fn replace_or_insert_system_head_inserts_into_empty() {
let mut history: Vec<ConversationItem> = vec![];
assert!(replace_or_insert_system_head(
&mut history,
"client override"
));
assert_eq!(system_prompt(&history), Some("client override"));
}
}

View file

@ -0,0 +1,52 @@
//! Events emitted by the ChatStateActor.
/// Events emitted by the ChatStateActor to the session main loop.
///
/// Persistence is handled internally by the actor — these events are for
/// session-level coordination only.
#[derive(Debug, Clone)]
pub enum ChatStateEvent {
/// Prompt index changed (session uses this to update hunk tracker attribution).
PromptIndexChanged { new_index: usize },
/// Token count updated (session uses this for notification meta,
/// auto-compact threshold checks).
TokensUpdated { total_tokens: u64 },
/// Conversation was replaced (compaction/rewind) — session may need to
/// reset idle-flush counters, memory injection flags, etc.
ConversationReset { new_len: usize },
/// Image byte-budget record for a built request (observability only,
/// emitted on image-bearing turns). The session consumer writes this to
/// the local unified log for verification. `evicted == 0` means the body
/// was under the trigger and every image was kept.
ImageBudget {
/// Exact serialized conversation body size measured for the gate.
body_bytes: usize,
/// Threshold at which eviction fires.
trigger_bytes: usize,
/// Low-water mark eviction reclaims down to once it fires.
reclaim_target_bytes: usize,
/// Inline images present before eviction.
inline_images: usize,
/// Whether the body crossed the trigger this turn.
needs_image_compaction: bool,
/// Images replaced with a placeholder this turn.
evicted: usize,
/// Estimated body size after eviction (== `body_bytes` when none).
body_bytes_after: usize,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_variants_are_constructible() {
let _ = ChatStateEvent::PromptIndexChanged { new_index: 1 };
let _ = ChatStateEvent::TokensUpdated { total_tokens: 500 };
let _ = ChatStateEvent::ConversationReset { new_len: 3 };
}
}

View file

@ -0,0 +1,641 @@
//! Handle to communicate with ChatStateActor.
use std::collections::BTreeSet;
use tokio::sync::{mpsc, oneshot};
use xai_grok_sampling_types::{
ConversationItem, ConversationRequest, DanglingToolCallReason, SamplingConfig, TokenUsage,
ToolSpec, TraceContext,
};
use crate::commands::{ChatStateCommand, RepairHistoryBlocked};
use crate::types::{
AutoCompactTrigger, ChatStateSnapshot, ConversationCounts, Credentials, NotificationMeta,
TurnCapture,
};
/// Handle to communicate with ChatStateActor.
/// This is cheap to clone and can be shared across tasks.
#[derive(Clone)]
pub struct ChatStateHandle {
cmd_tx: mpsc::UnboundedSender<ChatStateCommand>,
}
impl ChatStateHandle {
/// Create a new handle with the given command sender.
pub(crate) fn new(cmd_tx: mpsc::UnboundedSender<ChatStateCommand>) -> Self {
Self { cmd_tx }
}
/// Create a no-op handle that discards all commands.
/// Useful for tests and situations where chat state tracking is not needed.
pub fn noop() -> Self {
let (cmd_tx, _cmd_rx) = mpsc::unbounded_channel();
Self { cmd_tx }
}
// ═══ Fire-and-forget mutations ═══
/// Push a user message into the conversation.
pub fn push_user_message(&self, item: ConversationItem) {
let _ = self.cmd_tx.send(ChatStateCommand::PushUserMessage { item });
}
/// Push a user message and await acknowledgement that the chat-state actor
/// has accepted and processed it.
pub async fn push_user_message_and_ack(&self, item: ConversationItem) -> Option<()> {
self.query("PushUserMessageAndAck", |reply| {
ChatStateCommand::PushUserMessageAndAck { item, reply }
})
.await
}
/// Push a user message with an explicit dangling-repair reason.
pub fn push_user_message_with_repair_reason(
&self,
item: ConversationItem,
reason: DanglingToolCallReason,
) {
let _ = self
.cmd_tx
.send(ChatStateCommand::PushUserMessageWithRepairReason { item, reason });
}
/// Record the assistant's response.
pub fn push_assistant_response(&self, item: ConversationItem) {
let _ = self
.cmd_tx
.send(ChatStateCommand::PushAssistantResponse { item });
}
/// Record a tool result.
pub fn push_tool_result(&self, item: ConversationItem) {
let _ = self.cmd_tx.send(ChatStateCommand::PushToolResult { item });
}
/// Record accumulated token usage.
pub fn record_token_usage(&self, total_tokens: u64) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RecordTokenUsage { total_tokens });
}
/// Stash the per-turn `TokenUsage` from the most recent model response.
/// Fire-and-forget — no ack returned.
pub fn record_last_turn_usage(&self, usage: TokenUsage) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RecordLastTurnUsage { usage });
}
pub fn record_model_call_usage(
&self,
model_id: Option<String>,
usage: TokenUsage,
api_duration_ms: Option<u64>,
cost_usd_ticks: Option<i64>,
) {
let _ = self.cmd_tx.send(ChatStateCommand::RecordModelCallUsage {
model_id,
usage,
api_duration_ms,
cost_usd_ticks,
});
}
/// Apply subagent usage; returns false if the actor did not acknowledge.
pub async fn record_subagent_usage(
&self,
by_model: Vec<(String, crate::usage::UsageTotals)>,
attribute_to_prompt: bool,
incomplete: bool,
) -> bool {
self.query("RecordSubagentUsage", |reply| {
ChatStateCommand::RecordSubagentUsage {
by_model,
attribute_to_prompt,
incomplete,
reply,
}
})
.await
.is_some()
}
/// Mark open prompt and/or session ledgers incomplete.
pub async fn mark_usage_incomplete(&self, prompt: bool, session: bool) -> bool {
self.query("MarkUsageIncomplete", |reply| {
ChatStateCommand::MarkUsageIncomplete {
prompt,
session,
reply,
}
})
.await
.is_some()
}
/// Increment prompt index (called at start of each user turn).
pub fn increment_prompt_index(&self) {
let _ = self.cmd_tx.send(ChatStateCommand::IncrementPromptIndex);
}
/// Update the sampling config (e.g., model switch).
pub fn update_sampling_config(&self, config: SamplingConfig) {
let _ = self
.cmd_tx
.send(ChatStateCommand::UpdateSamplingConfig { config });
}
/// Track that the agent edited a file path.
pub fn record_agent_edited_path(&self, path: String) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RecordAgentEditedPath { path });
}
/// Record stream timing metadata.
pub fn record_stream_start(&self, timestamp_ms: i64) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RecordStreamStart { timestamp_ms });
}
/// Record turn timing metadata.
pub fn record_turn_start(&self, timestamp_ms: i64) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RecordTurnStart { timestamp_ms });
}
/// Replace conversation history.
pub fn replace_conversation(&self, items: Vec<ConversationItem>) {
self.send_replace(items, false);
}
/// Replace conversation history for compaction.
/// Sets `compaction_occurred` on the active turn capture.
pub fn replace_conversation_for_compaction(&self, items: Vec<ConversationItem>) {
self.send_replace(items, true);
}
fn send_replace(&self, items: Vec<ConversationItem>, is_compaction: bool) {
let _ = self.cmd_tx.send(ChatStateCommand::ReplaceConversation {
items,
is_compaction,
});
}
/// Out-of-band history repair (`x.ai/session/repair`); see
/// [`ChatStateCommand::RepairHistory`]. Returns `None` if the actor is
/// dead, `Some(Err(_))` if a turn was in flight at processing time.
pub async fn repair_history(
&self,
dry_run: bool,
turn_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Option<Result<crate::compaction_utils::HistoryRepairReport, RepairHistoryBlocked>> {
self.query("RepairHistory", |reply| ChatStateCommand::RepairHistory {
dry_run,
turn_active,
reply,
})
.await
}
/// Atomically align the leading `System` message with `prompt` (insert one
/// if absent), persisting when changed. Serializes with turn pushes inside
/// the actor, so a mid-turn reconnect can't drop concurrent updates.
/// Returns `Some(changed)`, or `None` if the actor is dead.
pub async fn replace_system_head(&self, prompt: &str) -> Option<bool> {
let prompt = prompt.to_owned();
self.query("ReplaceSystemHead", |reply| {
ChatStateCommand::ReplaceSystemHead { prompt, reply }
})
.await
}
/// Cache prompt text for rewind preview.
pub fn cache_prompt_text(&self, text: String) {
let _ = self.cmd_tx.send(ChatStateCommand::CachePromptText { text });
}
/// Record compaction boundary for rewind.
pub fn record_compaction_at(&self, prompt_index: usize) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RecordCompactionAt { prompt_index });
}
/// Flush pending persistence writes to disk.
pub fn flush(&self) {
let _ = self.cmd_tx.send(ChatStateCommand::Flush);
}
/// Update opaque credential secrets held by the actor.
pub fn update_credentials(&self, credentials: Credentials) {
let _ = self
.cmd_tx
.send(ChatStateCommand::UpdateCredentials { credentials });
}
/// Restore from a snapshot.
pub fn restore_snapshot(&self, snapshot: ChatStateSnapshot) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RestoreSnapshot(Box::new(snapshot)));
}
/// Begin capturing turn messages. Call at the start of a real user turn
/// (in `handle_prompt`), before `push_user_message`.
pub fn begin_turn_capture(&self) {
let _ = self.cmd_tx.send(ChatStateCommand::BeginTurnCapture);
}
/// Append synthetic `task` pairs for a harness-spawned subagent (goal
/// planner / verifier skeptic) to the in-progress harness trace phase. They
/// are sealed into a standalone trace turn by [`Self::flush_harness_trace_turn`]
/// and never enter the live `conversation` sent to the model. No-op on
/// empty input.
pub fn append_harness_trace_items(&self, items: Vec<ConversationItem>) {
if items.is_empty() {
return;
}
let _ = self
.cmd_tx
.send(ChatStateCommand::AppendHarnessTraceItems { items });
}
/// Seal the harness items accumulated since the last flush into one trace
/// turn. Call once per harness phase (after the planner, after a verifier
/// panel) so each phase becomes its own uploaded `turn_{N}` artifact. No-op
/// when nothing was recorded since the last flush.
pub fn flush_harness_trace_turn(&self) {
let _ = self.cmd_tx.send(ChatStateCommand::FlushHarnessTraceTurn);
}
/// Repair dangling tool calls after a harness-initiated halt.
pub fn repair_dangling_after_harness_halt(&self, class: &'static str) {
let _ = self
.cmd_tx
.send(ChatStateCommand::RepairDanglingAfterHarnessHalt { class });
}
// ═══ Async queries (via oneshot) ═══
/// Send a query to the actor and await the reply.
///
/// Returns `None` when the actor is dead (channel send failure or reply
/// dropped due to panic/cancellation). Both failure modes are logged at
/// `error` level with `cmd_name` for post-mortem diagnostics.
async fn query<T>(
&self,
cmd_name: &str,
make_cmd: impl FnOnce(oneshot::Sender<T>) -> ChatStateCommand,
) -> Option<T> {
let (tx, rx) = oneshot::channel();
if self.cmd_tx.send(make_cmd(tx)).is_err() {
tracing::error!(cmd_name, "ChatStateActor dead: send failed");
return None;
}
match rx.await {
Ok(v) => Some(v),
Err(_) => {
tracing::error!(cmd_name, "ChatStateActor dead: reply dropped");
None
}
}
}
/// Build a ConversationRequest from the current state.
/// Prunes, repairs, injects memory, and returns a ready-to-send request.
pub async fn build_request(
&self,
tool_definitions: Vec<ToolSpec>,
memory_reminder: Option<String>,
persist_memory_reminder: bool,
trace: Option<Box<dyn TraceContext>>,
conv_id: String,
req_id: String,
) -> Option<ConversationRequest> {
self.query("BuildConversationRequest", |reply| {
ChatStateCommand::BuildConversationRequest {
tool_definitions,
memory_reminder,
persist_memory_reminder,
trace,
conv_id,
req_id,
reply,
}
})
.await
}
/// Get a clone of the full conversation.
pub async fn get_conversation(&self) -> Vec<ConversationItem> {
self.query("GetConversation", |reply| {
ChatStateCommand::GetConversation { reply }
})
.await
.unwrap_or_default()
}
/// Get current prompt index.
pub async fn get_prompt_index(&self) -> usize {
self.query("GetPromptIndex", |reply| ChatStateCommand::GetPromptIndex {
reply,
})
.await
.unwrap_or(0)
}
/// Get the prompt index at which the last compaction occurred.
/// `Some` means the context currently holds a compaction summary.
pub async fn get_last_compaction_prompt_index(&self) -> Option<usize> {
self.query("GetLastCompactionPromptIndex", |reply| {
ChatStateCommand::GetLastCompactionPromptIndex { reply }
})
.await
.flatten()
}
/// Get total accumulated tokens.
pub async fn get_total_tokens(&self) -> u64 {
self.query("GetTotalTokens", |reply| ChatStateCommand::GetTotalTokens {
reply,
})
.await
.unwrap_or(0)
}
/// Retrieve the most recent stashed per-turn `TokenUsage`. Returns
/// `None` if no model turn has completed in this session yet, or if
/// the actor channel is closed.
pub async fn get_last_turn_usage(&self) -> Option<TokenUsage> {
self.query("GetLastTurnUsage", |reply| {
ChatStateCommand::GetLastTurnUsage { reply }
})
.await
.flatten()
}
/// Fail-closed prompt bill read.
/// `Ok(None)` means the actor answered "no ledger"; `Err(())` means it did
/// not answer at all. Never collapse `Err` to `None`: an unreadable bill
/// must not be mistaken for a free prompt.
pub async fn try_get_prompt_usage(&self) -> Result<Option<crate::usage::UsageLedger>, ()> {
self.query("GetPromptUsage", |reply| ChatStateCommand::GetPromptUsage {
reply,
})
.await
.ok_or(())
}
/// Fail-closed session bill read. `Err(())` if the actor is dead.
pub async fn try_get_session_usage(&self) -> Result<crate::usage::UsageLedger, ()> {
self.query("GetSessionUsage", |reply| {
ChatStateCommand::GetSessionUsage { reply }
})
.await
.ok_or(())
}
/// `total_tokens` plus bytes/4 estimate of tool results pushed since the
/// last model response. Used by `check_preflight_overflow`.
pub async fn get_estimated_total_tokens(&self) -> u64 {
self.query("GetEstimatedTotalTokens", |reply| {
ChatStateCommand::GetEstimatedTotalTokens { reply }
})
.await
.unwrap_or(0)
}
/// Bytes/4 estimate of all non-system conversation items.
pub async fn get_estimated_messages_tokens(&self) -> u64 {
self.query("GetEstimatedMessagesTokens", |reply| {
ChatStateCommand::GetEstimatedMessagesTokens { reply }
})
.await
.unwrap_or(0)
}
/// Get sampling config.
pub async fn get_sampling_config(&self) -> Option<SamplingConfig> {
self.query("GetSamplingConfig", |reply| {
ChatStateCommand::GetSamplingConfig { reply }
})
.await
}
/// Get the set of agent-edited file paths.
pub async fn get_agent_edited_paths(&self) -> BTreeSet<String> {
self.query("GetAgentEditedPaths", |reply| {
ChatStateCommand::GetAgentEditedPaths { reply }
})
.await
.unwrap_or_default()
}
/// Get notification meta (timing info).
pub async fn get_notification_meta(&self) -> Option<NotificationMeta> {
self.query("GetNotificationMeta", |reply| {
ChatStateCommand::GetNotificationMeta { reply }
})
.await
}
/// Snapshot state for forking or rewind.
pub async fn snapshot(&self) -> Option<ChatStateSnapshot> {
self.query("Snapshot", |reply| ChatStateCommand::Snapshot { reply })
.await
}
/// Truncate conversation to a target prompt index (for rewind).
pub async fn truncate_to_prompt_index(&self, target: usize) {
self.query("TruncateToPromptIndex", |reply| {
ChatStateCommand::TruncateToPromptIndex {
target_prompt_index: target,
reply,
}
})
.await;
}
/// Get credential secrets.
pub async fn get_credentials(&self) -> Credentials {
self.query("GetCredentials", |reply| ChatStateCommand::GetCredentials {
reply,
})
.await
.unwrap_or_default()
}
pub async fn get_last_model_metadata(&self) -> crate::commands::ModelMetadata {
self.query("GetLastModelMetadata", |reply| {
ChatStateCommand::GetLastModelMetadata { reply }
})
.await
.unwrap_or_default()
}
/// Take the accumulated turn messages and end the capture.
/// Returns `None` if no capture was active.
pub async fn take_turn_messages(&self) -> Option<TurnCapture> {
self.query("TakeTurnMessages", |reply| {
ChatStateCommand::TakeTurnMessages { reply }
})
.await
.flatten()
}
/// Drain the sealed harness trace turns (goal planner + verifier panels).
/// Each returned `Vec` is one turn's worth of synthetic `task` pairs,
/// destined to be uploaded as its own sibling `turn_{N}` artifact. A
/// trailing un-flushed accumulator is sealed defensively before draining.
/// Returns empty when nothing was recorded (the common, non-goal case).
pub async fn take_harness_trace_turns(&self) -> Vec<Vec<ConversationItem>> {
self.query("TakeHarnessTraceTurns", |reply| {
ChatStateCommand::TakeHarnessTraceTurns { reply }
})
.await
.unwrap_or_default()
}
/// Check if auto-compact is needed.
pub async fn check_auto_compact_needed(
&self,
threshold_percent: u8,
) -> Option<AutoCompactTrigger> {
self.query("CheckAutoCompactNeeded", |reply| {
ChatStateCommand::CheckAutoCompactNeeded {
threshold_percent,
reply,
}
})
.await
.flatten()
}
// ═══ Narrow targeted queries ═══
/// Get the number of items in the conversation.
///
/// Cheaper than [`get_conversation`] when only the length is needed —
/// the actor returns a single `usize` without cloning any items.
pub async fn get_conversation_len(&self) -> usize {
self.query("GetConversationLen", |reply| {
ChatStateCommand::GetConversationLen { reply }
})
.await
.unwrap_or(0)
}
/// Whether any assistant tool call lacks a matching `ToolResult` (the
/// dangling-tool-call repair would fire on the next request build).
///
/// Returns `false` if the actor is dead. Cheaper than [`get_conversation`]
/// — the actor scans in place and returns a single `bool`.
pub async fn has_dangling_tool_calls(&self) -> bool {
self.query("HasDanglingToolCalls", |reply| {
ChatStateCommand::HasDanglingToolCalls { reply }
})
.await
.unwrap_or(false)
}
/// Get the text content of the last assistant message with non-empty text.
///
/// Returns `None` if no such message exists or the actor is dead.
/// Cheaper than [`get_conversation`] when only the final assistant
/// response text is needed.
pub async fn get_last_assistant_text(&self) -> Option<String> {
self.query("GetLastAssistantText", |reply| {
ChatStateCommand::GetLastAssistantText { reply }
})
.await
.flatten()
}
/// Get the text of the first `Text` content part in the first `User` message.
///
/// Returns `None` if no user message with text content exists or the actor
/// is dead. Cheaper than [`get_conversation`] when only the initial user
/// query text is needed (e.g. for memory context search).
pub async fn get_first_user_text(&self) -> Option<String> {
self.query("GetFirstUserText", |reply| {
ChatStateCommand::GetFirstUserText { reply }
})
.await
.flatten()
}
/// Get a single conversation item by index (0-based).
///
/// Returns `None` if the index is out of bounds or the actor is dead.
/// Cheaper than [`get_conversation`] when only one specific item is needed
/// (e.g. item[1] for the original user-info block after compaction).
pub async fn get_conversation_item_at(&self, index: usize) -> Option<ConversationItem> {
self.query("GetConversationItemAt", |reply| {
ChatStateCommand::GetConversationItemAt { index, reply }
})
.await
.flatten()
}
/// Get the processed text of the last user query (metadata tags stripped).
///
/// Equivalent to `extract_last_user_query(&full_conv)` but without cloning
/// the full conversation. Returns `None` if there are no user messages or
/// the last user message is empty after processing.
pub async fn get_last_user_query_text(&self) -> Option<String> {
self.query("GetLastUserQueryText", |reply| {
ChatStateCommand::GetLastUserQueryText { reply }
})
.await
.flatten()
}
/// Get item counts for the conversation by role.
///
/// Returns a [`ConversationCounts`] struct without cloning any items.
/// Suitable for telemetry / logging that only needs totals.
pub async fn get_conversation_counts(&self) -> ConversationCounts {
self.query("GetConversationCounts", |reply| {
ChatStateCommand::GetConversationCounts { reply }
})
.await
.unwrap_or_default()
}
/// Get the first `System` message in the conversation, if any.
///
/// Cheaper than [`get_conversation`] when only the system prompt is needed
/// (e.g. for compaction setup or error validation).
pub async fn get_system_message(&self) -> Option<ConversationItem> {
self.query("GetSystemMessage", |reply| {
ChatStateCommand::GetSystemMessage { reply }
})
.await
.flatten()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noop_handle_does_not_panic() {
let handle = ChatStateHandle::noop();
handle.push_user_message(ConversationItem::user("test"));
handle.flush();
drop(handle);
}
#[test]
fn handle_is_clone() {
let handle = ChatStateHandle::noop();
let clone = handle.clone();
clone.push_user_message(ConversationItem::user("from clone"));
}
}

View file

@ -0,0 +1,55 @@
//! xai-chat-state — Actor-based chat state management for xAI agents.
//!
//! This crate extracts conversation state management from `xai-grok-shell`'s
//! `acp_session.rs` into a standalone actor. It follows the same actor pattern
//! as `xai-hunk-tracker`:
//!
//! ```text
//! ┌────────────────┐ ┌──────────────────────────────────────┐
//! │ SessionActor │ ─── Command ───▶ │ ChatStateActor │
//! │ (push_user, │ │ (runs in dedicated tokio task) │
//! │ build_req) │ │ │
//! └────────────────┘ │ State (no locks needed): │
//! │ - conversation: Vec<ConversationItem>│
//! ┌────────────────┐ │ - sampling_config: SamplingConfig │
//! │ Query (e.g. │ ── Cmd+Oneshot ─▶│ - prompt_index: usize │
//! │ get_conv) │ ◀── Response ────│ - total_tokens: u64 │
//! └────────────────┘ │ │
//! │ │ ChatStateEvent │
//! │ ▼ │
//! │ ┌──────────────────┐ │
//! │ │ event_tx │───▶ Session │
//! │ └──────────────────┘ │
//! └──────────────────────────────────────┘
//! ```
pub mod actor;
pub mod commands;
pub mod compaction_mode;
pub mod compaction_transcript;
pub mod compaction_utils;
pub mod conversation_util;
pub mod events;
pub mod handle;
pub mod persistence;
pub mod types;
pub mod usage;
// Re-export main types for convenience
pub use actor::ChatStateActor;
pub use actor::state::{
estimate_conversation_tokens, estimate_item_tokens, estimate_messages_tokens,
estimate_system_message_tokens, estimate_tool_definition_tokens,
estimate_tool_definitions_tokens,
};
pub use commands::ModelMetadata;
pub use compaction_mode::CompactionMode;
pub use compaction_transcript::CompactionDetail;
pub use events::ChatStateEvent;
pub use handle::ChatStateHandle;
pub use persistence::{
ChatPersistence, MockChatPersistence, MockPersistenceReceiver, NullChatPersistence,
PersistenceRecord,
};
pub use types::*;
pub use usage::{UsageLedger, UsageTotals};

View file

@ -0,0 +1,173 @@
//! Chat persistence trait and mock implementation.
//!
//! The actor owns persistence exclusively (`Box<dyn ChatPersistence>`), so the
//! trait uses `&mut self` — no locks, no atomics, no shared state.
//! The mock uses a channel to report records to the test, keeping everything
//! in the actor / message-passing paradigm.
use tokio::sync::mpsc;
use xai_grok_sampling_types::ConversationItem;
/// Abstraction over chat-specific persistence operations.
///
/// The actor owns this exclusively via `Box<dyn ChatPersistence>`, so all
/// methods take `&mut self` — no interior mutability needed.
///
/// The real implementation wraps an `mpsc::UnboundedSender<PersistenceMsg>`
/// (which only needs `&self` to send, but `&mut self` is still correct
/// because the actor is the sole owner).
pub trait ChatPersistence: Send + 'static {
/// Persist a single conversation item (append to chat_history.jsonl).
fn persist_message(&mut self, item: &ConversationItem);
/// Replace the entire chat history (compaction / rewind).
fn replace_history(&mut self, items: &[ConversationItem]);
/// Flush pending writes to disk.
fn flush(&mut self);
}
// ============================================================================
// Mock (test double) — channel-based, no locks, no atomics
// ============================================================================
/// A record of a persistence call, sent over a channel to the test.
#[derive(Debug, Clone)]
pub enum PersistenceRecord {
/// A single message was persisted.
Message(ConversationItem),
/// The full history was replaced.
ReplaceHistory(Vec<ConversationItem>),
/// A flush was requested.
Flush,
}
/// Test implementation: sends every call as a [`PersistenceRecord`] over a
/// channel. The test holds the [`MockPersistenceReceiver`] to inspect what
/// the actor did. No locks, no atomics — just message passing.
pub struct MockChatPersistence {
tx: mpsc::UnboundedSender<PersistenceRecord>,
}
/// Receiver side of the mock. Held by the test to drain and inspect records.
pub struct MockPersistenceReceiver {
rx: mpsc::UnboundedReceiver<PersistenceRecord>,
}
impl MockChatPersistence {
/// Create a paired (mock, receiver). Give the mock to the actor, keep the
/// receiver in the test.
pub fn new() -> (Self, MockPersistenceReceiver) {
let (tx, rx) = mpsc::unbounded_channel();
(Self { tx }, MockPersistenceReceiver { rx })
}
}
impl MockPersistenceReceiver {
/// Drain all pending records from the channel.
pub fn drain(&mut self) -> Vec<PersistenceRecord> {
let mut records = Vec::new();
while let Ok(record) = self.rx.try_recv() {
records.push(record);
}
records
}
/// Collect all `Message` items received so far (drains the channel).
pub fn messages(&mut self) -> Vec<ConversationItem> {
self.drain()
.into_iter()
.filter_map(|r| match r {
PersistenceRecord::Message(item) => Some(item),
_ => None,
})
.collect()
}
}
impl ChatPersistence for MockChatPersistence {
fn persist_message(&mut self, item: &ConversationItem) {
let _ = self.tx.send(PersistenceRecord::Message(item.clone()));
}
fn replace_history(&mut self, items: &[ConversationItem]) {
let _ = self
.tx
.send(PersistenceRecord::ReplaceHistory(items.to_vec()));
}
fn flush(&mut self) {
let _ = self.tx.send(PersistenceRecord::Flush);
}
}
// ============================================================================
// Null (noop) — for benchmarks / scenarios where persistence is unwanted
// ============================================================================
/// No-op implementation: discards everything (for benchmarks / noop scenarios).
pub struct NullChatPersistence;
impl ChatPersistence for NullChatPersistence {
fn persist_message(&mut self, _item: &ConversationItem) {}
fn replace_history(&mut self, _items: &[ConversationItem]) {}
fn flush(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_persistence_records_messages() {
let (mut mock, mut rx) = MockChatPersistence::new();
let item = ConversationItem::system("test");
mock.persist_message(&item);
let records = rx.drain();
assert_eq!(records.len(), 1);
assert!(matches!(&records[0], PersistenceRecord::Message(_)));
}
#[test]
fn mock_persistence_records_multiple_messages() {
let (mut mock, mut rx) = MockChatPersistence::new();
mock.persist_message(&ConversationItem::system("a"));
mock.persist_message(&ConversationItem::user("b"));
mock.persist_message(&ConversationItem::assistant("c"));
assert_eq!(rx.messages().len(), 3);
}
#[test]
fn mock_persistence_records_replace_history() {
let (mut mock, mut rx) = MockChatPersistence::new();
mock.replace_history(&[ConversationItem::system("a"), ConversationItem::system("b")]);
let records = rx.drain();
assert_eq!(records.len(), 1);
match &records[0] {
PersistenceRecord::ReplaceHistory(items) => assert_eq!(items.len(), 2),
other => panic!("expected ReplaceHistory, got {other:?}"),
}
}
#[test]
fn mock_persistence_records_flush() {
let (mut mock, mut rx) = MockChatPersistence::new();
mock.flush();
mock.flush();
let records = rx.drain();
assert_eq!(records.len(), 2);
assert!(
records
.iter()
.all(|r| matches!(r, PersistenceRecord::Flush))
);
}
#[test]
fn null_persistence_does_not_panic() {
let mut null = NullChatPersistence;
null.persist_message(&ConversationItem::system("test"));
null.replace_history(&[ConversationItem::user("a")]);
null.flush();
}
}

View file

@ -0,0 +1,257 @@
//! Shared domain types for the chat state actor.
use std::collections::BTreeSet;
use std::num::NonZeroU64;
use serde::{Deserialize, Serialize};
use xai_grok_sampling_types::{ConversationItem, SamplingConfig};
/// Canonical marker for an injected memory-context block. Shared by the
/// emitter in `xai-grok-shell` and the upsert/detection here — a drift would
/// silently break dedup and let blocks accumulate in the prompt prefix.
/// Detection assumes the literal never appears in a system prompt except as
/// an injected block.
pub const MEMORY_CONTEXT_OPEN_TAG: &str = "<memory-context>";
/// Closing tag paired with [`MEMORY_CONTEXT_OPEN_TAG`].
pub const MEMORY_CONTEXT_CLOSE_TAG: &str = "</memory-context>";
/// Configuration for the ChatStateActor at spawn time.
#[derive(Debug, Clone)]
pub struct ChatStateConfig {
/// Initial conversation items to populate the state with.
pub initial_conversation: Vec<ConversationItem>,
/// Sampling configuration (model, context window, etc.).
pub sampling_config: SamplingConfig,
}
/// Immutable snapshot of the actor's state (for forking, rewind).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatStateSnapshot {
/// The full conversation history.
pub conversation: Vec<ConversationItem>,
/// Current sampling configuration.
pub sampling_config: SamplingConfig,
/// Current prompt index (incremented per user turn).
pub prompt_index: usize,
/// Accumulated token usage.
pub total_tokens: u64,
/// Bytes/4 estimate of the conversation as of the last `record_token_usage`.
/// `0` means unknown (pre-field snapshot); restore re-estimates instead.
#[serde(default)]
pub estimate_at_last_response: u64,
/// File paths the agent has edited.
pub agent_edited_paths: BTreeSet<String>,
/// Cached prompt texts for rewind preview.
pub prompt_texts: Vec<String>,
/// Timestamp when the current stream started (epoch ms).
pub stream_start_ms: Option<i64>,
/// Timestamp when the current turn started (epoch ms).
pub turn_start_ms: Option<i64>,
/// Prompt index at which the last compaction occurred.
pub last_compaction_prompt_index: Option<usize>,
/// Opaque credential secrets (API key, optional extra auth, client version).
#[serde(default)]
pub credentials: Credentials,
}
/// Metadata for session notifications (timing info).
#[derive(Debug, Clone)]
pub struct NotificationMeta {
/// Timestamp when the current stream started (epoch ms).
pub stream_start_ms: Option<i64>,
/// Timestamp when the current turn started (epoch ms).
pub turn_start_ms: Option<i64>,
}
/// Configuration for tool-result pruning.
///
/// Prunes old, large tool results from the conversation to reclaim context space.
/// Two modes: soft trim (keep head + tail) and hard clear (replace entirely).
#[derive(Debug, Clone)]
pub struct PruningConfig {
/// Whether pruning is enabled.
pub enabled: bool,
/// Number of recent turns whose tool results are never pruned.
pub keep_last_n_turns: usize,
/// Character threshold above which old tool results are soft-trimmed.
pub soft_trim_threshold: usize,
/// Characters to keep from the start of a soft-trimmed result.
pub soft_trim_head: usize,
/// Characters to keep from the end of a soft-trimmed result.
pub soft_trim_tail: usize,
/// Turn age after which tool results are hard-cleared (replaced with placeholder).
pub hard_clear_age_turns: usize,
}
impl Default for PruningConfig {
fn default() -> Self {
Self {
enabled: true,
keep_last_n_turns: 3,
soft_trim_threshold: 4000,
soft_trim_head: 1500,
soft_trim_tail: 1500,
hard_clear_age_turns: 10,
}
}
}
/// Where the session's current api_key came from.
/// Determines whether the key can be refreshed.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthType {
/// From AuthManager (grok login, OIDC, external binary). Refreshable.
#[default]
SessionToken,
/// From user config ([model.*] api_key, env_key, XAI_API_KEY). Not refreshable.
ApiKey,
}
/// Credential/secret fields that the actor stores opaquely.
///
/// These are fields from the shell's full `Config` that aren't part of
/// `xai_grok_sampling_types::SamplingConfig` (which is secret-free).
/// The actor just stores and returns them — it never interprets them.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Credentials {
/// API key for authentication.
pub api_key: Option<String>,
/// Whether this is a session token (refreshable) or user-provided api key.
#[serde(default)]
pub auth_type: AuthType,
/// Optional extra auth material forwarded with requests when present.
pub alpha_test_key: Option<String>,
/// Client version string.
pub client_version: Option<String>,
}
/// The messages captured during a single conversation turn.
///
/// Produced by `TakeTurnMessages` after a `BeginTurnCapture`/message-push cycle.
#[derive(Debug, Clone)]
pub struct TurnCapture {
/// The ordered sequence of messages appended during this turn.
pub messages: Vec<ConversationItem>,
/// Whether compaction (conversation replacement) occurred mid-turn.
pub compaction_occurred: bool,
}
/// Item counts for a conversation, broken down by role.
///
/// Returned by `get_conversation_counts()` — avoids cloning the conversation
/// when only role counts and total length are needed (e.g. for telemetry).
#[derive(Debug, Clone, Default)]
pub struct ConversationCounts {
/// Total number of items in the conversation.
pub total: usize,
/// Number of `User` items.
pub user: usize,
/// Number of `Assistant` items.
pub assistant: usize,
/// Number of `ToolResult` items.
pub tool_result: usize,
}
/// Info returned when auto-compact threshold is exceeded.
#[derive(Debug, Clone)]
pub struct AutoCompactTrigger {
/// Current total token count.
pub total_tokens: u64,
/// Model's context window size.
pub context_window: NonZeroU64,
/// Current utilization as a percentage (0100).
pub utilization_percent: u8,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_round_trips_through_serde_json() {
let snapshot = ChatStateSnapshot {
conversation: vec![],
sampling_config: SamplingConfig {
base_url: "https://api.example.com".to_string(),
model: "test-model".to_string(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
stream_tool_calls: None,
},
prompt_index: 0,
total_tokens: 0,
estimate_at_last_response: 0,
agent_edited_paths: BTreeSet::new(),
prompt_texts: vec![],
stream_start_ms: None,
turn_start_ms: None,
last_compaction_prompt_index: None,
credentials: Credentials::default(),
};
let json = serde_json::to_string(&snapshot).expect("serialize");
let deserialized: ChatStateSnapshot = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.prompt_index, 0);
assert_eq!(deserialized.total_tokens, 0);
assert!(deserialized.conversation.is_empty());
assert!(deserialized.agent_edited_paths.is_empty());
assert!(deserialized.last_compaction_prompt_index.is_none());
}
#[test]
fn snapshot_round_trips_with_data() {
use xai_grok_sampling_types::ConversationItem;
let snapshot = ChatStateSnapshot {
conversation: vec![
ConversationItem::system("You are a helpful assistant."),
ConversationItem::user("Hello!"),
ConversationItem::assistant("Hi there!"),
],
sampling_config: SamplingConfig {
base_url: "https://api.example.com".to_string(),
model: "grok-3".to_string(),
max_completion_tokens: Some(4096),
temperature: Some(0.7),
top_p: None,
api_backend: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
stream_tool_calls: None,
},
prompt_index: 5,
total_tokens: 1234,
estimate_at_last_response: 900,
agent_edited_paths: BTreeSet::from([
"src/main.rs".to_string(),
"src/lib.rs".to_string(),
]),
prompt_texts: vec!["first prompt".to_string(), "second prompt".to_string()],
stream_start_ms: Some(1234567890),
turn_start_ms: Some(1234567800),
last_compaction_prompt_index: Some(2),
credentials: Credentials::default(),
};
let json = serde_json::to_string(&snapshot).expect("serialize");
let deserialized: ChatStateSnapshot = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.prompt_index, 5);
assert_eq!(deserialized.total_tokens, 1234);
assert_eq!(deserialized.conversation.len(), 3);
assert_eq!(deserialized.agent_edited_paths.len(), 2);
assert_eq!(deserialized.prompt_texts.len(), 2);
assert_eq!(deserialized.stream_start_ms, Some(1234567890));
assert_eq!(deserialized.turn_start_ms, Some(1234567800));
assert_eq!(deserialized.last_compaction_prompt_index, Some(2));
}
}

View file

@ -0,0 +1,195 @@
//! Per-prompt and per-session billing ledgers (not serialized).
//!
//! `total_tokens()` is input + output: Responses wire `total` is live context
//! length. Compaction and other side calls never call `record_main_loop_call`.
//!
//! # Completeness ownership
//!
//! Wire incomplete is the OR of these stores (each has a distinct role):
//!
//! - **`UsageLedger.incomplete`** — durable on the bill snapshot. Set by nested
//! subagent incomplete fold, drain timeout, true apply-miss, and
//! `mark_usage_incomplete`. Monotonic for a ledger instance.
//! - **Sticky (`subagent_usage_not_applied` on the coordinator)** — pin-scoped
//! **report** signal (session-only attribution or apply-miss report). Not a
//! second token sink; does not stain ledgers by itself.
//! - **Foreground live IDs** — fold may still land; freeze drains ≤120s or fails
//! closed. Cancel skips multi-second drain (actor-loop safety).
//! - **Background live** — never waits; prompt report incomplete immediately;
//! spend still folds into the session ledger at completion (no session-ledger
//! incomplete).
//!
//! Freeze and cancel share one outcome policy: ledger marks only on fail-closed;
//! sticky and background_live are report-level only.
//!
//! Projection (`PromptUsage`) never invents tokens; it only ORs completeness
//! and scrubs costs when partial or incomplete.
use indexmap::IndexMap;
use xai_grok_sampling_types::TokenUsage;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageTotals {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_read_tokens: u64,
pub reasoning_tokens: u64,
pub model_calls: u64,
pub api_duration_ms: u64,
/// USD ticks (1e10 per USD). Absent when no call reported cost.
pub cost_usd_ticks: Option<i64>,
pub cost_missing_calls: u64,
}
impl UsageTotals {
fn from_call(
usage: &TokenUsage,
api_duration_ms: Option<u64>,
cost_usd_ticks: Option<i64>,
) -> Self {
let cost_usd_ticks = xai_grok_sampling_types::reported_cost_ticks(cost_usd_ticks);
Self {
input_tokens: u64::from(usage.prompt_tokens),
output_tokens: u64::from(usage.completion_tokens),
cached_read_tokens: u64::from(usage.cached_prompt_tokens),
reasoning_tokens: u64::from(usage.reasoning_tokens),
model_calls: 1,
api_duration_ms: api_duration_ms.unwrap_or(0),
cost_usd_ticks,
cost_missing_calls: u64::from(cost_usd_ticks.is_none()),
}
}
pub fn total_tokens(&self) -> u64 {
self.input_tokens.saturating_add(self.output_tokens)
}
pub fn cost_is_partial(&self) -> bool {
self.cost_usd_ticks.is_some() && self.cost_missing_calls > 0
}
fn fold_totals(&mut self, other: &UsageTotals) {
let Self {
input_tokens,
output_tokens,
cached_read_tokens,
reasoning_tokens,
model_calls,
api_duration_ms,
cost_usd_ticks,
cost_missing_calls,
} = other;
self.input_tokens = self.input_tokens.saturating_add(*input_tokens);
self.output_tokens = self.output_tokens.saturating_add(*output_tokens);
self.cached_read_tokens = self.cached_read_tokens.saturating_add(*cached_read_tokens);
self.reasoning_tokens = self.reasoning_tokens.saturating_add(*reasoning_tokens);
self.model_calls = self.model_calls.saturating_add(*model_calls);
self.api_duration_ms = self.api_duration_ms.saturating_add(*api_duration_ms);
self.cost_missing_calls = self.cost_missing_calls.saturating_add(*cost_missing_calls);
self.cost_usd_ticks = merge_cost_ticks(self.cost_usd_ticks, *cost_usd_ticks);
}
}
fn merge_cost_ticks(a: Option<i64>, b: Option<i64>) -> Option<i64> {
match (a, b) {
(None, None) => None,
(a, b) => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageLedger {
pub totals: UsageTotals,
pub by_model: IndexMap<String, UsageTotals>,
/// Main-agent loop rounds for `num_turns` (subagents excluded).
pub main_loop_model_calls: u64,
/// Bill may under-count (drain timeout, nested subagent incomplete, apply failure).
pub incomplete: bool,
}
impl UsageLedger {
/// Fold one main-agent-loop model call. This is the only writer of
/// `main_loop_model_calls` (the wire `numTurns`); side calls such as
/// compaction must not use it.
pub fn record_main_loop_call(
&mut self,
model_id: &str,
usage: &TokenUsage,
api_duration_ms: Option<u64>,
cost_usd_ticks: Option<i64>,
) {
let call = UsageTotals::from_call(usage, api_duration_ms, cost_usd_ticks);
self.main_loop_model_calls = self.main_loop_model_calls.saturating_add(1);
self.fold_entry(model_id, &call);
}
/// Fold subagent usage without incrementing `main_loop_model_calls`.
pub fn record_subagent(&mut self, by_model: &[(String, UsageTotals)], incomplete: bool) {
for (model_id, totals) in by_model {
self.fold_entry(model_id, totals);
}
if incomplete {
self.incomplete = true;
}
}
pub fn mark_incomplete(&mut self) {
self.incomplete = true;
}
fn fold_entry(&mut self, model_id: &str, totals: &UsageTotals) {
self.totals.fold_totals(totals);
self.by_model
.entry(model_id.to_owned())
.or_default()
.fold_totals(totals);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tu(prompt: u32, completion: u32) -> TokenUsage {
TokenUsage {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: 999_999,
reasoning_tokens: 0,
cached_prompt_tokens: 0,
}
}
#[test]
fn ledger_sums_partial_subagent_and_zero_cost() {
let mut ledger = UsageLedger::default();
ledger.record_main_loop_call("m", &tu(1, 1), None, Some(0));
assert_eq!(ledger.totals.cost_usd_ticks, None);
assert_eq!(ledger.totals.cost_missing_calls, 1);
ledger.record_main_loop_call("a", &tu(100, 10), Some(100), None);
ledger.record_main_loop_call("a", &tu(50, 5), Some(50), Some(70));
assert_eq!(ledger.totals.cost_usd_ticks, Some(70));
assert!(ledger.totals.cost_is_partial());
assert_eq!(ledger.main_loop_model_calls, 3);
ledger.record_subagent(
&[(
"b".into(),
UsageTotals {
input_tokens: 5,
model_calls: 1,
..Default::default()
},
)],
false,
);
assert_eq!(ledger.by_model["b"].input_tokens, 5);
assert_eq!(ledger.main_loop_model_calls, 3);
assert_eq!(ledger.totals.model_calls, 4);
assert!(!ledger.incomplete);
ledger.record_subagent(&[], true);
assert!(ledger.incomplete);
}
}