Refactor view modules for improved readability and consistency
- Updated markdown rendering logic to use more concise methods for obtaining vector lengths. - Changed review status display to use the correct flag from settings. - Cleaned up sidebar rendering code for better formatting and readability. - Enhanced status bar rendering with improved string formatting and consistent style application. - Refined workflow panel rendering, ensuring consistent style usage and improved readability. - Added architecture overview and detailed documentation for backend, data, dependencies, and frontend structures.
This commit is contained in:
+68
-133
@@ -545,44 +545,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
||||
}
|
||||
Action::Compact => {
|
||||
let Some(messages) = state.session_runtime.as_ref().map(|rt| rt.messages.clone()) else {
|
||||
return;
|
||||
};
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, msg));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let max_wire_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
|
||||
let turn_events = state.turn_events.clone();
|
||||
|
||||
state.push_toast(Toast::new(ToastKind::Info, "Compacting conversation history...".to_string()));
|
||||
|
||||
// Manual /compact previously ran synchronously and always
|
||||
// passed `client: None` to shape_messages, so it never got
|
||||
// LLM summarization — only automatic mid-turn compaction did.
|
||||
// Running this on a background thread (same pattern as
|
||||
// spawn_turn) fixes that asymmetry: both paths now summarize
|
||||
// dropped history with the LLM instead of one silently
|
||||
// falling back to a bare placeholder.
|
||||
std::thread::spawn(move || {
|
||||
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
|
||||
let (deduped, _) = crate::app::runtime::context::dedup::collapse(&messages);
|
||||
let token_count: usize = deduped.iter()
|
||||
.map(crate::app::runtime::context::tokens::count_message_tokens)
|
||||
let max_wire_tokens = state.app_config.model_roles.values()
|
||||
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
let total_chars: usize = rt.messages.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let compacted = crate::app::runtime::context::shaping::shape_messages(
|
||||
&deduped, token_count, max_wire_tokens, true, Some(&client),
|
||||
);
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::Compacted(compacted));
|
||||
}
|
||||
});
|
||||
let token_estimate = total_chars / 3;
|
||||
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
|
||||
state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string()));
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
Action::LessonAccept { name } => {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
@@ -623,45 +600,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the API key, model name, and base URL for the currently
|
||||
/// configured provider.
|
||||
///
|
||||
/// Flow: look up the provider's `ProviderConfig` for its `api_base` ->
|
||||
/// resolve the API key from `Settings.api_keys`, falling back to the
|
||||
/// provider's `api_key_env` environment variable, then its
|
||||
/// `default_api_key`, then the crate-wide empty-string default.
|
||||
///
|
||||
/// Why: this exact resolution was duplicated between `spawn_turn` and
|
||||
/// needed again for `Action::Compact`'s background-thread LLM call —
|
||||
/// factored out so both stay in sync.
|
||||
///
|
||||
/// Return: `Ok((api_key, model, base_url))`, or `Err(message)` — a
|
||||
/// user-facing string — if the configured provider has no entry in
|
||||
/// `AppConfig.providers` at all.
|
||||
fn resolve_llm_client_config(state: &AppStateRest) -> Result<(String, String, Option<String>), String> {
|
||||
let base_url = state.app_config.providers.get(&state.settings.provider).map(|p| p.api_base.clone());
|
||||
let Some(base_url) = base_url else {
|
||||
return Err(format!(
|
||||
"Provider '{}' is not configured — no matching entry found. \
|
||||
Pick a different provider in Settings, or configure it.",
|
||||
state.settings.provider
|
||||
));
|
||||
};
|
||||
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
||||
if api_key.is_empty() {
|
||||
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
||||
api_key = provider_cfg.api_key_env.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
.or_else(|| provider_cfg.default_api_key.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
|
||||
}
|
||||
Ok((api_key, state.settings.model.clone(), Some(base_url)))
|
||||
}
|
||||
|
||||
/// Spawn a background thread that runs one full LLM turn.
|
||||
///
|
||||
/// Flow: check that no turn is currently in-flight → bail if so →
|
||||
@@ -692,17 +630,42 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
if let Ok(mut q) = state.turn_events.lock() {
|
||||
q.push_back(TurnEvent::Error(msg));
|
||||
}
|
||||
return;
|
||||
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
||||
let model = state.settings.model.clone();
|
||||
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||
.map(|p| p.api_base.clone());
|
||||
let context_window = state.app_config.model_roles.values()
|
||||
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||
// The selected provider has no entry in app_config at all (e.g. the
|
||||
// Claude-settings auto-detection that registers "claude" found nothing
|
||||
// this run). Without this check, LlmClient::new silently falls back to
|
||||
// the zen default base URL while keeping this provider's model name —
|
||||
// a mismatched request that reaches a real server and comes back as a
|
||||
// confusing "Missing API key" 401 from an unrelated provider, instead
|
||||
// of the actual problem: the configured provider doesn't exist.
|
||||
if base_url.is_none() {
|
||||
if let Ok(mut q) = state.turn_events.lock() {
|
||||
q.push_back(TurnEvent::Error(format!(
|
||||
"Provider '{}' is not configured — no matching entry found. \
|
||||
Pick a different provider in Settings, or configure it.",
|
||||
state.settings.provider
|
||||
)));
|
||||
}
|
||||
};
|
||||
let context_window = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
|
||||
let concise_output = state.settings.concise_output;
|
||||
return;
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
||||
api_key = provider_cfg.api_key_env.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
.or_else(|| provider_cfg.default_api_key.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
|
||||
}
|
||||
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
|
||||
state.misc.effort_level,
|
||||
state.settings.max_tokens,
|
||||
@@ -747,7 +710,6 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
max_tokens,
|
||||
abort_flag,
|
||||
hive_mind_converged,
|
||||
concise_output,
|
||||
};
|
||||
let result = run_agent_turn(&tc, &messages, &events_q);
|
||||
if let Err(e) = result {
|
||||
@@ -780,10 +742,6 @@ struct TurnCtx {
|
||||
/// of this turn — whether a hive-mind convergence already completed
|
||||
/// earlier in this session.
|
||||
hive_mind_converged: bool,
|
||||
/// Snapshot of `Settings.concise_output` taken at the start of this
|
||||
/// turn, so the system-prompt assembly above can read it without
|
||||
/// `TurnCtx` needing a `Settings` reference.
|
||||
concise_output: bool,
|
||||
}
|
||||
|
||||
/// Build an ASCII tree of the workspace directory structure for the
|
||||
@@ -933,9 +891,8 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence
|
||||
/// handle tool calls, and loop until the LLM produces a non-tool response
|
||||
/// or runs out of unfinished todo items.
|
||||
///
|
||||
/// Flow: build system prompt with workspace tree → deduplicate messages
|
||||
/// via `context::dedup::collapse` → optionally shape (compact) messages via
|
||||
/// `context::shaping::{should_shape, shape_messages}` → call `chat_with_tools_streaming`
|
||||
/// Flow: build system prompt with workspace tree → optionally shape
|
||||
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
|
||||
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
|
||||
/// and `Usage` events → on streaming success, handle tool calls (gated
|
||||
/// through `Harness::gate_tool_call`) or unwrap the final assistant
|
||||
@@ -970,23 +927,12 @@ fn run_agent_turn(
|
||||
// workspace tree and reads all memory files each time).
|
||||
let tree_info = generate_workspace_tree(&tc.workspace_roots);
|
||||
let memory_section = build_memory_section(&tc.ctx.memory_dir);
|
||||
let concise_section = if tc.concise_output {
|
||||
"\n\nWrite tersely: drop articles (a/an/the), filler words (just/really/basically/\
|
||||
actually/simply), pleasantries (sure/certainly/of course/happy to), and hedging. \
|
||||
Fragments are fine. Code, commands, file paths, and error text must stay byte-exact \
|
||||
— never abbreviate or paraphrase those. Exception: for destructive-operation \
|
||||
confirmations and security-relevant warnings, always give full detail regardless of \
|
||||
this instruction — clarity matters more than brevity when something risky is at stake."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let system_text = format!(
|
||||
"{}\n\n{}\n\n{}{}{}",
|
||||
"{}\n\n{}\n\n{}{}",
|
||||
crate::resources::SYSTEM_PROMPT,
|
||||
crate::resources::SYSTEM_TOOLS,
|
||||
tree_info,
|
||||
memory_section,
|
||||
concise_section,
|
||||
);
|
||||
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
|
||||
let sys = ChatMessage::system(system_text);
|
||||
@@ -1198,43 +1144,33 @@ fn run_agent_turn(
|
||||
let mut todo_retry_count = 0usize;
|
||||
|
||||
loop {
|
||||
// Dedup runs every iteration, unconditionally — repeated
|
||||
// read-only tool calls (same tool + same arguments) are
|
||||
// collapsed to their latest result before anything else, so
|
||||
// context stays minimal from turn 1 instead of only shrinking
|
||||
// once shaping's budget threshold trips.
|
||||
let (deduped, dedup_changed) = crate::app::runtime::context::dedup::collapse(&msgs);
|
||||
let token_count: usize = deduped.iter()
|
||||
.map(crate::app::runtime::context::tokens::count_message_tokens)
|
||||
let total_chars: usize = msgs.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let token_estimate = total_chars / 4;
|
||||
let max_wire_tokens = tc.context_window;
|
||||
|
||||
// Skip shaping if abort was requested — the non-streaming LLM
|
||||
// call for summarization would block without checking abort_flag.
|
||||
// Skip message compaction if abort was requested — the non-streaming
|
||||
// LLM call for summarization would block without checking abort_flag.
|
||||
let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst)
|
||||
&& crate::app::runtime::context::shaping::should_shape(token_count, max_wire_tokens, prev_shaped)
|
||||
&& crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped)
|
||||
{
|
||||
prev_shaped = true;
|
||||
let compacted = crate::app::runtime::context::shaping::shape_messages(&deduped, token_count, max_wire_tokens, false, Some(&tc.client));
|
||||
|
||||
// Dispatch to the main thread so the local session history is
|
||||
// permanently updated and doesn't re-trigger shaping immediately
|
||||
// on the next turn.
|
||||
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
|
||||
|
||||
// Dispatch the compacted messages to the main thread so the local session history
|
||||
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Compacted(compacted.clone()));
|
||||
}
|
||||
|
||||
|
||||
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
|
||||
msgs.clone_from(&compacted);
|
||||
compacted
|
||||
} else {
|
||||
prev_shaped = false;
|
||||
if dedup_changed {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Compacted(deduped.clone()));
|
||||
}
|
||||
msgs.clone_from(&deduped);
|
||||
}
|
||||
deduped
|
||||
msgs.clone()
|
||||
};
|
||||
|
||||
let mut stream_started = false;
|
||||
@@ -1481,8 +1417,7 @@ fn run_agent_turn(
|
||||
}
|
||||
}
|
||||
|
||||
let squashed_output = crate::app::runtime::context::squash::apply(&tool_name, &output);
|
||||
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), squashed_output);
|
||||
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg);
|
||||
msgs.push(tool_msg);
|
||||
}
|
||||
@@ -1677,7 +1612,7 @@ fn execute_one_tool(
|
||||
/// `should_trigger_review` on `Tick`), only informs the user that
|
||||
/// a review has material to examine.
|
||||
fn maybe_trigger_review(state: &mut AppStateRest) {
|
||||
if !state.settings.review_enabled {
|
||||
if !state.settings.flags.review_enabled {
|
||||
return;
|
||||
}
|
||||
let edit_count = state
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Maps parsed `/` slash commands into one or more `Action` variants
|
||||
//! that `apply_action` can process.
|
||||
use crate::controller::command::Command;
|
||||
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.
|
||||
///
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
//! `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 std::collections::HashMap;
|
||||
use sha2::Digest;
|
||||
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]";
|
||||
@@ -50,7 +49,9 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = &m.tool_call_id else { continue };
|
||||
let Some((name, args)) = call_info.get(id) else { continue };
|
||||
let Some((name, args)) = call_info.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !READ_TOOLS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
@@ -58,22 +59,30 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
||||
}
|
||||
|
||||
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();
|
||||
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)
|
||||
}
|
||||
@@ -102,7 +111,10 @@ mod tests {
|
||||
m.tool_calls = Some(vec![ToolCall {
|
||||
id: id.to_string(),
|
||||
type_: "function".to_string(),
|
||||
function: ToolFunction { name: name.to_string(), arguments: args },
|
||||
function: ToolFunction {
|
||||
name: name.to_string(),
|
||||
arguments: args,
|
||||
},
|
||||
}]);
|
||||
m
|
||||
}
|
||||
@@ -172,9 +184,10 @@ mod tests {
|
||||
|
||||
#[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 messages = vec![ChatMessage::tool_result(
|
||||
"orphan-id".to_string(),
|
||||
"some result".to_string(),
|
||||
)];
|
||||
|
||||
let (result, changed) = collapse(&messages);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
//! 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;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! 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;
|
||||
|
||||
@@ -101,7 +100,8 @@ pub fn shape_messages(
|
||||
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]");
|
||||
summary_text =
|
||||
format!("[Summary of compacted prior conversation:\n{content}\n]");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -136,7 +136,10 @@ mod tests {
|
||||
|
||||
#[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(900, 1000, true),
|
||||
"below 95% and already shaped: no re-trigger yet"
|
||||
);
|
||||
assert!(should_shape(950, 1000, true));
|
||||
}
|
||||
|
||||
@@ -186,9 +189,9 @@ mod tests {
|
||||
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]")
|
||||
});
|
||||
let has_placeholder = result
|
||||
.iter()
|
||||
.any(|m| m.content.as_deref() == Some("[prior conversation compacted]"));
|
||||
assert!(has_placeholder);
|
||||
}
|
||||
|
||||
@@ -200,6 +203,9 @@ mod tests {
|
||||
}
|
||||
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");
|
||||
assert!(
|
||||
result.iter().any(|m| m.content == last_content),
|
||||
"most recent message must survive shaping"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! 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;
|
||||
|
||||
@@ -220,12 +219,24 @@ fn squash_log(text: &str) -> String {
|
||||
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));
|
||||
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));
|
||||
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();
|
||||
@@ -257,7 +268,10 @@ fn squash_generic(text: &str, budget: usize) -> String {
|
||||
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>();
|
||||
+ 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;
|
||||
@@ -324,8 +338,8 @@ mod tests {
|
||||
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");
|
||||
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");
|
||||
@@ -357,7 +371,11 @@ mod tests {
|
||||
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_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");
|
||||
}
|
||||
|
||||
@@ -412,20 +430,34 @@ mod tests {
|
||||
|
||||
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");
|
||||
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 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("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());
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! `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`.
|
||||
@@ -21,7 +20,9 @@ use crate::dto::chat::message::ChatMessage;
|
||||
/// (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()
|
||||
tiktoken_rs::o200k_base_singleton()
|
||||
.encode_ordinary(text)
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Count tokens in a `ChatMessage`'s text content.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! 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;
|
||||
|
||||
@@ -18,7 +17,9 @@ use crate::model::settings::Settings;
|
||||
///
|
||||
/// Return: always a concrete token count, never "unknown".
|
||||
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
|
||||
app_config.model_roles.values()
|
||||
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
|
||||
@@ -32,13 +33,16 @@ mod tests {
|
||||
#[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,
|
||||
});
|
||||
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();
|
||||
@@ -53,23 +57,32 @@ mod tests {
|
||||
settings.provider = "nonexistent".to_string();
|
||||
settings.model = "nonexistent-model".to_string();
|
||||
|
||||
assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize);
|
||||
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,
|
||||
});
|
||||
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);
|
||||
assert_eq!(
|
||||
resolve(&app_config, &settings),
|
||||
app_config.default_context_window as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
pub mod turn;
|
||||
pub mod tools;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -89,7 +87,7 @@ impl SseParser {
|
||||
/// provider-specific parsing layer.
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
@@ -112,20 +110,32 @@ impl SseParser {
|
||||
|
||||
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)
|
||||
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 });
|
||||
events.push(StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,23 +153,32 @@ impl SseParser {
|
||||
}
|
||||
|
||||
// Reasoning token
|
||||
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||
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()) {
|
||||
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")
|
||||
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")
|
||||
let args_delta = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
@@ -174,7 +193,9 @@ impl SseParser {
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
||||
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);
|
||||
}
|
||||
@@ -193,72 +214,6 @@ impl SseParser {
|
||||
events.append(&mut other_events);
|
||||
events
|
||||
}
|
||||
|
||||
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
|
||||
/// reuse a parser instance across requests rather than constructing a fresh one.
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.event_type = None;
|
||||
self.data_lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
|
||||
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
|
||||
/// standard SSE framing directly), kept for providers/tests that feed raw chunks.
|
||||
///
|
||||
/// Flow: parse `data` as JSON → extract first `choices[0].delta` →
|
||||
/// return a `Token`, `Reasoning`, `Done`, or `ToolCallDelta` event based
|
||||
/// on the fields present.
|
||||
///
|
||||
/// Return: `Some(StreamEvent)` if the chunk contained recognisable
|
||||
/// content, `None` otherwise.
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
|
||||
let value: Value = serde_json::from_str(data).ok()?;
|
||||
if value == Value::Null {
|
||||
return None;
|
||||
}
|
||||
let choices = value.get("choices")?.as_array()?;
|
||||
let choice = choices.first()?;
|
||||
let delta = choice.get("delta")?;
|
||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
return Some(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||
return Some(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
if let Some(finish) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
||||
if finish == "stop" || finish == "tool_calls" {
|
||||
return Some(StreamEvent::Done);
|
||||
}
|
||||
}
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
if let Some(tc) = tool_calls.first() {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] fallback parser: tool call 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 = tc.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
return Some(StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta: args,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -280,7 +235,10 @@ mod tests {
|
||||
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");
|
||||
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] {
|
||||
@@ -300,9 +258,7 @@ mod tests {
|
||||
#[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",
|
||||
);
|
||||
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], StreamEvent::Done));
|
||||
}
|
||||
@@ -315,7 +271,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::ToolCallDelta { index, id, name, arguments_delta } => {
|
||||
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"));
|
||||
@@ -333,7 +294,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens } => {
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
} => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
@@ -351,7 +316,11 @@ mod tests {
|
||||
assert_eq!(events.len(), 2);
|
||||
match (&events[0], &events[1]) {
|
||||
(
|
||||
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens },
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
StreamEvent::Token(t),
|
||||
) => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
//! Standalone accumulator for streamed tool-call deltas.
|
||||
//!
|
||||
//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id,
|
||||
//! name, arguments_delta)` chunks as they arrive over SSE → grows its
|
||||
//! internal `Vec<ParsedToolCall>` as needed → `is_complete` reports once
|
||||
//! every accumulated call has both a name and arguments.
|
||||
//!
|
||||
//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event`
|
||||
//! but as an independent, reusable type for callers that want to track
|
||||
//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight
|
||||
//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future
|
||||
//! use case.
|
||||
|
||||
use super::turn::ParsedToolCall;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Standalone tool-call delta accumulator, functionally equivalent to the accumulation
|
||||
/// logic built into `StreamedTurn::apply_event`. Reserved for callers that want to track
|
||||
/// tool-call deltas independently of a full `StreamedTurn` (e.g. a lighter-weight preview).
|
||||
#[allow(dead_code)]
|
||||
pub struct ToolCallAccumulator {
|
||||
calls: Vec<ParsedToolCall>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ToolCallAccumulator {
|
||||
/// Construct an empty accumulator with no tool calls tracked yet.
|
||||
///
|
||||
/// Return: a fresh `ToolCallAccumulator`.
|
||||
pub fn new() -> Self {
|
||||
ToolCallAccumulator { calls: Vec::new() }
|
||||
}
|
||||
|
||||
/// Append a delta to the tool call at the given index, growing the
|
||||
/// calls vector if needed.
|
||||
pub fn add_delta(
|
||||
&mut self,
|
||||
index: usize,
|
||||
id: Option<&str>,
|
||||
name: Option<&str>,
|
||||
arguments_delta: &str,
|
||||
) {
|
||||
while self.calls.len() <= index {
|
||||
self.calls.push(ParsedToolCall {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
arguments: String::new(),
|
||||
is_complete: false,
|
||||
});
|
||||
}
|
||||
let tc = &mut self.calls[index];
|
||||
if let Some(new_id) = id {
|
||||
if !new_id.is_empty() {
|
||||
tc.id = new_id.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(new_name) = name {
|
||||
if !new_name.is_empty() {
|
||||
tc.name = new_name.to_string();
|
||||
}
|
||||
}
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
}
|
||||
|
||||
/// Borrow the accumulated tool calls.
|
||||
pub fn calls(&self) -> &[ParsedToolCall] {
|
||||
&self.calls
|
||||
}
|
||||
|
||||
/// Return true once all tool calls have both a name and arguments.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
!self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty())
|
||||
}
|
||||
|
||||
/// Clear all accumulated calls (starting a fresh turn).
|
||||
pub fn reset(&mut self) {
|
||||
self.calls.clear();
|
||||
}
|
||||
|
||||
/// Build a JSON-serialisable `Vec<Value>` of pending (non-empty-name)
|
||||
/// tool calls, suitable for downstream inspection or replay.
|
||||
pub fn pending_args(&self) -> Vec<Value> {
|
||||
self.calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.map(|tc| {
|
||||
json!({
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.name,
|
||||
"arguments": tc.arguments,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolCallAccumulator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -101,17 +101,7 @@ pub struct ParsedToolCall {
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
impl ParsedToolCall {
|
||||
/// Attempt to parse the accumulated argument string as JSON before
|
||||
/// the tool call is marked complete — useful for a speculative preview.
|
||||
///
|
||||
/// Return: `Some(Value)` if the arguments are parsable JSON, `None`
|
||||
/// if still partial.
|
||||
#[allow(dead_code)]
|
||||
pub fn try_parse(&self) -> Option<Value> {
|
||||
serde_json::from_str(&self.arguments).ok()
|
||||
}
|
||||
}
|
||||
impl ParsedToolCall {}
|
||||
|
||||
impl StreamedTurn {
|
||||
/// Create an empty turn accumulator.
|
||||
@@ -186,12 +176,12 @@ impl StreamedTurn {
|
||||
let mut msg = if self.tool_calls.is_empty() {
|
||||
ChatMessage::assistant(None)
|
||||
} else {
|
||||
let tool_dtos: Vec<ToolCall> = self.tool_calls
|
||||
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)
|
||||
{
|
||||
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);
|
||||
@@ -200,7 +190,8 @@ impl StreamedTurn {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' had truncated JSON \
|
||||
arguments — repaired successfully: {}",
|
||||
tc.name, e,
|
||||
tc.name,
|
||||
e,
|
||||
);
|
||||
v
|
||||
}
|
||||
@@ -209,7 +200,9 @@ impl StreamedTurn {
|
||||
"[stream] tool call '{}' has invalid JSON \
|
||||
arguments: {} (after repair: {}) — falling \
|
||||
back to raw string",
|
||||
tc.name, e, e2,
|
||||
tc.name,
|
||||
e,
|
||||
e2,
|
||||
);
|
||||
serde_json::Value::String(tc.arguments.clone())
|
||||
}
|
||||
@@ -235,7 +228,10 @@ impl StreamedTurn {
|
||||
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)
|
||||
format!(
|
||||
"<think>\n{}\n</think>\n\n{}",
|
||||
self.accumulated_reasoning, self.accumulated_content
|
||||
)
|
||||
};
|
||||
let content = if full_content.is_empty() {
|
||||
None
|
||||
@@ -259,7 +255,8 @@ impl StreamedTurn {
|
||||
/// 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()
|
||||
self.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.find_map(|tc| {
|
||||
serde_json::from_str::<Value>(&tc.arguments)
|
||||
@@ -267,19 +264,6 @@ impl StreamedTurn {
|
||||
.map(|e| (tc.name.as_str(), e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Reserved accessor for callers that want to branch mid-stream before the turn
|
||||
/// completes; the current wiring only inspects the final `build_assistant_message()`.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
self.tool_calls.iter().any(|tc| !tc.name.is_empty())
|
||||
}
|
||||
|
||||
/// Reserved accessor mirroring `has_tool_calls` for mid-stream content peeks.
|
||||
#[allow(dead_code)]
|
||||
pub fn content(&self) -> &str {
|
||||
&self.accumulated_content
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamedTurn {
|
||||
@@ -353,7 +337,10 @@ mod tests {
|
||||
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!(
|
||||
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"));
|
||||
}
|
||||
@@ -361,7 +348,10 @@ mod tests {
|
||||
#[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"));
|
||||
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"));
|
||||
}
|
||||
@@ -369,7 +359,10 @@ mod tests {
|
||||
#[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\"}"));
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"done\"}",
|
||||
));
|
||||
assert!(turn.incomplete_tool_call().is_none());
|
||||
}
|
||||
|
||||
@@ -386,7 +379,10 @@ mod tests {
|
||||
// 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"));
|
||||
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