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:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
@@ -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
);
}
}