M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
//! Handle to communicate with ChatStateActor.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use kigi_sampling_types::{
|
||||
ConversationItem, ConversationRequest, DanglingToolCallReason, SamplingConfig, TokenUsage,
|
||||
ToolSpec, TraceContext,
|
||||
};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user