Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
28
crates/codegen/xai-chat-state/Cargo.toml
Normal file
28
crates/codegen/xai-chat-state/Cargo.toml
Normal 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
|
||||
391
crates/codegen/xai-chat-state/src/actor/mod.rs
Normal file
391
crates/codegen/xai-chat-state/src/actor/mod.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
532
crates/codegen/xai-chat-state/src/actor/mutations.rs
Normal file
532
crates/codegen/xai-chat-state/src/actor/mutations.rs
Normal 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"
|
||||
);
|
||||
&[]
|
||||
})
|
||||
}
|
||||
}
|
||||
227
crates/codegen/xai-chat-state/src/actor/queries.rs
Normal file
227
crates/codegen/xai-chat-state/src/actor/queries.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
865
crates/codegen/xai-chat-state/src/actor/request_builder.rs
Normal file
865
crates/codegen/xai-chat-state/src/actor/request_builder.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
402
crates/codegen/xai-chat-state/src/actor/state.rs
Normal file
402
crates/codegen/xai-chat-state/src/actor/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
4418
crates/codegen/xai-chat-state/src/actor/tests.rs
Normal file
4418
crates/codegen/xai-chat-state/src/actor/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
485
crates/codegen/xai-chat-state/src/commands.rs
Normal file
485
crates/codegen/xai-chat-state/src/commands.rs
Normal 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 };
|
||||
}
|
||||
}
|
||||
149
crates/codegen/xai-chat-state/src/compaction_mode.rs
Normal file
149
crates/codegen/xai-chat-state/src/compaction_mode.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
821
crates/codegen/xai-chat-state/src/compaction_transcript.rs
Normal file
821
crates/codegen/xai-chat-state/src/compaction_transcript.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
3633
crates/codegen/xai-chat-state/src/compaction_utils.rs
Normal file
3633
crates/codegen/xai-chat-state/src/compaction_utils.rs
Normal file
File diff suppressed because it is too large
Load diff
117
crates/codegen/xai-chat-state/src/conversation_util.rs
Normal file
117
crates/codegen/xai-chat-state/src/conversation_util.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
52
crates/codegen/xai-chat-state/src/events.rs
Normal file
52
crates/codegen/xai-chat-state/src/events.rs
Normal 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 };
|
||||
}
|
||||
}
|
||||
641
crates/codegen/xai-chat-state/src/handle.rs
Normal file
641
crates/codegen/xai-chat-state/src/handle.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
55
crates/codegen/xai-chat-state/src/lib.rs
Normal file
55
crates/codegen/xai-chat-state/src/lib.rs
Normal 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};
|
||||
173
crates/codegen/xai-chat-state/src/persistence.rs
Normal file
173
crates/codegen/xai-chat-state/src/persistence.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
257
crates/codegen/xai-chat-state/src/types.rs
Normal file
257
crates/codegen/xai-chat-state/src/types.rs
Normal 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 (0–100).
|
||||
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));
|
||||
}
|
||||
}
|
||||
195
crates/codegen/xai-chat-state/src/usage.rs
Normal file
195
crates/codegen/xai-chat-state/src/usage.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue