refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
//! Maps parsed `/` slash commands into one or more `Action` variants
|
||||
//! that `apply_action` can process.
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
use crate::controller::command::Command;
|
||||
|
||||
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
|
||||
///
|
||||
/// Flow: match each `Command` variant to its handler — most produce a
|
||||
/// single `Action` (open an overlay, dispatch an OAuth flow, open the
|
||||
/// editor, etc.); some produce an `Action::SystemNote` for errors or
|
||||
/// informational responses.
|
||||
///
|
||||
/// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
|
||||
/// by `apply_action`.
|
||||
pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
match command {
|
||||
Command::Help => {
|
||||
vec![Action::OpenOverlay(Overlay::Help)]
|
||||
}
|
||||
Command::Quit => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
Command::McpOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Mcp)]
|
||||
}
|
||||
Command::ClearConfirm => {
|
||||
vec![Action::OpenOverlay(Overlay::ClearConfirm)]
|
||||
}
|
||||
Command::Clear => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "clear".to_string(),
|
||||
message: "transcript cleared".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::Login { provider } if provider.is_empty() => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: "Usage: /login <provider>".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::Login { provider } => {
|
||||
vec![Action::StartOAuth { provider }]
|
||||
}
|
||||
Command::Edit(path) if path == "." || path.is_empty() => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "info".to_string(),
|
||||
message: "Usage: /edit <path>\nOpens a file for inline editing.\nExample: /edit src/main.rs".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::Edit(path) => {
|
||||
vec![Action::OpenEditor { path }]
|
||||
}
|
||||
Command::McpAdd { name, command } => {
|
||||
vec![Action::McpAdd { name, command }]
|
||||
}
|
||||
Command::ModelList => {
|
||||
vec![Action::ModelList]
|
||||
}
|
||||
Command::Compact => {
|
||||
vec![Action::Compact]
|
||||
}
|
||||
|
||||
Command::TodoOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Todo)]
|
||||
}
|
||||
Command::UsageOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Usage)]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: format!("unknown command: {cmd}"),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#![allow(dead_code)]
|
||||
//! Cross-call tool-result deduplication: when a read-only tool is called
|
||||
//! again with identical arguments, the earlier result is replaced with a
|
||||
//! placeholder so only the latest copy occupies context.
|
||||
//!
|
||||
//! Flow: pair each `Role::Tool` message to its originating `ToolCall` via
|
||||
//! `tool_call_id` -> key on `(function.name, sha256(canonical_json(args)))`
|
||||
//! -> for read-only tools, keep only the last occurrence of each key in
|
||||
//! full, placeholder the rest.
|
||||
//!
|
||||
//! Why: reading the same file (or re-running the same grep) twice in a
|
||||
//! session otherwise keeps both full copies in context until compaction
|
||||
//! eventually drops the older one wholesale, along with everything else
|
||||
//! from that period. Mutating tools (`write`, `edit`, `bash`, `delete`,
|
||||
//! `git_operator`, ...) are never touched, even with identical
|
||||
//! arguments, because call order and repetition can be semantically
|
||||
//! meaningful (e.g. retrying a flaky `bash` command until it passes).
|
||||
use crate::app::subagent::division::tool_scope::READ_TOOLS;
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
use sha2::Digest;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DUPLICATE_PLACEHOLDER: &str =
|
||||
"[duplicate result — superseded by a later identical call, see below]";
|
||||
|
||||
/// Replace superseded read-only tool results with a placeholder.
|
||||
///
|
||||
/// Return: a `Vec<ChatMessage>` the same length as `messages`, and
|
||||
/// `true` iff at least one entry was replaced. The caller uses the
|
||||
/// `bool` to decide whether the result is worth persisting/announcing,
|
||||
/// without `ChatMessage` needing to implement `PartialEq`.
|
||||
pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
||||
// tool_call_id -> (tool name, canonical JSON of its arguments)
|
||||
let mut call_info: HashMap<String, (String, String)> = HashMap::new();
|
||||
for m in messages {
|
||||
if let Some(calls) = &m.tool_calls {
|
||||
for call in calls {
|
||||
let canonical = serde_json::to_string(&call.function.arguments).unwrap_or_default();
|
||||
call_info.insert(call.id.clone(), (call.function.name.clone(), canonical));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each (tool, args-hash) key among read-only tools, find the
|
||||
// index of its LAST occurrence — that's the one kept in full.
|
||||
let mut last_index_for_key: HashMap<String, usize> = HashMap::new();
|
||||
for (idx, m) in messages.iter().enumerate() {
|
||||
if m.role != Role::Tool {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = &m.tool_call_id else { continue };
|
||||
let Some((name, args)) = call_info.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !READ_TOOLS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
last_index_for_key.insert(dedup_key(name, args), idx);
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
let result = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, m)| {
|
||||
if m.role != Role::Tool {
|
||||
return m.clone();
|
||||
}
|
||||
let Some(id) = &m.tool_call_id else {
|
||||
return m.clone();
|
||||
};
|
||||
let Some((name, args)) = call_info.get(id) else {
|
||||
return m.clone();
|
||||
};
|
||||
if !READ_TOOLS.contains(&name.as_str()) {
|
||||
return m.clone();
|
||||
}
|
||||
let key = dedup_key(name, args);
|
||||
if last_index_for_key.get(&key) == Some(&idx) {
|
||||
return m.clone();
|
||||
}
|
||||
changed = true;
|
||||
ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
(result, changed)
|
||||
}
|
||||
|
||||
/// Build the dedup key for a tool call.
|
||||
///
|
||||
/// Why hash the arguments: keeps the key a fixed, short size regardless
|
||||
/// of argument payload size. `serde_json::to_string` is already
|
||||
/// canonical here — this codebase doesn't enable `serde_json`'s
|
||||
/// `preserve_order` feature, so `Value::Object` is backed by a
|
||||
/// `BTreeMap` and always serializes keys in sorted order.
|
||||
fn dedup_key(tool_name: &str, canonical_args: &str) -> String {
|
||||
let hash = hex::encode(sha2::Sha256::digest(canonical_args.as_bytes()));
|
||||
format!("{tool_name}:{hash}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::chat::tool::{ToolCall, ToolFunction};
|
||||
use serde_json::json;
|
||||
|
||||
fn assistant_with_call(id: &str, name: &str, args: serde_json::Value) -> ChatMessage {
|
||||
let mut m = ChatMessage::assistant(None);
|
||||
m.tool_calls = Some(vec![ToolCall {
|
||||
id: id.to_string(),
|
||||
type_: "function".to_string(),
|
||||
function: ToolFunction {
|
||||
name: name.to_string(),
|
||||
arguments: args,
|
||||
},
|
||||
}]);
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_result_of_same_read_tool_and_args_is_replaced() {
|
||||
let messages = vec![
|
||||
assistant_with_call("call-1", "read", json!({"path": "a.rs"})),
|
||||
ChatMessage::tool_result("call-1".to_string(), "first read of a.rs".to_string()),
|
||||
assistant_with_call("call-2", "read", json!({"path": "a.rs"})),
|
||||
ChatMessage::tool_result("call-2".to_string(), "second read of a.rs".to_string()),
|
||||
];
|
||||
|
||||
let (result, changed) = collapse(&messages);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER));
|
||||
assert_eq!(result[3].content.as_deref(), Some("second read of a.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_arguments_are_not_deduplicated() {
|
||||
let messages = vec![
|
||||
assistant_with_call("call-1", "read", json!({"path": "a.rs"})),
|
||||
ChatMessage::tool_result("call-1".to_string(), "read of a.rs".to_string()),
|
||||
assistant_with_call("call-2", "read", json!({"path": "b.rs"})),
|
||||
ChatMessage::tool_result("call-2".to_string(), "read of b.rs".to_string()),
|
||||
];
|
||||
|
||||
let (result, changed) = collapse(&messages);
|
||||
|
||||
assert!(!changed);
|
||||
assert_eq!(result[1].content.as_deref(), Some("read of a.rs"));
|
||||
assert_eq!(result[3].content.as_deref(), Some("read of b.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_order_in_arguments_does_not_prevent_dedup() {
|
||||
let messages = vec![
|
||||
assistant_with_call("call-1", "grep", json!({"pattern": "foo", "path": "."})),
|
||||
ChatMessage::tool_result("call-1".to_string(), "first grep".to_string()),
|
||||
assistant_with_call("call-2", "grep", json!({"path": ".", "pattern": "foo"})),
|
||||
ChatMessage::tool_result("call-2".to_string(), "second grep".to_string()),
|
||||
];
|
||||
|
||||
let (result, changed) = collapse(&messages);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutating_tool_with_identical_args_is_never_deduplicated() {
|
||||
let messages = vec![
|
||||
assistant_with_call("call-1", "bash", json!({"command": "cargo test"})),
|
||||
ChatMessage::tool_result("call-1".to_string(), "first run: 3 failed".to_string()),
|
||||
assistant_with_call("call-2", "bash", json!({"command": "cargo test"})),
|
||||
ChatMessage::tool_result("call-2".to_string(), "second run: 0 failed".to_string()),
|
||||
];
|
||||
|
||||
let (result, changed) = collapse(&messages);
|
||||
|
||||
assert!(!changed);
|
||||
assert_eq!(result[1].content.as_deref(), Some("first run: 3 failed"));
|
||||
assert_eq!(result[3].content.as_deref(), Some("second run: 0 failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_with_no_matching_call_is_left_untouched() {
|
||||
let messages = vec![ChatMessage::tool_result(
|
||||
"orphan-id".to_string(),
|
||||
"some result".to_string(),
|
||||
)];
|
||||
|
||||
let (result, changed) = collapse(&messages);
|
||||
|
||||
assert!(!changed);
|
||||
assert_eq!(result[0].content.as_deref(), Some("some result"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Context management: token counting, cross-call tool-result dedup,
|
||||
//! per-result compression, budget-based shaping, and shared
|
||||
//! context-window resolution — replaces `runtime::shortsend`.
|
||||
//!
|
||||
//! No facade function here: `dedup`, `shaping`, and `tokens` are called
|
||||
//! directly from each call site (the per-turn auto-compaction loop in
|
||||
//! `actions::run_agent_turn`, and `Action::Compact`), matching this
|
||||
//! codebase's "no DI, call modules directly" convention. An orchestration
|
||||
//! layer would only serve one of the two callers generically — the
|
||||
//! auto-loop already needs per-stage control to decide when to emit
|
||||
//! `TurnEvent::Compacted`.
|
||||
pub mod dedup;
|
||||
pub mod shaping;
|
||||
pub mod squash;
|
||||
pub mod tokens;
|
||||
pub mod window;
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Budget-based message shaping: compacts long conversation histories so
|
||||
//! they fit within the provider's context window before being sent to
|
||||
//! the LLM API. Ported from the former `runtime::shortsend` — behavior
|
||||
//! is unchanged, only its token-counting now goes through
|
||||
//! `context::tokens` instead of an inline heuristic.
|
||||
use super::tokens::count_tokens;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
/// Decide whether the message list should be shaped (compacted) before
|
||||
/// sending to the LLM.
|
||||
///
|
||||
/// Flow: trigger based on token estimate. If `token_estimate` exceeds
|
||||
/// the threshold, we shape. When `prev_shaped` is true, the threshold is
|
||||
/// raised (95%) to avoid fluttering — compaction only re-triggers when
|
||||
/// the context is genuinely full again. When `prev_shaped` is false, the
|
||||
/// threshold is lower (85%) so compaction starts proactively.
|
||||
///
|
||||
/// Why: hysteresis prevents repeated compaction on every turn when the
|
||||
/// token count hovers near the boundary.
|
||||
///
|
||||
/// Return: `true` if shaping should be applied.
|
||||
pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool {
|
||||
let threshold = if prev_shaped {
|
||||
(max_wire_tokens as f32 * 0.95) as usize
|
||||
} else {
|
||||
(max_wire_tokens as f32 * 0.85) as usize
|
||||
};
|
||||
token_estimate >= threshold
|
||||
}
|
||||
|
||||
/// Compact a long message list by dropping middle messages and inserting
|
||||
/// a summary placeholder.
|
||||
///
|
||||
/// Flow: if the estimated token count is within budget and not forced,
|
||||
/// return messages unchanged -> otherwise keep the system message and
|
||||
/// the most recent messages that fit a 70%-of-budget target, with a
|
||||
/// `[prior conversation compacted]` (or LLM-generated summary, if
|
||||
/// `client` is `Some`) system message in between.
|
||||
///
|
||||
/// Why: keeps context-size overhead roughly constant regardless of
|
||||
/// session length.
|
||||
///
|
||||
/// Return: the shaped message list, or `messages` unchanged if shaping
|
||||
/// wasn't needed.
|
||||
pub fn shape_messages(
|
||||
messages: &[ChatMessage],
|
||||
token_count: usize,
|
||||
max_wire_tokens: usize,
|
||||
force: bool,
|
||||
client: Option<&crate::service::provider::LlmClient>,
|
||||
) -> Vec<ChatMessage> {
|
||||
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
|
||||
return messages.to_vec();
|
||||
}
|
||||
|
||||
let target_tokens = (max_wire_tokens as f32 * 0.70) as usize;
|
||||
let mut current_tokens = 0;
|
||||
let mut keep_recent = Vec::new();
|
||||
let mut dropped_msgs = Vec::new();
|
||||
|
||||
let mut msgs_to_eval = messages.to_vec();
|
||||
let first = if msgs_to_eval.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(msgs_to_eval.remove(0))
|
||||
};
|
||||
|
||||
for m in msgs_to_eval.into_iter().rev() {
|
||||
let text = m.content.as_deref().unwrap_or("");
|
||||
let msg_tokens = count_tokens(text);
|
||||
|
||||
if current_tokens + msg_tokens <= target_tokens {
|
||||
current_tokens += msg_tokens;
|
||||
keep_recent.push(m);
|
||||
} else {
|
||||
dropped_msgs.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
dropped_msgs.reverse();
|
||||
|
||||
let mut result = Vec::new();
|
||||
if let Some(f) = first {
|
||||
result.push(f);
|
||||
}
|
||||
|
||||
if !dropped_msgs.is_empty() {
|
||||
let mut summary_text = "[prior conversation compacted]".to_string();
|
||||
|
||||
if let Some(llm) = client {
|
||||
let prompt = format!(
|
||||
"Summarize the following dropped conversation history briefly. Focus on main goals, decisions made, and files modified, so the context is preserved for future turns. Keep it concise.\n\nHistory:\n{}",
|
||||
dropped_msgs.iter()
|
||||
.map(|m| format!("[{}]: {}", if m.role == crate::dto::chat::message::Role::User { "User" } else { "Assistant" }, m.content.as_deref().unwrap_or("")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
);
|
||||
|
||||
let req_msgs = vec![ChatMessage::user(prompt)];
|
||||
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
|
||||
Ok(resp) => {
|
||||
if let Some(content) = resp.0.content {
|
||||
summary_text =
|
||||
format!("[Summary of compacted prior conversation:\n{content}\n]");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[context::shaping] LLM summarization failed: {}. \
|
||||
Prior conversation history is lost — no summary available. \
|
||||
This means the model will lose context about earlier parts of \
|
||||
the conversation.",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(ChatMessage::system(summary_text));
|
||||
}
|
||||
|
||||
result.extend(keep_recent.into_iter().rev());
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
#[test]
|
||||
fn should_shape_triggers_at_85_percent_when_not_previously_shaped() {
|
||||
assert!(should_shape(850, 1000, false));
|
||||
assert!(!should_shape(849, 1000, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_shape_uses_95_percent_threshold_once_already_shaped() {
|
||||
assert!(
|
||||
!should_shape(900, 1000, true),
|
||||
"below 95% and already shaped: no re-trigger yet"
|
||||
);
|
||||
assert!(should_shape(950, 1000, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_messages_is_a_noop_under_budget_and_not_forced() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user("hi"),
|
||||
ChatMessage::assistant(Some("hello".to_string())),
|
||||
];
|
||||
let result = shape_messages(&messages, 10, 1000, false, None);
|
||||
assert_eq!(result.len(), messages.len());
|
||||
}
|
||||
|
||||
/// Build a message whose real BPE token count is large enough that 20
|
||||
/// of them (~49 tokens each, ~980 total — verified empirically with
|
||||
/// `context::tokens::count_tokens`) comfortably exceed
|
||||
/// `shape_messages`'s 70%-of-1000 = 700 token target, guaranteeing
|
||||
/// several get dropped. A short fixture like `format!("message {i}")`
|
||||
/// (~8 tokens each, ~160 total for 20) stays entirely under budget
|
||||
/// with real BPE counting and would make these tests pass vacuously
|
||||
/// (nothing ever gets dropped, so "must survive shaping" and "falls
|
||||
/// back to placeholder" hold trivially without exercising the actual
|
||||
/// drop logic) — this was a real bug caught during Task 5's first
|
||||
/// implementation attempt.
|
||||
fn padded_message(i: usize) -> String {
|
||||
format!(
|
||||
"message number {i} with some padding text {}",
|
||||
"additional padding content to increase token count substantially ".repeat(5),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_messages_always_preserves_the_first_system_message() {
|
||||
let mut messages = vec![ChatMessage::system("system prompt")];
|
||||
for i in 0..20 {
|
||||
messages.push(ChatMessage::user(padded_message(i)));
|
||||
}
|
||||
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
||||
assert_eq!(result[0].content.as_deref(), Some("system prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_messages_without_a_client_falls_back_to_placeholder_summary() {
|
||||
let mut messages = vec![ChatMessage::system("system prompt")];
|
||||
for i in 0..20 {
|
||||
messages.push(ChatMessage::user(padded_message(i)));
|
||||
}
|
||||
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
||||
let has_placeholder = result
|
||||
.iter()
|
||||
.any(|m| m.content.as_deref() == Some("[prior conversation compacted]"));
|
||||
assert!(has_placeholder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_messages_keeps_most_recent_messages_over_older_ones() {
|
||||
let mut messages = vec![ChatMessage::system("system prompt")];
|
||||
for i in 0..20 {
|
||||
messages.push(ChatMessage::user(padded_message(i)));
|
||||
}
|
||||
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
||||
let last_content = messages.last().unwrap().content.clone();
|
||||
assert!(
|
||||
result.iter().any(|m| m.content == last_content),
|
||||
"most recent message must survive shaping"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
#![allow(dead_code)]
|
||||
//! Per-tool-result compression: shrink large tool outputs before they
|
||||
//! ever enter conversation history, dispatching by content shape.
|
||||
//!
|
||||
//! Flow: `apply(tool_name, output)` -> `read` tool or under the size
|
||||
//! floor? pass through unchanged : valid JSON? `squash_json` : tool is
|
||||
//! `bash` and looks log-shaped? `squash_log` : `squash_generic`.
|
||||
//!
|
||||
//! Why: a single large `bash`/`grep` result can dominate a
|
||||
//! conversation's token budget even on its first occurrence, long
|
||||
//! before `dedup`/`shaping` ever get a chance to act on repeats or
|
||||
//! overall budget.
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
/// Below this size, compression isn't worth the risk of losing detail —
|
||||
/// pass the output through unchanged.
|
||||
const SQUASH_FLOOR_BYTES: usize = 1500;
|
||||
|
||||
/// Byte budget for the generic fallback compressor — double the squash
|
||||
/// floor, so the fallback path still yields a real reduction on
|
||||
/// anything that triggered it.
|
||||
const GENERIC_BUDGET_BYTES: usize = SQUASH_FLOOR_BYTES * 2;
|
||||
|
||||
/// Tools whose output must never be altered. `read` is exempted because
|
||||
/// its output must stay byte-exact — the agent relies on it for
|
||||
/// exact-match edits afterward, and squashing a file that happens to
|
||||
/// parse as JSON (e.g. `package.json`) would silently corrupt the
|
||||
/// agent's view of real file content.
|
||||
const NEVER_SQUASH: &[&str] = &["read"];
|
||||
|
||||
/// Tools whose output the log classifier is allowed to run on.
|
||||
/// `looks_log_shaped` keys purely on content (>=3 error/warn/fail-shaped
|
||||
/// lines), which a `grep`/`search` result full of matches against
|
||||
/// error-handling code would trip just as easily as a real build log —
|
||||
/// but `squash_log` caps at 20 error + 10 warning lines with no byte
|
||||
/// budget, silently dropping legitimate matches past that cap. Only
|
||||
/// `bash` (the actual log-producing tool) is allowed to route through
|
||||
/// it; everything else that looks log-shaped falls through to the
|
||||
/// gentler, byte-budgeted `squash_generic` instead.
|
||||
const LOG_SHAPED_TOOLS: &[&str] = &["bash"];
|
||||
|
||||
/// Compress a tool's raw output before it's stored in conversation
|
||||
/// history.
|
||||
///
|
||||
/// Return: `output` unchanged if `tool_name` is in `NEVER_SQUASH` or at
|
||||
/// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from
|
||||
/// whichever detector matches its content shape.
|
||||
pub fn apply(tool_name: &str, output: &str) -> String {
|
||||
if NEVER_SQUASH.contains(&tool_name) || output.len() <= SQUASH_FLOOR_BYTES {
|
||||
return output.to_string();
|
||||
}
|
||||
if serde_json::from_str::<serde_json::Value>(output).is_ok() {
|
||||
return squash_json(output);
|
||||
}
|
||||
if LOG_SHAPED_TOOLS.contains(&tool_name) && looks_log_shaped(output) {
|
||||
return squash_log(output);
|
||||
}
|
||||
squash_generic(output, GENERIC_BUDGET_BYTES)
|
||||
}
|
||||
|
||||
/// Compress a JSON tool result by keeping all structural content (keys,
|
||||
/// array/object shape) and eliding long, low-entropy string *values*,
|
||||
/// while keeping short values (<=20 chars) and high-entropy single-token
|
||||
/// ones (UUIDs, hashes, paths) intact. Array elements past the first 3
|
||||
/// are elided regardless of length/entropy.
|
||||
///
|
||||
/// Why walk a parsed `Value` instead of hand-rolling a JSON tokenizer:
|
||||
/// `serde_json` already handles escaping/nesting correctly (this
|
||||
/// codebase's own `dto::chat::tool::repair_json` exists specifically to
|
||||
/// work around how easy it is to get that wrong by hand) — reusing it
|
||||
/// is both simpler and more robust.
|
||||
///
|
||||
/// Return: re-serialized JSON with the same shape as the input.
|
||||
fn squash_json(text: &str) -> String {
|
||||
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(text) else {
|
||||
return text.to_string();
|
||||
};
|
||||
squash_json_value(&mut value, false);
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| text.to_string())
|
||||
}
|
||||
|
||||
/// Recursively elide long, low-entropy string values in place.
|
||||
/// `in_late_array` is true once past the first 3 elements of an
|
||||
/// enclosing array, tightening the elision rule for the rest of it.
|
||||
///
|
||||
/// Why the `!s.contains(' ')` gate before the entropy check: raw
|
||||
/// per-character Shannon entropy alone does NOT separate "meaningful
|
||||
/// prose" from "random-looking identifier" — verified empirically,
|
||||
/// repeated English prose scores ~3.89 bits/char, *higher* than a UUID's
|
||||
/// ~3.39 or a SHA-256 hex digest's ~3.66, because prose draws from a
|
||||
/// wide, fairly-balanced character set too. What actually distinguishes
|
||||
/// identifiers from prose is that identifiers are a single unbroken
|
||||
/// token — this mirrors headroom's own approach (its entropy check is
|
||||
/// "cheaply pre-filtered by 'no spaces'" before scoring). Multi-word
|
||||
/// values never reach the entropy branch at all; only whitespace-free
|
||||
/// tokens do, where entropy correctly separates "abc123" or "aaaaaaaa"
|
||||
/// (low, elided if long) from a UUID/hash/API-key-shaped string (high,
|
||||
/// kept).
|
||||
fn squash_json_value(value: &mut serde_json::Value, in_late_array: bool) {
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
let looks_like_identifier = !s.contains(' ') && shannon_entropy(s) >= 3.0;
|
||||
let keep = !in_late_array && (s.len() <= 20 || looks_like_identifier);
|
||||
if !keep {
|
||||
*s = "…".to_string();
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
squash_json_value(item, i >= 3);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for v in map.values_mut() {
|
||||
squash_json_value(v, false);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shannon entropy in bits per character — used, after the `squash_json`
|
||||
/// caller's own "no internal whitespace" pre-filter, to distinguish
|
||||
/// high-entropy single-token strings (UUIDs, hashes, random IDs, worth
|
||||
/// keeping) from low-entropy ones (e.g. `"aaaaaaaaaa"`, safe to elide).
|
||||
/// 3.0 sits comfortably below a UUID's ~3.39 and a SHA-256 hex digest's
|
||||
/// ~3.66 (both empirically measured with this exact formula) while
|
||||
/// staying well above a degenerate repeated-character string's 0.0.
|
||||
fn shannon_entropy(s: &str) -> f64 {
|
||||
if s.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut counts: std::collections::HashMap<char, usize> = std::collections::HashMap::new();
|
||||
for c in s.chars() {
|
||||
*counts.entry(c).or_insert(0) += 1;
|
||||
}
|
||||
let len = s.chars().count() as f64;
|
||||
counts
|
||||
.values()
|
||||
.map(|&count| {
|
||||
let p = f64::from(u32::try_from(count).unwrap_or(u32::MAX)) / len;
|
||||
-p * p.log2()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Coarse severity classification for a single log line, used by
|
||||
/// `squash_log` to rank which lines are most worth keeping.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum LogLevel {
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
/// Classify a single log line by scanning for level keywords.
|
||||
///
|
||||
/// Why substring matching on a lowercased copy instead of a real log
|
||||
/// parser: tool output comes from arbitrary external processes with no
|
||||
/// consistent log format, so keyword sniffing is the only detector that
|
||||
/// generalizes across all of them.
|
||||
fn classify_line(line: &str) -> LogLevel {
|
||||
let lower = line.to_lowercase();
|
||||
if lower.contains("error") || lower.contains("fail") || lower.contains("panic") {
|
||||
LogLevel::Error
|
||||
} else if lower.contains("warn") {
|
||||
LogLevel::Warn
|
||||
} else if lower.contains("debug") || lower.contains("trace") {
|
||||
LogLevel::Debug
|
||||
} else {
|
||||
LogLevel::Info
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic gate for routing to `squash_log` vs `squash_generic`: at
|
||||
/// least 3 lines that look like error/warning/stack-trace output.
|
||||
fn looks_log_shaped(text: &str) -> bool {
|
||||
let hits = text
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let lower = l.to_lowercase();
|
||||
lower.contains("error")
|
||||
|| lower.contains("warn")
|
||||
|| lower.contains("fail")
|
||||
|| lower.contains("panic")
|
||||
|| l.trim_start().starts_with("at ")
|
||||
})
|
||||
.count();
|
||||
hits >= 3
|
||||
}
|
||||
|
||||
/// Compress log-shaped output: keep up to 20 highest-scored error lines
|
||||
/// and up to 10 highest-scored warning lines (score = level weight +
|
||||
/// 0.3 if the line looks like a stack-trace frame), each with a
|
||||
/// +/-2-line context window, replacing every gap with a `[N lines
|
||||
/// omitted]` marker.
|
||||
///
|
||||
/// Why not a comment-shaped marker (e.g. `// N lines omitted`): the
|
||||
/// `rtk` project's own regression tests found that shape gets parsed by
|
||||
/// the LLM as code and triggers a retry loop.
|
||||
fn squash_log(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let levels: Vec<LogLevel> = lines.iter().map(|l| classify_line(l)).collect();
|
||||
|
||||
let score = |i: usize| -> f32 {
|
||||
let level_score = match levels[i] {
|
||||
LogLevel::Error => 1.0,
|
||||
LogLevel::Warn => 0.5,
|
||||
LogLevel::Info => 0.1,
|
||||
LogLevel::Debug => 0.05,
|
||||
};
|
||||
let stack_boost = if lines[i].trim_start().starts_with("at ") {
|
||||
0.3
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
level_score + stack_boost
|
||||
};
|
||||
|
||||
let mut error_idxs: Vec<usize> = (0..lines.len())
|
||||
.filter(|&i| levels[i] == LogLevel::Error)
|
||||
.collect();
|
||||
error_idxs.sort_by(|&a, &b| {
|
||||
score(b)
|
||||
.partial_cmp(&score(a))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
error_idxs.truncate(20);
|
||||
|
||||
let mut warn_idxs: Vec<usize> = (0..lines.len())
|
||||
.filter(|&i| levels[i] == LogLevel::Warn)
|
||||
.collect();
|
||||
warn_idxs.sort_by(|&a, &b| {
|
||||
score(b)
|
||||
.partial_cmp(&score(a))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
warn_idxs.truncate(10);
|
||||
|
||||
let mut keep: HashSet<usize> = HashSet::new();
|
||||
for &i in error_idxs.iter().chain(warn_idxs.iter()) {
|
||||
let lo = i.saturating_sub(2);
|
||||
let hi = (i + 2).min(lines.len().saturating_sub(1));
|
||||
keep.extend(lo..=hi);
|
||||
}
|
||||
|
||||
if keep.is_empty() {
|
||||
return squash_generic(text, GENERIC_BUDGET_BYTES);
|
||||
}
|
||||
|
||||
render_kept_lines(&lines, &keep)
|
||||
}
|
||||
|
||||
/// Importance-ranked truncation for content that isn't JSON or
|
||||
/// log-shaped: keep the first 10 and last 10 lines, plus any
|
||||
/// non-blank line that isn't a repeat of the one before it, until
|
||||
/// `budget` bytes are used.
|
||||
fn squash_generic(text: &str, budget: usize) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
if lines.len() <= 20 {
|
||||
return text.chars().take(budget).collect();
|
||||
}
|
||||
|
||||
let head_end = 10;
|
||||
let tail_start = lines.len() - 10;
|
||||
let mut keep: HashSet<usize> = (0..head_end).chain(tail_start..lines.len()).collect();
|
||||
|
||||
let mut used: usize = lines[..head_end].iter().map(|l| l.len() + 1).sum::<usize>()
|
||||
+ lines[tail_start..]
|
||||
.iter()
|
||||
.map(|l| l.len() + 1)
|
||||
.sum::<usize>();
|
||||
let mut prev = "";
|
||||
for (i, &line) in lines.iter().enumerate().take(tail_start).skip(head_end) {
|
||||
let non_trivial = !line.trim().is_empty() && line != prev;
|
||||
if non_trivial && used + line.len() < budget {
|
||||
keep.insert(i);
|
||||
used += line.len() + 1;
|
||||
}
|
||||
prev = line;
|
||||
}
|
||||
|
||||
render_kept_lines(&lines, &keep)
|
||||
}
|
||||
|
||||
/// Render a subset of `lines` in order, inserting a `[N lines omitted]`
|
||||
/// marker at every gap between kept lines.
|
||||
fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
|
||||
let mut kept_sorted: Vec<usize> = keep.iter().copied().collect();
|
||||
kept_sorted.sort_unstable();
|
||||
|
||||
let mut out = String::new();
|
||||
let mut cursor = 0usize;
|
||||
for &i in &kept_sorted {
|
||||
if i > cursor {
|
||||
let _ = writeln!(out, "[{} lines omitted]", i - cursor);
|
||||
}
|
||||
out.push_str(lines[i]);
|
||||
out.push('\n');
|
||||
cursor = i + 1;
|
||||
}
|
||||
if cursor < lines.len() {
|
||||
let _ = writeln!(out, "[{} lines omitted]", lines.len() - cursor);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn output_under_the_floor_passes_through_unchanged() {
|
||||
let small = "short output";
|
||||
assert_eq!(apply("bash", small), small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_tool_output_is_never_squashed_even_when_huge_json() {
|
||||
let big_json = format!(
|
||||
"{{\"description\": \"{}\"}}",
|
||||
"a very long description value that repeats ".repeat(100),
|
||||
);
|
||||
assert!(big_json.len() > SQUASH_FLOOR_BYTES);
|
||||
assert_eq!(apply("read", &big_json), big_json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_output_over_floor_keeps_structure_and_short_values() {
|
||||
let value = serde_json::json!({
|
||||
"id": "abc123",
|
||||
"note": "hi",
|
||||
"description": "a very long description value that repeats ".repeat(100),
|
||||
});
|
||||
let text = serde_json::to_string(&value).unwrap();
|
||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||
|
||||
let result = apply("some_mcp_tool", &text);
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&result).expect("squashed JSON must still be valid JSON");
|
||||
|
||||
assert_eq!(parsed["id"], "abc123", "short values must survive");
|
||||
assert_eq!(parsed["note"], "hi", "short values must survive");
|
||||
assert_ne!(
|
||||
parsed["description"].as_str().unwrap().len(),
|
||||
value["description"].as_str().unwrap().len(),
|
||||
"long low-entropy value must be shrunk",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_array_elements_past_third_are_squashed_harder() {
|
||||
// A UUID-shaped value has no internal whitespace and clears the
|
||||
// entropy threshold, so under the *normal* per-value rule (which
|
||||
// still applies to array indices 0-2) it survives untouched.
|
||||
// Padding elsewhere in the object pushes total size over the
|
||||
// squash floor without affecting which array elements get kept.
|
||||
let identifier = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let padding = "padding text to push this payload past the squash floor so apply() actually dispatches to squash_json ".repeat(20);
|
||||
let value = serde_json::json!({
|
||||
"padding": padding,
|
||||
"items": [identifier, identifier, identifier, identifier],
|
||||
});
|
||||
let text = serde_json::to_string(&value).unwrap();
|
||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||
|
||||
let result = apply("some_mcp_tool", &text);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
let items = parsed["items"].as_array().unwrap();
|
||||
|
||||
assert_eq!(items[0].as_str().unwrap(), identifier, "index 0 is under the array cutoff and identifier-shaped, so it's kept under the normal rule");
|
||||
assert_eq!(
|
||||
items[2].as_str().unwrap(),
|
||||
identifier,
|
||||
"index 2 is still under the cutoff (past-third means index >= 3)"
|
||||
);
|
||||
assert_ne!(items[3].as_str().unwrap(), identifier, "index 3 must be force-elided even though it's identifier-shaped and would survive at any earlier index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_like_output_keeps_error_lines_and_marks_omissions() {
|
||||
// `looks_log_shaped` requires >= 3 lines matching error/warn/fail/
|
||||
// panic/stack-frame patterns before routing to `squash_log` at
|
||||
// all — a single error line isn't enough and would silently fall
|
||||
// through to `squash_generic` instead, so this fixture needs at
|
||||
// least 3 such lines, spread apart, to actually exercise
|
||||
// squash_log's scoring/windowing logic (not just its fallback).
|
||||
let mut lines = vec!["build started".to_string()];
|
||||
for i in 0..200 {
|
||||
lines.push(format!("info: compiling module {i}"));
|
||||
}
|
||||
lines.push("error: something failed early in the build".to_string());
|
||||
for i in 0..200 {
|
||||
lines.push(format!("info: compiling module {}", i + 200));
|
||||
}
|
||||
lines.push("warning: deprecated api used somewhere".to_string());
|
||||
lines.push("error: something failed at the end".to_string());
|
||||
let text = lines.join("\n");
|
||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||
|
||||
let result = apply("bash", &text);
|
||||
|
||||
assert!(result.contains("error: something failed early in the build"));
|
||||
assert!(result.contains("error: something failed at the end"));
|
||||
assert!(result.contains("lines omitted"));
|
||||
assert!(result.len() < text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_bash_tool_with_log_shaped_content_is_not_log_compressed() {
|
||||
// A grep result whose matched lines all mention "error" would
|
||||
// trip `looks_log_shaped`'s >=3-line keyword threshold just like
|
||||
// a real build log — but `squash_log` caps at 20 highest-scored
|
||||
// error lines with no guaranteed tail retention, silently
|
||||
// dropping legitimate matches past that cap. Only `bash` is
|
||||
// treated as log-shaped; `grep` must fall through to
|
||||
// `squash_generic`, which always keeps the first and last 10
|
||||
// lines regardless of score. With every line tied at the same
|
||||
// score, a `squash_log` route would keep indices 0-19 (stable
|
||||
// sort preserves original order on ties) and drop index 49 —
|
||||
// so asserting the tail survives is a route-distinguishing
|
||||
// check, not just a content check.
|
||||
let lines: Vec<String> = (0..50)
|
||||
.map(|i| format!("src/file{i}.rs:{i}: error handling for case {i}"))
|
||||
.collect();
|
||||
let text = lines.join("\n");
|
||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||
|
||||
let result = apply("grep", &text);
|
||||
|
||||
assert!(
|
||||
result.contains("src/file0.rs:0: error handling for case 0"),
|
||||
"generic keeps head"
|
||||
);
|
||||
assert!(
|
||||
result.contains("src/file49.rs:49: error handling for case 49"),
|
||||
"generic keeps tail — squash_log would have dropped this"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_large_text_is_truncated_with_omission_marker() {
|
||||
let lines: Vec<String> = (0..500)
|
||||
.map(|i| format!("line number {i} of plain output"))
|
||||
.collect();
|
||||
let text = lines.join("\n");
|
||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||
|
||||
let result = apply("bash", &text);
|
||||
|
||||
assert!(
|
||||
result.contains("line number 0 of plain output"),
|
||||
"keeps head"
|
||||
);
|
||||
assert!(
|
||||
result.contains("line number 499 of plain output"),
|
||||
"keeps tail"
|
||||
);
|
||||
assert!(result.contains("lines omitted"));
|
||||
assert!(result.len() < text.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Unified token-count estimation for context-window budgeting.
|
||||
//!
|
||||
//! Flow: text -> `tiktoken_rs::o200k_base_singleton()` (BPE vocab embedded
|
||||
//! in the binary via `include_str!`, no network access) -> `encode_ordinary`
|
||||
//! -> token count.
|
||||
//!
|
||||
//! Why: replaces three independent char-count heuristics that disagreed
|
||||
//! with each other (`/3` in the old `shortsend.rs`, `/4` in the turn
|
||||
//! loop, `/4` again in the status bar) with one real BPE tokenizer.
|
||||
//! `o200k_base` is an approximation for non-OpenAI providers but is far
|
||||
//! closer than a flat byte-per-token guess; it's only used for the
|
||||
//! 85%/95% budget thresholds, not for billing-accurate counts.
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
/// Count tokens in a single string under `o200k_base`.
|
||||
///
|
||||
/// Return: the BPE token count for `text`. `encode_ordinary` (not
|
||||
/// `encode`/`encode_with_special_tokens`) is used deliberately — message
|
||||
/// content that happens to contain a special-token-shaped substring
|
||||
/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
|
||||
/// as ordinary text, not interpreted as a control token.
|
||||
pub fn count_tokens(text: &str) -> usize {
|
||||
tiktoken_rs::o200k_base_singleton()
|
||||
.encode_ordinary(text)
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Count tokens in a `ChatMessage`'s text content.
|
||||
///
|
||||
/// Return: 0 for a message with no `content` (e.g. an assistant message
|
||||
/// that only carries `tool_calls`).
|
||||
#[allow(dead_code)]
|
||||
pub fn count_message_tokens(msg: &ChatMessage) -> usize {
|
||||
msg.content.as_deref().map_or(0, count_tokens)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
#[test]
|
||||
fn empty_string_has_zero_tokens() {
|
||||
assert_eq!(count_tokens(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_short_phrase_has_expected_token_count() {
|
||||
// Verified empirically against tiktoken-rs 0.12's o200k_base:
|
||||
// "hello world" -> [24912, 2375], i.e. 2 tokens.
|
||||
assert_eq!(count_tokens("hello world"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_code_snippet_has_expected_token_count() {
|
||||
// Verified empirically: 9 tokens under o200k_base.
|
||||
assert_eq!(count_tokens("fn main() { println!(\"hi\"); }"), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_with_no_content_counts_zero() {
|
||||
let msg = ChatMessage::assistant(None);
|
||||
assert_eq!(count_message_tokens(&msg), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_token_count_matches_count_tokens_on_its_content() {
|
||||
let msg = ChatMessage::user("hello world");
|
||||
assert_eq!(count_message_tokens(&msg), count_tokens("hello world"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#![allow(dead_code)]
|
||||
//! Single source of truth for resolving the active model's context
|
||||
//! window size, replacing three copies of the same lookup that had
|
||||
//! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each
|
||||
//! had their own inline version — the status bar's copy additionally
|
||||
//! displayed "?" on no match instead of falling back like the other two,
|
||||
//! an inconsistency this unifies away).
|
||||
use crate::model::app_config::AppConfig;
|
||||
use crate::model::settings::Settings;
|
||||
|
||||
/// Resolve the context-window size (in tokens) for the currently
|
||||
/// configured provider/model.
|
||||
///
|
||||
/// Flow: find the `ModelRole` whose `provider`+`model` match
|
||||
/// `settings` -> use its `context_window` if set -> otherwise fall back
|
||||
/// to `app_config.default_context_window`.
|
||||
///
|
||||
/// Return: always a concrete token count, never "unknown".
|
||||
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
|
||||
app_config
|
||||
.model_roles
|
||||
.values()
|
||||
.find(|role| role.provider == settings.provider && role.model == settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(app_config.default_context_window) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::app_config::ModelRole;
|
||||
|
||||
#[test]
|
||||
fn resolves_context_window_from_matching_model_role() {
|
||||
let mut app_config = AppConfig::default();
|
||||
app_config.model_roles.insert(
|
||||
"default".to_string(),
|
||||
ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: Some(128_000),
|
||||
temperature: None,
|
||||
},
|
||||
);
|
||||
let mut settings = Settings::default();
|
||||
settings.provider = "zen".to_string();
|
||||
settings.model = "deepseek-v4-flash-free".to_string();
|
||||
|
||||
assert_eq!(resolve(&app_config, &settings), 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_context_window_when_no_role_matches() {
|
||||
let app_config = AppConfig::default();
|
||||
let mut settings = Settings::default();
|
||||
settings.provider = "nonexistent".to_string();
|
||||
settings.model = "nonexistent-model".to_string();
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app_config, &settings),
|
||||
app_config.default_context_window as usize
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_when_matching_role_has_no_context_window_set() {
|
||||
let mut app_config = AppConfig::default();
|
||||
app_config.model_roles.insert(
|
||||
"default".to_string(),
|
||||
ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: None,
|
||||
temperature: None,
|
||||
},
|
||||
);
|
||||
let mut settings = Settings::default();
|
||||
settings.provider = "zen".to_string();
|
||||
settings.model = "deepseek-v4-flash-free".to_string();
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app_config, &settings),
|
||||
app_config.default_context_window as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
|
||||
//! after any activity, then slows down to conserve CPU.
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
|
||||
const FAST_POLL_MS: u64 = 8;
|
||||
const SLOW_POLL_MS: u64 = 100;
|
||||
const IDLE_THRESHOLD_MS: u64 = 500;
|
||||
|
||||
/// Tracks whether the app has been active vs idle to adjust the TUI poll
|
||||
/// rate, balancing responsiveness against CPU usage.
|
||||
pub struct EventLoop {
|
||||
last_activity: Instant,
|
||||
fast_poll_until: Option<Instant>,
|
||||
}
|
||||
|
||||
impl EventLoop {
|
||||
/// Create an `EventLoop` with the current instant as the last activity.
|
||||
pub fn new() -> Self {
|
||||
EventLoop {
|
||||
last_activity: Instant::now(),
|
||||
fast_poll_until: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the appropriate polling delay based on activity state.
|
||||
///
|
||||
/// Flow: if `fast_poll_until` is set and the deadline hasn't expired,
|
||||
/// return `FAST_POLL_MS`; otherwise return `SLOW_POLL_MS`.
|
||||
pub fn poll_interval(&self) -> Duration {
|
||||
if let Some(fast_until) = self.fast_poll_until {
|
||||
if Instant::now() < fast_until {
|
||||
return Duration::from_millis(FAST_POLL_MS);
|
||||
}
|
||||
}
|
||||
Duration::from_millis(SLOW_POLL_MS)
|
||||
}
|
||||
|
||||
/// Mark the current time as the last activity and arm the fast-poll
|
||||
/// window for the next `IDLE_THRESHOLD_MS`.
|
||||
pub fn mark_active(&mut self) {
|
||||
self.last_activity = Instant::now();
|
||||
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS));
|
||||
}
|
||||
|
||||
/// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`.
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.last_activity.elapsed().as_millis() as u64 > IDLE_THRESHOLD_MS
|
||||
}
|
||||
|
||||
/// Drain all pending `TurnEvent`s from the shared mutex queue.
|
||||
///
|
||||
/// Return: a `Vec` of all events that were in the queue (may be empty).
|
||||
pub fn drain_events(
|
||||
events: &std::sync::Mutex<VecDeque<TurnEvent>>,
|
||||
) -> Vec<TurnEvent> {
|
||||
events.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventLoop {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Runtime layer: action dispatch, slash commands, short-send handling,
|
||||
//! and the LLM streaming pipeline.
|
||||
pub mod actions;
|
||||
pub mod commands;
|
||||
pub mod context;
|
||||
pub mod stream;
|
||||
@@ -0,0 +1,356 @@
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
pub mod turn;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// One atomic event extracted from an LLM streaming response stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StreamEvent {
|
||||
Token(String),
|
||||
Reasoning(String),
|
||||
ToolCallDelta {
|
||||
index: usize,
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments_delta: String,
|
||||
},
|
||||
Usage {
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
},
|
||||
Done,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Buffered SSE frame parser that accumulates raw `data:` lines and
|
||||
/// flushes a `StreamEvent` on each blank-line boundary.
|
||||
pub struct SseParser {
|
||||
buffer: String,
|
||||
event_type: Option<String>,
|
||||
data_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
/// Create a new parser with an empty buffer.
|
||||
pub fn new() -> Self {
|
||||
SseParser {
|
||||
buffer: String::new(),
|
||||
event_type: None,
|
||||
data_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a raw SSE chunk and produce any completed events.
|
||||
///
|
||||
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
|
||||
/// blank line, call `flush_event` to parse the accumulated data →
|
||||
/// on `event:` line, store the event type → on `data:` line, append
|
||||
/// to data accumulator → continue until buffer exhausted.
|
||||
///
|
||||
/// Edge case: a chunk may split mid-line; the remainder stays in the
|
||||
/// buffer for the next `feed()` call.
|
||||
///
|
||||
/// Return: all `StreamEvent`s completed by this chunk.
|
||||
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
|
||||
self.buffer.push_str(chunk);
|
||||
let mut events = Vec::new();
|
||||
while let Some(line_end) = self.buffer.find('\n') {
|
||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
||||
if line.is_empty() {
|
||||
events.extend(self.flush_event());
|
||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
||||
self.event_type = Some(ty.trim().to_string());
|
||||
} else if let Some(data) = line.strip_prefix("data:") {
|
||||
// Handle both "data: {...}" (with space) and "data:{...}"
|
||||
// (without space). Some providers omit the trailing space.
|
||||
let data = data.trim_start().to_string();
|
||||
self.data_lines.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
|
||||
///
|
||||
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
|
||||
/// emit `Usage` if a usage object is present → else match `event_type`
|
||||
/// ("message.stop", "message.delta", etc.) → extract content,
|
||||
/// reasoning, tool-call deltas, or finish-reason from the delta
|
||||
/// structure (supporting both Anthropic-style top-level delta and
|
||||
/// OpenAI-style `choices` array).
|
||||
///
|
||||
/// Why: dual-format support in one method avoids a separate
|
||||
/// provider-specific parsing layer.
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
let event_type = self.event_type.take().unwrap_or_default();
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
if data == "[DONE]" {
|
||||
return vec![StreamEvent::Done];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[stream] failed to parse chunk: {}", e);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
let prompt_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let completion_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||
prompt_tokens + completion_tokens
|
||||
});
|
||||
events.push(StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut other_events = match event_type.as_str() {
|
||||
"message.stop" => vec![StreamEvent::Done],
|
||||
"message.delta" | "" => {
|
||||
let mut d_events = Vec::new();
|
||||
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
|
||||
if let Some(choices) = delta.as_array() {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(d) = choice.get("delta") {
|
||||
// Content token
|
||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
|
||||
// Reasoning token
|
||||
if let Some(reasoning) =
|
||||
d.get("reasoning_content").and_then(|r| r.as_str())
|
||||
{
|
||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
|
||||
// Tool calls — iterate ALL entries, not just first()
|
||||
if let Some(tool_calls) =
|
||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||
{
|
||||
for tc in tool_calls {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
||||
0
|
||||
}) as usize;
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let args_delta = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
d_events.push(StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta: args_delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if let Some(reason) =
|
||||
choice.get("finish_reason").and_then(|r| r.as_str())
|
||||
{
|
||||
if reason == "stop" || reason == "tool_calls" {
|
||||
d_events.push(StreamEvent::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
}
|
||||
d_events
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
events.append(&mut other_events);
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feed_parses_single_token_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "hello"),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_handles_chunk_split_mid_line() {
|
||||
let mut p = SseParser::new();
|
||||
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
|
||||
assert!(
|
||||
e1.is_empty(),
|
||||
"no event until the line and blank separator complete"
|
||||
);
|
||||
let e2 = p.feed("\"}}]}\n\n");
|
||||
assert_eq!(e2.len(), 1);
|
||||
match &e2[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "partial"),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_emits_done_on_done_sentinel() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed("data: [DONE]\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], StreamEvent::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_emits_done_on_finish_reason_stop() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], StreamEvent::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_parses_tool_call_delta() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\"\"}}]}}]}\n\n",
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta,
|
||||
} => {
|
||||
assert_eq!(*index, 0);
|
||||
assert_eq!(id.as_deref(), Some("call_1"));
|
||||
assert_eq!(name.as_deref(), Some("bash"));
|
||||
assert_eq!(arguments_delta, "{\"cmd\"");
|
||||
}
|
||||
other => panic!("expected ToolCallDelta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_parses_usage_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(
|
||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
} => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
}
|
||||
other => panic!("expected Usage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_parses_usage_and_content_bundled_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
|
||||
);
|
||||
assert_eq!(events.len(), 2);
|
||||
match (&events[0], &events[1]) {
|
||||
(
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
StreamEvent::Token(t),
|
||||
) => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
assert_eq!(t, "hello");
|
||||
}
|
||||
other => panic!("expected [Usage, Token], got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_ignores_empty_data_lines() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(": comment\n\n");
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_multiple_events_across_one_chunk() {
|
||||
let mut p = SseParser::new();
|
||||
let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n";
|
||||
let events = p.feed(chunk);
|
||||
assert_eq!(events.len(), 2);
|
||||
match (&events[0], &events[1]) {
|
||||
(StreamEvent::Token(a), StreamEvent::Token(b)) => {
|
||||
assert_eq!(a, "a");
|
||||
assert_eq!(b, "b");
|
||||
}
|
||||
other => panic!("expected two Tokens, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Accumulates streaming LLM responses into complete message/tool-call
|
||||
//! representation via `StreamedTurn`, and provides a standalone tool-call
|
||||
//! accumulator in `tools::ToolCallAccumulator`.
|
||||
use super::StreamEvent;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::chat::tool::{ToolCall, ToolFunction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Try to repair truncated JSON by closing open strings, braces, and brackets.
|
||||
///
|
||||
/// Flow: scan character-by-character tracking string/escape state. For
|
||||
/// every `{` or `[` seen outside a string, push onto a LIFO stack; on
|
||||
/// `}`/`]` pop the matching opener (tracking remaining depth only).
|
||||
/// At the end, if the last char was a backslash (start of an escape
|
||||
/// sequence), remove it; if inside a string, append `"`; then close
|
||||
/// every unclosed opener in reverse (LIFO) order.
|
||||
///
|
||||
/// Why: LLM responses can be cut off (`max_tokens`, network) mid‑JSON
|
||||
/// string, but we want tools to receive whatever arguments were already
|
||||
/// emitted so the partial work can proceed.
|
||||
///
|
||||
/// Why LIFO vs. depth counters: `{` inside `[` must be closed with `}`
|
||||
/// *before* the `]`, not after it. Simple depth counters get the order
|
||||
/// wrong for nested heterogenous structures.
|
||||
fn repair_incomplete_json(s: &str) -> String {
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut prev_was_backslash = false;
|
||||
// `true` only when the very last character consumed was a bare `\`
|
||||
// inside a string (i.e. the start of an escape that was never completed).
|
||||
let mut ends_with_unclosed_escape = false;
|
||||
|
||||
for c in s.chars() {
|
||||
if prev_was_backslash {
|
||||
// Consume the character that was being escaped — the escape is
|
||||
// complete, so clear the unclosed-escape flag.
|
||||
prev_was_backslash = false;
|
||||
ends_with_unclosed_escape = false;
|
||||
continue;
|
||||
}
|
||||
if c == '\\' && in_string {
|
||||
prev_was_backslash = true;
|
||||
ends_with_unclosed_escape = true;
|
||||
continue;
|
||||
}
|
||||
ends_with_unclosed_escape = false;
|
||||
if c == '"' {
|
||||
in_string = !in_string;
|
||||
continue;
|
||||
}
|
||||
if in_string {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'{' | '[' => stack.push(c),
|
||||
'}' | ']' => {
|
||||
stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = s.to_string();
|
||||
if ends_with_unclosed_escape {
|
||||
// The last character is a dangling backslash that started an escape
|
||||
// but got cut off before the escaped char — remove it.
|
||||
result.pop();
|
||||
}
|
||||
if in_string {
|
||||
result.push('"');
|
||||
}
|
||||
for &opener in stack.iter().rev() {
|
||||
match opener {
|
||||
'{' => result.push('}'),
|
||||
'[' => result.push(']'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Accumulates a single streaming assistant turn into its final
|
||||
/// `ChatMessage` form, including tool-call deltas and content/reasoning.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamedTurn {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub tool_calls: Vec<ParsedToolCall>,
|
||||
pub is_complete: bool,
|
||||
pub done_received: bool,
|
||||
pub accumulated_content: String,
|
||||
pub accumulated_reasoning: String,
|
||||
}
|
||||
|
||||
/// A single tool call being built up from streaming deltas.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParsedToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
impl ParsedToolCall {}
|
||||
|
||||
impl StreamedTurn {
|
||||
/// Create an empty turn accumulator.
|
||||
pub fn new() -> Self {
|
||||
StreamedTurn {
|
||||
messages: Vec::new(),
|
||||
tool_calls: Vec::new(),
|
||||
is_complete: false,
|
||||
done_received: false,
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a `StreamEvent` to the turn, updating accumulated content,
|
||||
/// reasoning, and tool-call deltas.
|
||||
///
|
||||
/// Flow: match on variant — `Token` appends to `accumulated_content`,
|
||||
/// `Reasoning` to `accumulated_reasoning`, `ToolCallDelta` fills or
|
||||
/// grows the `tool_calls` vector, `Done` sets `is_complete = true`.
|
||||
pub fn apply_event(&mut self, event: &StreamEvent) {
|
||||
match event {
|
||||
StreamEvent::Token(token) => {
|
||||
self.accumulated_content.push_str(token);
|
||||
}
|
||||
StreamEvent::Reasoning(reasoning) => {
|
||||
self.accumulated_reasoning.push_str(reasoning);
|
||||
}
|
||||
StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta,
|
||||
} => {
|
||||
while self.tool_calls.len() <= *index {
|
||||
self.tool_calls.push(ParsedToolCall {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
arguments: String::new(),
|
||||
is_complete: false,
|
||||
});
|
||||
}
|
||||
let tc = &mut self.tool_calls[*index];
|
||||
if let Some(new_id) = id {
|
||||
if !new_id.is_empty() {
|
||||
tc.id.clone_from(new_id);
|
||||
}
|
||||
}
|
||||
if let Some(new_name) = name {
|
||||
if !new_name.is_empty() {
|
||||
tc.name.clone_from(new_name);
|
||||
}
|
||||
}
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
}
|
||||
StreamEvent::Done => {
|
||||
self.is_complete = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalise the turn into a `ChatMessage`, combining accumulated
|
||||
/// reasoning (wrapped in `<think>` tags) with content and tool calls.
|
||||
///
|
||||
/// Flow: if tool calls exist, build a `ChatMessage` with `tool_calls`
|
||||
/// set; otherwise build a plain assistant message → set `content` to
|
||||
/// the combined reasoning+content string (or `None` if empty).
|
||||
///
|
||||
/// Return: a complete `ChatMessage` with role `Assistant`.
|
||||
pub fn build_assistant_message(&self) -> ChatMessage {
|
||||
let mut msg = if self.tool_calls.is_empty() {
|
||||
ChatMessage::assistant(None)
|
||||
} else {
|
||||
let tool_dtos: Vec<ToolCall> = self
|
||||
.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.map(|tc| {
|
||||
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let repaired = repair_incomplete_json(&tc.arguments);
|
||||
match serde_json::from_str(&repaired) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' had truncated JSON \
|
||||
arguments — repaired successfully: {}",
|
||||
tc.name,
|
||||
e,
|
||||
);
|
||||
v
|
||||
}
|
||||
Err(e2) => {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' has invalid JSON \
|
||||
arguments: {} (after repair: {}) — falling \
|
||||
back to raw string",
|
||||
tc.name,
|
||||
e,
|
||||
e2,
|
||||
);
|
||||
serde_json::Value::String(tc.arguments.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
ToolCall {
|
||||
id: tc.id.clone(),
|
||||
type_: "function".to_string(),
|
||||
function: ToolFunction {
|
||||
name: tc.name.clone(),
|
||||
arguments: args_value,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut msg = ChatMessage::assistant(None);
|
||||
if !tool_dtos.is_empty() {
|
||||
msg.tool_calls = Some(tool_dtos);
|
||||
}
|
||||
msg
|
||||
};
|
||||
let full_content = if self.accumulated_reasoning.is_empty() {
|
||||
self.accumulated_content.clone()
|
||||
} else {
|
||||
format!(
|
||||
"<think>\n{}\n</think>\n\n{}",
|
||||
self.accumulated_reasoning, self.accumulated_content
|
||||
)
|
||||
};
|
||||
let content = if full_content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(full_content)
|
||||
};
|
||||
msg.content = content;
|
||||
msg
|
||||
}
|
||||
|
||||
/// Find the first named tool call whose accumulated `arguments` do not
|
||||
/// parse as valid JSON.
|
||||
///
|
||||
/// Why: a connection that closes mid-stream (no `[DONE]` event) still
|
||||
/// leaves partial argument text in the accumulator — e.g. a `write`
|
||||
/// tool call cut off mid-string. Parsing that fragment always fails,
|
||||
/// so a parse failure at end-of-stream is a reliable signal that the
|
||||
/// response was truncated, not that the model legitimately finished
|
||||
/// without sending `[DONE]`.
|
||||
///
|
||||
/// Return: `Some((name, parse_error))` for the first bad tool call, or
|
||||
/// `None` if every tool call's arguments are complete, parsable JSON.
|
||||
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
|
||||
self.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.find_map(|tc| {
|
||||
serde_json::from_str::<Value>(&tc.arguments)
|
||||
.err()
|
||||
.map(|e| (tc.name.as_str(), e.to_string()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamedTurn {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool_call(name: &str, arguments: &str) -> ParsedToolCall {
|
||||
ParsedToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
is_complete: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_closes_unclosed_string() {
|
||||
let result = repair_incomplete_json("{\"key\": \"value");
|
||||
assert_eq!(result, "{\"key\": \"value\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_closes_unclosed_object() {
|
||||
let result = repair_incomplete_json("{\"key\": \"value\"");
|
||||
assert_eq!(result, "{\"key\": \"value\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_closes_nested_structures() {
|
||||
let result = repair_incomplete_json("{\"a\": [1, 2, {\"b\": 3");
|
||||
assert_eq!(result, "{\"a\": [1, 2, {\"b\": 3}]}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_leaves_complete_json_unchanged() {
|
||||
let s = "{\"a\": 1, \"b\": \"hello\"}";
|
||||
assert_eq!(repair_incomplete_json(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_handles_trailing_backslash_before_cut() {
|
||||
// Truncated inside an escape sequence like "hello\"
|
||||
let result = repair_incomplete_json("{\"text\": \"hello\\");
|
||||
assert_eq!(result, "{\"text\": \"hello\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_handles_escaped_quotes_inside_string() {
|
||||
// Input ends with `\"` where the `"` is the escaped character
|
||||
// (consumed by the backslash handler), so the string is still
|
||||
// unterminated. Repair adds `"` to close the string and `}` to
|
||||
// close the object.
|
||||
let result = repair_incomplete_json("{\"msg\": \"he said \\\"hello\\\"");
|
||||
assert_eq!(result, "{\"msg\": \"he said \\\"hello\\\"\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_assistant_message_repairs_truncated_tool_call() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"short\", \"reason\": \"trunc",
|
||||
));
|
||||
let msg = turn.build_assistant_message();
|
||||
let tcs = msg.tool_calls.expect("should produce tool calls");
|
||||
assert_eq!(tcs.len(), 1);
|
||||
let args = &tcs[0].function.arguments;
|
||||
assert!(
|
||||
args.is_object(),
|
||||
"args should be an object after repair: {args:?}"
|
||||
);
|
||||
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt"));
|
||||
assert_eq!(args.get("content").and_then(|v| v.as_str()), Some("short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_flags_truncated_json() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||
));
|
||||
let bad = turn.incomplete_tool_call();
|
||||
assert_eq!(bad.map(|(name, _)| name), Some("write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_accepts_complete_json() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"done\"}",
|
||||
));
|
||||
assert!(turn.incomplete_tool_call().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_ignores_calls_without_a_name() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call("", "not json at all"));
|
||||
assert!(turn.incomplete_tool_call().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_call_accepts_repaired_json() {
|
||||
// `incomplete_tool_call` uses raw `serde_json::from_str` (no repair)
|
||||
// so it should still flag truncated JSON even though
|
||||
// `build_assistant_message` will later repair it.
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||
));
|
||||
// Even though it's repairable, raw parse should still fail
|
||||
assert!(serde_json::from_str::<Value>(&turn.tool_calls[0].arguments).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user