docs: tambah doc comment, logging, dan inline comments di semua 255 file
Meliputi: - File-level //! doc comment: tujuan file, alur kerja, komponen utama - Function-level /// doc comment: apa, parameter, return, flow, edge cases - Struct/enum/trait /// doc comment: peran, field docs - Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi - Inline comments untuk variable dan branching logic penting - Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities, zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils - Build: 0 errors, 242/242 tests passed
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
#![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.
|
||||
@@ -19,6 +18,7 @@ use crate::app::subagent::division::tool_scope::READ_TOOLS;
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
use sha2::Digest;
|
||||
use std::collections::HashMap;
|
||||
use tracing;
|
||||
|
||||
const DUPLICATE_PLACEHOLDER: &str =
|
||||
"[duplicate result — superseded by a later identical call, see below]";
|
||||
@@ -29,7 +29,15 @@ const DUPLICATE_PLACEHOLDER: &str =
|
||||
/// `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`.
|
||||
///
|
||||
/// # Status
|
||||
///
|
||||
/// This function is defined but not yet wired into the compaction loop;
|
||||
/// it will be called from the per-turn auto-compaction pass once the
|
||||
/// shaping integration is complete.
|
||||
#[expect(dead_code, reason = "will be wired into the compaction loop")]
|
||||
pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
||||
tracing::debug!(n_messages = messages.len(), "dedup::collapse — start");
|
||||
// tool_call_id -> (tool name, canonical JSON of its arguments)
|
||||
let mut call_info: HashMap<String, (String, String)> = HashMap::new();
|
||||
for m in messages {
|
||||
@@ -84,6 +92,7 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::debug!(changed, "dedup::collapse — done");
|
||||
(result, changed)
|
||||
}
|
||||
|
||||
@@ -101,6 +110,9 @@ fn dedup_key(tool_name: &str, canonical_args: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for tool-result dedup: identical read-tool calls are
|
||||
//! collapsed, different args / mutating tools are left untouched,
|
||||
//! and orphaned tool results pass through unchanged.
|
||||
use super::*;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::chat::tool::{ToolCall, ToolFunction};
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
//! per-result compression, budget-based shaping, and shared
|
||||
//! context-window resolution — replaces `runtime::shortsend`.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! | Module | Responsibility |
|
||||
//! |------------|----------------------------------------------------------|
|
||||
//! | `dedup` | Cross-call deduplication of repeated tool results |
|
||||
//! | `shaping` | Budget-based message shaping within the context window |
|
||||
//! | `squash` | Per-result compression (summarisation / truncation) |
|
||||
//! | `tokens` | Token counting and estimation |
|
||||
//! | `window` | Resolve the active model's context-window size |
|
||||
//!
|
||||
//! # Call-sites
|
||||
//!
|
||||
//! 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
|
||||
|
||||
@@ -68,6 +68,10 @@ const FORCE_KEEP_MAX: usize = 15;
|
||||
const SUMMARY_PREFIX: &str = "[Summary of compacted prior conversation:";
|
||||
|
||||
/// Detect whether a message contains a previous compaction summary.
|
||||
///
|
||||
/// Flow: check if message `content` starts with [`SUMMARY_PREFIX`].
|
||||
/// Used to filter out old summaries from the "dropped" set so they
|
||||
/// are handled by progressive summarization instead.
|
||||
fn msg_has_prior_summary(m: &ChatMessage) -> bool {
|
||||
m.content
|
||||
.as_deref()
|
||||
@@ -77,6 +81,13 @@ fn msg_has_prior_summary(m: &ChatMessage) -> bool {
|
||||
/// Format dropped messages for the summarization prompt, excluding any
|
||||
/// messages that are themselves previous summaries (those are handled
|
||||
/// separately by progressive summarization).
|
||||
///
|
||||
/// Flow: filter out prior-summary messages → for each remaining message,
|
||||
/// render a `[Role]: content` line with optional tool-call list appended.
|
||||
/// Join entries with `\n\n---\n\n` as separator.
|
||||
///
|
||||
/// Return: a single string suitable as the `### New messages to merge`
|
||||
/// section of the summarization prompt.
|
||||
fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
|
||||
dropped
|
||||
.iter()
|
||||
@@ -106,6 +117,13 @@ fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
|
||||
}
|
||||
|
||||
/// Extract the content of a previous compaction summary from a message.
|
||||
///
|
||||
/// Flow: check if `content` starts with [`SUMMARY_PREFIX`] → strip prefix
|
||||
/// and trailing `]` → return inner text. Returns `None` if the message
|
||||
/// is not a prior-summary message.
|
||||
///
|
||||
/// Why: progressive summarization needs the old summary text so the LLM
|
||||
/// can merge it with new context instead of starting from scratch.
|
||||
fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
|
||||
let content = m.content.as_deref()?;
|
||||
if content.starts_with(SUMMARY_PREFIX) {
|
||||
@@ -124,6 +142,13 @@ fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
|
||||
/// Build the summarization prompt, supporting progressive compaction:
|
||||
/// if the dropped messages contain a previous summary, it is extracted
|
||||
/// and the new prompt asks the LLM to build on it.
|
||||
///
|
||||
/// Flow: search dropped messages for a prior summary via `extract_prior_summary`.
|
||||
/// If found, emit a "build on this" prompt with the previous summary + new
|
||||
/// content. Otherwise emit a plain "summarize this history" prompt.
|
||||
/// In both cases the prompt requests a structured 5-section summary.
|
||||
///
|
||||
/// Return: a fully-formed user-style prompt string ready to send to the LLM.
|
||||
fn build_summarization_prompt(
|
||||
dropped_msgs: &[ChatMessage],
|
||||
dropped_content: &str,
|
||||
@@ -174,6 +199,14 @@ fn build_summarization_prompt(
|
||||
/// `[prior conversation compacted]` placeholder — it tells the LLM how
|
||||
/// many messages of each role were dropped and what tools were used,
|
||||
/// preserving key structural context.
|
||||
///
|
||||
/// Flow: count messages by role → collect unique tool names → extract
|
||||
/// the last user message as a hint → format as:
|
||||
/// `[prior conversation: N user, M assistant, ... | tools used: ... | last request: ...]`
|
||||
///
|
||||
/// Why: a static placeholder provides zero useful context. Even without
|
||||
/// AI summarization, structural metadata helps the LLM understand what
|
||||
/// was lost.
|
||||
fn make_structural_summary(dropped: &[ChatMessage]) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
@@ -244,11 +277,22 @@ pub fn shape_messages(
|
||||
client: Option<&crate::service::provider::LlmClient>,
|
||||
abort_flag: Option<&AtomicBool>,
|
||||
) -> Vec<ChatMessage> {
|
||||
tracing::debug!(
|
||||
n_messages = messages.len(),
|
||||
token_count,
|
||||
max_wire_tokens,
|
||||
force,
|
||||
has_client = client.is_some(),
|
||||
"shape_messages — entry"
|
||||
);
|
||||
|
||||
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
|
||||
tracing::debug!("shape_messages — under budget or too few messages, no-op");
|
||||
return messages.to_vec();
|
||||
}
|
||||
|
||||
if force && messages.len() < 5 {
|
||||
tracing::debug!("shape_messages — force but fewer than 5 messages, no-op");
|
||||
return messages.to_vec();
|
||||
}
|
||||
|
||||
@@ -352,10 +396,12 @@ pub fn shape_messages(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("shape_messages — summarization aborted by user, using structural summary");
|
||||
make_structural_summary(&dropped_msgs)
|
||||
}
|
||||
} else {
|
||||
// No LLM client available (tests / edge case with no provider).
|
||||
tracing::debug!("shape_messages — no LLM client, using structural summary");
|
||||
make_structural_summary(&dropped_msgs)
|
||||
};
|
||||
|
||||
@@ -363,11 +409,19 @@ pub fn shape_messages(
|
||||
}
|
||||
|
||||
result.extend(keep_recent.into_iter().rev());
|
||||
|
||||
tracing::debug!(
|
||||
result_len = result.len(),
|
||||
dropped = dropped_msgs.len(),
|
||||
"shape_messages — done"
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for message shaping: threshold hysteresis, system-message
|
||||
//! preservation, structural-summary fallback, and most-recent survival.
|
||||
use super::*;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
//! Per-tool-result compression: shrink large tool outputs before they
|
||||
//! ever enter conversation history, dispatching by content shape.
|
||||
//!
|
||||
@@ -12,6 +11,7 @@
|
||||
//! overall budget.
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
use tracing;
|
||||
|
||||
/// Below this size, compression isn't worth the risk of losing detail —
|
||||
/// pass the output through unchanged.
|
||||
@@ -46,16 +46,28 @@ const LOG_SHAPED_TOOLS: &[&str] = &["bash"];
|
||||
/// 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.
|
||||
///
|
||||
/// # Status
|
||||
///
|
||||
/// Defined but not yet wired into the tool-execution pipeline; will be
|
||||
/// called from `tool::shell` and MCP result handlers once integration
|
||||
/// is complete.
|
||||
#[expect(dead_code, reason = "will be wired into the tool-execution pipeline")]
|
||||
pub fn apply(tool_name: &str, output: &str) -> String {
|
||||
tracing::trace!(tool_name, output_len = output.len(), "squash::apply — start");
|
||||
if NEVER_SQUASH.contains(&tool_name) || output.len() <= SQUASH_FLOOR_BYTES {
|
||||
tracing::trace!(tool_name, "squash::apply — passthrough (never-squash tool or under floor)");
|
||||
return output.to_string();
|
||||
}
|
||||
if serde_json::from_str::<serde_json::Value>(output).is_ok() {
|
||||
tracing::trace!(tool_name, "squash::apply — routing to squash_json");
|
||||
return squash_json(output);
|
||||
}
|
||||
if LOG_SHAPED_TOOLS.contains(&tool_name) && looks_log_shaped(output) {
|
||||
tracing::trace!(tool_name, "squash::apply — routing to squash_log");
|
||||
return squash_log(output);
|
||||
}
|
||||
tracing::trace!(tool_name, "squash::apply — routing to squash_generic");
|
||||
squash_generic(output, GENERIC_BUDGET_BYTES)
|
||||
}
|
||||
|
||||
@@ -287,6 +299,16 @@ fn squash_generic(text: &str, budget: usize) -> String {
|
||||
|
||||
/// Render a subset of `lines` in order, inserting a `[N lines omitted]`
|
||||
/// marker at every gap between kept lines.
|
||||
///
|
||||
/// Flow: sort kept indices → iterate; for each kept line, if a gap
|
||||
/// exists before it write `[N lines omitted]`, then write the line.
|
||||
/// After all kept lines, write a final omission marker if lines remain.
|
||||
///
|
||||
/// Why `[N lines omitted]` instead of a comment-shaped marker: the
|
||||
/// `rtk` project's own regression tests found that comment shapes get
|
||||
/// parsed by the LLM as code and trigger a retry loop.
|
||||
///
|
||||
/// Return: rendered string with kept lines in original order.
|
||||
fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
|
||||
let mut kept_sorted: Vec<usize> = keep.iter().copied().collect();
|
||||
kept_sorted.sort_unstable();
|
||||
@@ -309,6 +331,9 @@ fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for tool-result squashing: floor threshold, read-tool
|
||||
//! exemption, JSON structure preservation, log compression, and
|
||||
//! generic truncation with head/tail retention.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! closer than a flat byte-per-token guess; it's only used for the
|
||||
//! 85%/95% budget thresholds, not for billing-accurate counts.
|
||||
|
||||
use tracing;
|
||||
|
||||
/// Count tokens in a single string under `o200k_base`.
|
||||
///
|
||||
@@ -20,17 +21,23 @@
|
||||
/// (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()
|
||||
let count = tiktoken_rs::o200k_base_singleton()
|
||||
.encode_ordinary(text)
|
||||
.len()
|
||||
.len();
|
||||
tracing::trace!(len = text.len(), count, "count_tokens");
|
||||
count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Unit tests for token counting: empty strings, known phrases, code,
|
||||
//! and ChatMessage content extraction.
|
||||
use super::*;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
/// Count tokens in a `ChatMessage`'s text content.
|
||||
///
|
||||
/// Returns 0 when the message has no content (None).
|
||||
fn count_message_tokens(msg: &ChatMessage) -> usize {
|
||||
msg.content.as_deref().map_or(0, count_tokens)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#![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 tracing::debug;
|
||||
use zesdex_cms::domain::app_config::AppConfig;
|
||||
use zesdex_cms::domain::settings::Settings;
|
||||
|
||||
@@ -15,14 +15,31 @@ use zesdex_cms::domain::settings::Settings;
|
||||
/// `settings` -> use its `context_window` if set -> otherwise fall back
|
||||
/// to `app_config.default_context_window`.
|
||||
///
|
||||
/// # Tracing
|
||||
/// Outputs a `tracing::debug!` event with the resolved token count and
|
||||
/// matching role name (or "fallback") at each call site.
|
||||
///
|
||||
/// 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)
|
||||
// Search model roles for one matching the active provider + model pair
|
||||
let matched = app_config.model_roles.values().find(|role| {
|
||||
role.provider == settings.provider && role.model == settings.model
|
||||
});
|
||||
|
||||
// Use the role's explicit context_window, or fall back to the default
|
||||
let tokens: usize = matched
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(app_config.default_context_window) as usize
|
||||
.unwrap_or(app_config.default_context_window) as usize;
|
||||
|
||||
debug!(
|
||||
provider = %settings.provider,
|
||||
model = %settings.model,
|
||||
tokens,
|
||||
source = if matched.is_some() { "model_role" } else { "default_fallback" },
|
||||
"resolved context-window size",
|
||||
);
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user