From c5d6fbef0851e7f76586780c2ad5ce523570854c Mon Sep 17 00:00:00 2001 From: Dhanji Prasanna Date: Wed, 22 Oct 2025 22:14:12 +1100 Subject: [PATCH] control commands --- README.md | 10 + crates/g3-cli/src/lib.rs | 77 ++++- crates/g3-cli/src/retro_tui.rs | 49 ++-- crates/g3-cli/src/tui.rs | 2 +- crates/g3-cli/src/ui_writer_impl.rs | 3 +- crates/g3-core/src/fixed_filter_json.rs | 8 +- crates/g3-core/src/lib.rs | 367 ++++++++++++++++++++++-- crates/g3-core/src/project.rs | 1 + crates/g3-execution/src/lib.rs | 2 +- crates/g3-providers/src/databricks.rs | 2 +- crates/g3-providers/src/oauth.rs | 2 +- crates/g3-providers/src/openai.rs | 4 +- 12 files changed, 446 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index a205213..e3aefc1 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,16 @@ G3 includes robust error handling with automatic retry logic: - Conversation history preservation through summaries - Dynamic token allocation for different providers (4k to 200k+ tokens) +### Interactive Control Commands +G3's interactive CLI includes control commands for manual context management: +- **`/compact`**: Manually trigger summarization to compact conversation history +- **`/thinnify`**: Manually trigger context thinning to replace large tool results with file references +- **`/readme`**: Reload README.md and AGENTS.md from disk without restarting +- **`/stats`**: Show detailed context and performance statistics +- **`/help`**: Display all available control commands + +These commands give you fine-grained control over context management, allowing you to proactively optimize token usage and refresh project documentation. See [Control Commands Documentation](docs/CONTROL_COMMANDS.md) for detailed usage. + ### Tool Ecosystem - **File Operations**: Read, write, and edit files with line-range precision - **Shell Integration**: Execute system commands with output capture diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index be0dc19..1382bd5 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -637,8 +637,8 @@ fn extract_readme_heading(readme_content: &str) -> Option { let trimmed = line.trim(); // Check for H1 heading (# Title) - if trimmed.starts_with("# ") { - let title = trimmed[2..].trim(); + if let Some(stripped) = trimmed.strip_prefix("# ") { + let title = stripped.trim(); if !title.is_empty() { // Return the full title (including any description after dash) return Some(title.to_string()); @@ -807,9 +807,8 @@ async fn run_interactive_retro( let trimmed = input_buffer.trim_end(); // Check if line ends with backslash for continuation - if trimmed.ends_with('\\') { + if let Some(without_backslash) = trimmed.strip_suffix('\\') { // Remove the backslash and add to buffer - let without_backslash = &trimmed[..trimmed.len() - 1]; multiline_buffer.push_str(without_backslash); multiline_buffer.push('\n'); in_multiline = true; @@ -1013,9 +1012,8 @@ async fn run_interactive( let trimmed = line.trim_end(); // Check if line ends with backslash for continuation - if trimmed.ends_with('\\') { + if let Some(without_backslash) = trimmed.strip_suffix('\\') { // Remove the backslash and add to buffer - let without_backslash = &trimmed[..trimmed.len() - 1]; multiline_buffer.push_str(without_backslash); multiline_buffer.push('\n'); in_multiline = true; @@ -1058,6 +1056,63 @@ async fn run_interactive( // Add to history rl.add_history_entry(&input)?; + // Check for control commands + if input.starts_with('/') { + match input.as_str() { + "/help" => { + output.print(""); + output.print("šŸ“– Control Commands:"); + output.print(" /compact - Trigger auto-summarization (compacts conversation history)"); + output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)"); + output.print(" /readme - Reload README.md and AGENTS.md from disk"); + output.print(" /stats - Show detailed context and performance statistics"); + output.print(" /help - Show this help message"); + output.print(" exit/quit - Exit the interactive session"); + output.print(""); + continue; + } + "/compact" => { + output.print("šŸ—œļø Triggering manual summarization..."); + match agent.force_summarize().await { + Ok(true) => { + output.print("āœ… Summarization completed successfully"); + } + Ok(false) => { + output.print("āš ļø Summarization failed"); + } + Err(e) => { + output.print(&format!("āŒ Error during summarization: {}", e)); + } + } + continue; + } + "/thinnify" => { + output.print("šŸ”§ Triggering manual context thinning..."); + let summary = agent.force_thin(); + output.print(&summary); + continue; + } + "/readme" => { + output.print("šŸ“š Reloading README.md and AGENTS.md..."); + match agent.reload_readme() { + Ok(true) => output.print("āœ… README content reloaded successfully"), + Ok(false) => output.print("āš ļø No README was loaded at startup, cannot reload"), + Err(e) => output.print(&format!("āŒ Error reloading README: {}", e)), + } + continue; + } + "/stats" => { + let stats = agent.get_stats(); + output.print(&stats); + continue; + } + _ => { + output.print(&format!("āŒ Unknown command: {}. Type /help for available commands.", input)); + continue; + } + } + } + // Process the single line input execute_task(&mut agent, &input, show_prompt, show_code, &output).await; } @@ -1282,7 +1337,7 @@ async fn run_autonomous( elapsed.as_secs_f64() )); output.print(&format!("šŸ”„ Turns Taken: 0/{}", max_turns)); - output.print(&format!("šŸ“ Final Status: āš ļø NO REQUIREMENTS FILE")); + output.print("šŸ“ Final Status: āš ļø NO REQUIREMENTS FILE"); output.print("\nšŸ“ˆ Token Usage Statistics:"); output.print(&format!(" • Used Tokens: {}", context_window.used_tokens)); @@ -1324,7 +1379,7 @@ async fn run_autonomous( elapsed.as_secs_f64() )); output.print(&format!("šŸ”„ Turns Taken: 0/{}", max_turns)); - output.print(&format!("šŸ“ Final Status: āš ļø CANNOT READ REQUIREMENTS")); + output.print("šŸ“ Final Status: āš ļø CANNOT READ REQUIREMENTS"); output.print("\nšŸ“ˆ Token Usage Statistics:"); output.print(&format!(" • Used Tokens: {}", context_window.used_tokens)); @@ -1410,7 +1465,7 @@ async fn run_autonomous( "šŸ“‹ Player received coach feedback ({} chars):", coach_feedback.len() )); - output.print(&format!("{}", coach_feedback)); + output.print(&coach_feedback.to_string()); } output.print(""); // Empty line for readability @@ -1455,7 +1510,7 @@ async fn run_autonomous( elapsed.as_secs_f64() )); output.print(&format!("šŸ”„ Turns Taken: {}/{}", turn, max_turns)); - output.print(&format!("šŸ“ Final Status: šŸ’„ PLAYER PANIC")); + output.print("šŸ“ Final Status: šŸ’„ PLAYER PANIC"); output.print("\nšŸ“ˆ Token Usage Statistics:"); output.print(&format!( @@ -1616,7 +1671,7 @@ Remember: Be clear in your review and concise in your feedback. APPROVE if the i elapsed.as_secs_f64() )); output.print(&format!("šŸ”„ Turns Taken: {}/{}", turn, max_turns)); - output.print(&format!("šŸ“ Final Status: šŸ’„ COACH PANIC")); + output.print("šŸ“ Final Status: šŸ’„ COACH PANIC"); output.print("\nšŸ“ˆ Token Usage Statistics:"); output.print(&format!(" • Used Tokens: {}", context_window.used_tokens)); diff --git a/crates/g3-cli/src/retro_tui.rs b/crates/g3-cli/src/retro_tui.rs index 2aa9e97..9c84172 100644 --- a/crates/g3-cli/src/retro_tui.rs +++ b/crates/g3-cli/src/retro_tui.rs @@ -267,23 +267,23 @@ impl TerminalState { let mut current_text = String::new(); // Check for headers first - if line.starts_with("### ") { + if let Some(stripped) = line.strip_prefix("### ") { return Line::from(Span::styled( - format!(" {}", &line[4..]), + format!(" {}", stripped), Style::default() .fg(self.theme.terminal_cyan.to_color()) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), )); - } else if line.starts_with("## ") { + } else if let Some(stripped) = line.strip_prefix("## ") { return Line::from(Span::styled( - format!(" {}", &line[3..]), + format!(" {}", stripped), Style::default() .fg(self.theme.terminal_amber.to_color()) .add_modifier(Modifier::BOLD), )); - } else if line.starts_with("# ") { + } else if let Some(stripped) = line.strip_prefix("# ") { return Line::from(Span::styled( - format!(" {}", &line[2..]), + format!(" {}", stripped), Style::default() .fg(self.theme.terminal_green.to_color()) .add_modifier(Modifier::BOLD), @@ -343,7 +343,7 @@ impl TerminalState { } // Find closing * let mut italic_text = String::new(); - while let Some(ch) = chars.next() { + for ch in chars.by_ref() { if ch == '*' { break; } @@ -367,7 +367,7 @@ impl TerminalState { } // Find closing ` let mut code_text = String::new(); - while let Some(ch) = chars.next() { + for ch in chars.by_ref() { if ch == '`' { break; } @@ -612,11 +612,9 @@ impl RetroTui { } // Update status blink only if status is "PROCESSING" - if state.status_line == "PROCESSING" { - if state.last_status_blink.elapsed() > Duration::from_millis(500) { - state.status_blink = !state.status_blink; - state.last_status_blink = Instant::now(); - } + if state.status_line == "PROCESSING" && state.last_status_blink.elapsed() > Duration::from_millis(500) { + state.status_blink = !state.status_blink; + state.last_status_blink = Instant::now(); } // Update activity area animation @@ -771,12 +769,7 @@ impl RetroTui { let total_cursor_pos = cursor_position; // Determine the window into the buffer we should show - let window_start = if total_cursor_pos > available_width - 1 { - // Cursor is beyond the visible area, scroll the view - total_cursor_pos - (available_width - 1) - } else { - 0 - }; + let window_start = total_cursor_pos.saturating_sub(available_width - 1); // Get the visible portion of the buffer let visible_buffer: String = input_buffer @@ -1013,9 +1006,9 @@ impl RetroTui { let fade_color = |color: Color| -> Color { match color { Color::Rgb(r, g, b) => { - let faded_r = ((r as f32 * opacity) as u8).max(0); - let faded_g = ((g as f32 * opacity) as u8).max(0); - let faded_b = ((b as f32 * opacity) as u8).max(0); + let faded_r = (r as f32 * opacity) as u8; + let faded_g = (g as f32 * opacity) as u8; + let faded_b = (b as f32 * opacity) as u8; Color::Rgb(faded_r, faded_g, faded_b) } _ => color, @@ -1098,9 +1091,9 @@ impl RetroTui { let fade_color = |color: Color| -> Color { match color { Color::Rgb(r, g, b) => { - let faded_r = ((r as f32 * opacity) as u8).max(0); - let faded_g = ((g as f32 * opacity) as u8).max(0); - let faded_b = ((b as f32 * opacity) as u8).max(0); + let faded_r = (r as f32 * opacity) as u8; + let faded_g = (g as f32 * opacity) as u8; + let faded_b = (b as f32 * opacity) as u8; Color::Rgb(faded_r, faded_g, faded_b) } _ => color, @@ -1176,7 +1169,7 @@ impl RetroTui { } // Wave characters for smooth animation - let wave_chars = vec!['▁', 'ā–‚', 'ā–ƒ', 'ā–„', 'ā–…', 'ā–†', 'ā–‡', 'ā–ˆ']; + let wave_chars = ['▁', 'ā–‚', 'ā–ƒ', 'ā–„', 'ā–…', 'ā–†', 'ā–‡', 'ā–ˆ']; // Build the wave line let mut wave_line = String::new(); @@ -1190,7 +1183,7 @@ impl RetroTui { let idx = wave_data.len().saturating_sub(display_width) + i; if idx < wave_data.len() { - let value = wave_data[idx].min(1.0).max(0.0); + let value = wave_data[idx].clamp(0.0, 1.0); let char_idx = ((value * 7.0) as usize).min(7); wave_line.push(wave_chars[char_idx]); } else { @@ -1206,8 +1199,6 @@ impl RetroTui { f.render_widget(wave_paragraph, area); } - /// Draw the status bar - /// Draw the status bar fn draw_status_bar( f: &mut Frame, diff --git a/crates/g3-cli/src/tui.rs b/crates/g3-cli/src/tui.rs index 261408e..aedd522 100644 --- a/crates/g3-cli/src/tui.rs +++ b/crates/g3-cli/src/tui.rs @@ -40,7 +40,7 @@ impl SimpleOutput { trimmed.starts_with("* ") || trimmed.starts_with("+ ") || (trimmed.len() > 2 && - trimmed.chars().next().map_or(false, |c| c.is_ascii_digit()) && + trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.chars().nth(1) == Some('.') && trimmed.chars().nth(2) == Some(' ')) || (trimmed.contains('[') && trimmed.contains("](")) diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index c69034c..ae0b7a3 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -115,7 +115,6 @@ impl UiWriter for ConsoleUiWriter { // For todo tools, we'll skip the normal header and print a custom one later if is_todo { - return; } } @@ -404,7 +403,7 @@ impl UiWriter for RetroTuiWriter { // Add range information for read_file tool calls let tool_name = self.current_tool_name.lock().unwrap(); - let range_suffix = if tool_name.as_ref().map_or(false, |name| name == "read_file") { + let range_suffix = if tool_name.as_ref().is_some_and(|name| name == "read_file") { // We need to check if start/end args will be provided - for now just check if this is a partial read // This is a simplified approach since we're building the caption incrementally String::new() // We'll handle this in print_tool_output_header instead diff --git a/crates/g3-core/src/fixed_filter_json.rs b/crates/g3-core/src/fixed_filter_json.rs index 67dfa59..5ed6a89 100644 --- a/crates/g3-core/src/fixed_filter_json.rs +++ b/crates/g3-core/src/fixed_filter_json.rs @@ -156,15 +156,15 @@ pub fn fixed_filter_json_tool_calls(content: &str) -> String { } // No JSON tool call detected, return only the new content we haven't returned yet - let new_content = if state.buffer.len() > state.content_returned_up_to { + + + if state.buffer.len() > state.content_returned_up_to { let result = state.buffer[state.content_returned_up_to..].to_string(); state.content_returned_up_to = state.buffer.len(); result } else { String::new() - }; - - new_content + } }) } diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 76efe31..2106d3d 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -60,6 +60,12 @@ pub struct StreamingToolParser { json_tool_start: Option, } +impl Default for StreamingToolParser { + fn default() -> Self { + Self::new() + } +} + impl StreamingToolParser { pub fn new() -> Self { Self { @@ -399,7 +405,12 @@ Format this as a detailed but concise summary that can be used to resume the con } /// Reset the context window with a summary - pub fn reset_with_summary(&mut self, summary: String, latest_user_message: Option) { + pub fn reset_with_summary(&mut self, summary: String, latest_user_message: Option) -> usize { + // Calculate chars saved (old history minus new summary) + let old_chars: usize = self.conversation_history.iter() + .map(|m| m.content.len()) + .sum(); + // Clear the conversation history self.conversation_history.clear(); self.used_tokens = 0; @@ -418,6 +429,11 @@ Format this as a detailed but concise summary that can be used to resume the con content: user_msg, }); } + + let new_chars: usize = self.conversation_history.iter() + .map(|m| m.content.len()) + .sum(); + old_chars.saturating_sub(new_chars) } /// Check if we should trigger context thinning @@ -438,7 +454,7 @@ Format this as a detailed but concise summary that can be used to resume the con /// Perform context thinning: scan first third of conversation and replace large tool results /// Returns a summary message about what was thinned - pub fn thin_context(&mut self) -> String { + pub fn thin_context(&mut self) -> (String, usize) { let current_percentage = self.percentage_used() as u32; let current_threshold = (current_percentage / 10) * 10; @@ -456,7 +472,7 @@ Format this as a detailed but concise summary that can be used to resume the con let tmp_dir = shellexpand::tilde("~/tmp").to_string(); if let Err(e) = std::fs::create_dir_all(&tmp_dir) { warn!("Failed to create ~/tmp directory: {}", e); - return format!("āš ļø Context thinning failed: could not create ~/tmp directory"); + return ("āš ļø Context thinning failed: could not create ~/tmp directory".to_string(), 0); } // Scan the first third of messages @@ -499,11 +515,11 @@ Format this as a detailed but concise summary that can be used to resume the con self.recalculate_tokens(); if leaned_count > 0 { - format!("šŸ„’ Context thinned at {}%: {} tool results, ~{} chars saved", - current_threshold, leaned_count, chars_saved) + (format!("šŸ„’ Context thinned at {}%: {} tool results, ~{} chars saved", + current_threshold, leaned_count, chars_saved), chars_saved) } else { - format!("ℹ Context thinning triggered at {}% but no large tool results found in first third", - current_threshold) + (format!("ℹ Context thinning triggered at {}% but no large tool results found in first third", + current_threshold), 0) } } @@ -522,6 +538,9 @@ Format this as a detailed but concise summary that can be used to resume the con pub struct Agent { providers: ProviderRegistry, context_window: ContextWindow, + thinning_events: Vec, // chars saved per thinning event + summarization_events: Vec, // chars saved per summarization event + first_token_times: Vec, // time to first token for each completion config: Config, session_id: Option, tool_call_metrics: Vec<(String, Duration, bool)>, // (tool_name, duration, success) @@ -745,6 +764,9 @@ impl Agent { Ok(Self { providers, context_window, + thinning_events: Vec::new(), + summarization_events: Vec::new(), + first_token_times: Vec::new(), config, session_id: None, tool_call_metrics: Vec::new(), @@ -794,9 +816,7 @@ impl Agent { // Databricks models have varying context windows depending on the model if model_name.contains("claude") { 200000 // Claude models on Databricks have large context windows - } else if model_name.contains("llama") { - 32768 // Llama models typically support 32k context - } else if model_name.contains("dbrx") { + } else if model_name.contains("llama") || model_name.contains("dbrx") { 32768 // DBRX supports 32k context } else { 16384 // Conservative default for other Databricks models @@ -875,6 +895,7 @@ impl Agent { .await } + #[allow(clippy::too_many_arguments)] pub async fn execute_task_with_timing_cancellable( &mut self, description: &str, @@ -1225,6 +1246,291 @@ Template: &self.context_window } + /// Manually trigger context summarization regardless of context window size + /// Returns Ok(true) if summarization was successful, Ok(false) if it failed + pub async fn force_summarize(&mut self) -> Result { + info!("Manual summarization triggered"); + + self.ui_writer.print_context_status(&format!( + "\nšŸ—œļø Manual summarization requested (current usage: {}%)...", + self.context_window.percentage_used() as u32 + )); + + // Create summary request with FULL history + let summary_prompt = self.context_window.create_summary_prompt(); + + // Get the full conversation history + let conversation_text = self + .context_window + .conversation_history + .iter() + .map(|m| format!("{:?}: {}", m.role, m.content)) + .collect::>() + .join("\n\n"); + + let summary_messages = vec![ + Message { + role: MessageRole::System, + content: "You are a helpful assistant that creates concise summaries." + .to_string(), + }, + Message { + role: MessageRole::User, + content: format!( + "Based on this conversation history, {}\n\nConversation:\n{}", + summary_prompt, conversation_text + ), + }, + ]; + + let provider = self.providers.get(None)?; + + // Dynamically calculate max_tokens for summary based on what's left + let summary_max_tokens = match provider.name() { + "databricks" | "anthropic" => { + let model_limit = 200_000u32; + let current_usage = self.context_window.used_tokens; + let available = model_limit + .saturating_sub(current_usage) + .saturating_sub(5000); + Some(available.min(10_000)) + } + "embedded" => { + let model_limit = self.context_window.total_tokens; + let current_usage = self.context_window.used_tokens; + let available = model_limit + .saturating_sub(current_usage) + .saturating_sub(1000); + Some(available.min(3000)) + } + _ => { + let available = self.context_window.remaining_tokens().saturating_sub(2000); + Some(available.min(5000)) + } + }; + + debug!( + "Requesting summary with max_tokens: {:?} (current usage: {} tokens)", + summary_max_tokens, self.context_window.used_tokens + ); + + let summary_request = CompletionRequest { + messages: summary_messages, + max_tokens: summary_max_tokens, + temperature: Some(0.3), + stream: false, + tools: None, + }; + + // Get the summary + match provider.complete(summary_request).await { + Ok(summary_response) => { + self.ui_writer.print_context_status( + "āœ… Context compacted successfully.\n", + ); + + // Get the latest user message to preserve it + let latest_user_msg = self + .context_window + .conversation_history + .iter() + .rev() + .find(|m| matches!(m.role, MessageRole::User)) + .map(|m| m.content.clone()); + + // Reset context with summary + let chars_saved = self.context_window + .reset_with_summary(summary_response.content, latest_user_msg); + self.summarization_events.push(chars_saved); + + Ok(true) + } + Err(e) => { + error!("Failed to create summary: {}", e); + self.ui_writer.print_context_status( + "āš ļø Unable to create summary. Please try again or start a new session.\n", + ); + Ok(false) + } + } + } + + /// Manually trigger context thinning regardless of thresholds + pub fn force_thin(&mut self) -> String { + info!("Manual context thinning triggered"); + let (message, chars_saved) = self.context_window.thin_context(); + self.thinning_events.push(chars_saved); + message + } + + /// Reload README.md and AGENTS.md and replace the first system message + /// Returns Ok(true) if README was found and reloaded, Ok(false) if no README was present initially + pub fn reload_readme(&mut self) -> Result { + info!("Manual README reload triggered"); + + // Check if the first message in conversation history is a system message with README content + let has_readme = self + .context_window + .conversation_history + .first() + .map(|m| matches!(m.role, MessageRole::System) && + (m.content.contains("Project README") || m.content.contains("Agent Configuration"))) + .unwrap_or(false); + + if !has_readme { + return Ok(false); + } + + // Try to load README.md and AGENTS.md + let mut combined_content = String::new(); + let mut found_any = false; + + if let Ok(agents_content) = std::fs::read_to_string("AGENTS.md") { + combined_content.push_str("# Agent Configuration\n\n"); + combined_content.push_str(&agents_content); + combined_content.push_str("\n\n"); + found_any = true; + } + + if let Ok(readme_content) = std::fs::read_to_string("README.md") { + combined_content.push_str("# Project README\n\n"); + combined_content.push_str(&readme_content); + found_any = true; + } + + if found_any { + // Replace the first message with the new content + if let Some(first_msg) = self.context_window.conversation_history.first_mut() { + first_msg.content = combined_content; + info!("README content reloaded successfully"); + Ok(true) + } else { + Ok(false) + } + } else { + Ok(false) + } + } + + /// Get detailed context statistics + pub fn get_stats(&self) -> String { + let mut stats = String::new(); + use std::time::Duration; + + stats.push_str("\nšŸ“Š Context Window Statistics\n"); + stats.push_str(&"=".repeat(60)); + stats.push_str("\n\n"); + + // Context window usage + stats.push_str("šŸ—‚ļø Context Window:\n"); + stats.push_str(&format!(" • Used Tokens: {:>10} / {}\n", + self.context_window.used_tokens, + self.context_window.total_tokens)); + stats.push_str(&format!(" • Usage Percentage: {:>10.1}%\n", + self.context_window.percentage_used())); + stats.push_str(&format!(" • Remaining Tokens: {:>10}\n", + self.context_window.remaining_tokens())); + stats.push_str(&format!(" • Cumulative Tokens: {:>10}\n", + self.context_window.cumulative_tokens)); + stats.push_str(&format!(" • Last Thinning: {:>10}%\n", + self.context_window.last_thinning_percentage)); + stats.push('\n'); + + // Context optimization metrics + stats.push_str("šŸ—œļø Context Optimization:\n"); + stats.push_str(&format!(" • Thinning Events: {:>10}\n", + self.thinning_events.len())); + if !self.thinning_events.is_empty() { + let total_thinned: usize = self.thinning_events.iter().sum(); + let avg_thinned = total_thinned / self.thinning_events.len(); + stats.push_str(&format!(" • Total Chars Saved: {:>10}\n", total_thinned)); + stats.push_str(&format!(" • Avg Chars/Event: {:>10}\n", avg_thinned)); + } + + stats.push_str(&format!(" • Summarizations: {:>10}\n", + self.summarization_events.len())); + if !self.summarization_events.is_empty() { + let total_summarized: usize = self.summarization_events.iter().sum(); + let avg_summarized = total_summarized / self.summarization_events.len(); + stats.push_str(&format!(" • Total Chars Saved: {:>10}\n", total_summarized)); + stats.push_str(&format!(" • Avg Chars/Event: {:>10}\n", avg_summarized)); + } + stats.push('\n'); + + // Performance metrics + stats.push_str("⚔ Performance:\n"); + if !self.first_token_times.is_empty() { + let avg_ttft = self.first_token_times.iter().sum::() / self.first_token_times.len() as u32; + let mut sorted_times = self.first_token_times.clone(); + sorted_times.sort(); + let median_ttft = sorted_times[sorted_times.len() / 2]; + stats.push_str(&format!(" • Avg Time to First Token: {:>6.3}s\n", avg_ttft.as_secs_f64())); + stats.push_str(&format!(" • Median Time to First Token: {:>6.3}s\n", median_ttft.as_secs_f64())); + } + stats.push('\n'); + + // Conversation history + stats.push_str("šŸ’¬ Conversation History:\n"); + stats.push_str(&format!(" • Total Messages: {:>10}\n", + self.context_window.conversation_history.len())); + + // Count messages by role + let mut system_count = 0; + let mut user_count = 0; + let mut assistant_count = 0; + + for msg in &self.context_window.conversation_history { + match msg.role { + MessageRole::System => system_count += 1, + MessageRole::User => user_count += 1, + MessageRole::Assistant => assistant_count += 1, + } + } + + stats.push_str(&format!(" • System Messages: {:>10}\n", system_count)); + stats.push_str(&format!(" • User Messages: {:>10}\n", user_count)); + stats.push_str(&format!(" • Assistant Messages:{:>10}\n", assistant_count)); + stats.push('\n'); + + // Tool call metrics + stats.push_str("šŸ”§ Tool Call Metrics:\n"); + stats.push_str(&format!(" • Total Tool Calls: {:>10}\n", + self.tool_call_metrics.len())); + + let successful_calls = self.tool_call_metrics.iter() + .filter(|(_, _, success)| *success) + .count(); + let failed_calls = self.tool_call_metrics.len() - successful_calls; + + stats.push_str(&format!(" • Successful: {:>10}\n", successful_calls)); + stats.push_str(&format!(" • Failed: {:>10}\n", failed_calls)); + + if !self.tool_call_metrics.is_empty() { + let total_duration: Duration = self.tool_call_metrics.iter() + .map(|(_, duration, _)| *duration) + .sum(); + let avg_duration = total_duration / self.tool_call_metrics.len() as u32; + + stats.push_str(&format!(" • Total Duration: {:>10.2}s\n", + total_duration.as_secs_f64())); + stats.push_str(&format!(" • Average Duration: {:>10.2}s\n", + avg_duration.as_secs_f64())); + } + stats.push('\n'); + + // Provider info + stats.push_str("šŸ”Œ Provider:\n"); + if let Ok((provider, model)) = self.get_provider_info() { + stats.push_str(&format!(" • Provider: {}\n", provider)); + stats.push_str(&format!(" • Model: {}\n", model)); + } + + stats.push_str(&"=".repeat(60)); + stats.push('\n'); + + stats + } + pub fn get_tool_call_metrics(&self) -> &Vec<(String, Duration, bool)> { &self.tool_call_metrics } @@ -1774,8 +2080,9 @@ Template: .map(|m| m.content.clone()); // Reset context with summary - self.context_window + let chars_saved = self.context_window .reset_with_summary(summary_response.content, latest_user_msg); + self.summarization_events.push(chars_saved); // Update the request with new context request.messages = self.context_window.conversation_history.clone(); @@ -1904,6 +2211,10 @@ Template: // Record time to first token if first_token_time.is_none() && !chunk.content.is_empty() { first_token_time = Some(stream_start.elapsed()); + // Record in agent metrics + if let Some(ttft) = first_token_time { + self.first_token_times.push(ttft); + } } chunks_received += 1; @@ -1919,12 +2230,13 @@ Template: let completed_tools = parser.process_chunk(&chunk); // Handle completed tool calls - for tool_call in completed_tools { + if let Some(tool_call) = completed_tools.into_iter().next() { debug!("Processing completed tool call: {:?}", tool_call); // Check if we should thin the context BEFORE executing the tool if self.context_window.should_thin() { - let thin_summary = self.context_window.thin_context(); + let (thin_summary, chars_saved) = self.context_window.thin_context(); + self.thinning_events.push(chars_saved); // Print the thinning summary to the user self.ui_writer.println(""); self.ui_writer.print_context_status(&format!("{}\n", thin_summary)); @@ -2001,18 +2313,16 @@ Template: } else { s.clone() } + } else if s.len() > 100 { + // Use char_indices to respect UTF-8 boundaries + let truncated = s + .char_indices() + .take(100) + .map(|(_, c)| c) + .collect::(); + format!("{}...", truncated) } else { - if s.len() > 100 { - // Use char_indices to respect UTF-8 boundaries - let truncated = s - .char_indices() - .take(100) - .map(|(_, c)| c) - .collect::(); - format!("{}...", truncated) - } else { - s.clone() - } + s.clone() } } _ => value.to_string(), @@ -2034,7 +2344,7 @@ Template: Ok(result) => result?, Err(_) => { warn!("Tool call {} timed out after 8 minutes", tool_call.tool); - format!("āŒ Tool execution timed out after 8 minutes") + "āŒ Tool execution timed out after 8 minutes".to_string() } }; let exec_duration = exec_start.elapsed(); @@ -2950,14 +3260,14 @@ Template: // Write the result back to the file match std::fs::write(&file_path, &result) { - Ok(()) => Ok(format!("āœ… applied unified diff")), + Ok(()) => Ok("āœ… applied unified diff".to_string()), Err(e) => Ok(format!("āŒ Failed to write to file '{}': {}", file_path, e)), } } "final_output" => { if let Some(summary) = tool_call.args.get("summary") { if let Some(summary_str) = summary.as_str() { - Ok(format!("{}", summary_str)) + Ok(summary_str.to_string()) } else { Ok("āœ… Turn completed".to_string()) } @@ -3702,8 +4012,7 @@ fn parse_unified_diff_hunks(diff: &str) -> Vec<(String, String)> { } } - if line.starts_with(' ') { - let content = &line[1..]; + if let Some(content) = line.strip_prefix(' ') { old_lines.push(content.to_string()); new_lines.push(content.to_string()); } else if line.starts_with('+') && !line.starts_with("+++") { diff --git a/crates/g3-core/src/project.rs b/crates/g3-core/src/project.rs index 5028455..edaa954 100644 --- a/crates/g3-core/src/project.rs +++ b/crates/g3-core/src/project.rs @@ -104,6 +104,7 @@ impl Project { } /// Recursively check a directory for implementation files + #[allow(clippy::only_used_in_recursion)] fn check_dir_for_implementation_files(&self, dir: &Path) -> bool { // Common source file extensions let extensions = vec![ diff --git a/crates/g3-execution/src/lib.rs b/crates/g3-execution/src/lib.rs index 2f2769b..a42ba97 100644 --- a/crates/g3-execution/src/lib.rs +++ b/crates/g3-execution/src/lib.rs @@ -259,7 +259,7 @@ impl CodeExecutor { line = stderr_lines.next_line() => { match line { Ok(Some(line)) => { - receiver.on_output_line(&format!("{}", line)); + receiver.on_output_line(&line.to_string()); stderr_output.push(line); } Ok(None) => {}, // stderr EOF, continue diff --git a/crates/g3-providers/src/databricks.rs b/crates/g3-providers/src/databricks.rs index 68fa413..02c669a 100644 --- a/crates/g3-providers/src/databricks.rs +++ b/crates/g3-providers/src/databricks.rs @@ -213,7 +213,7 @@ impl DatabricksProvider { let mut builder = self .client - .post(&format!( + .post(format!( "{}/serving-endpoints/{}/invocations", self.host, self.model )) diff --git a/crates/g3-providers/src/oauth.rs b/crates/g3-providers/src/oauth.rs index 406d893..75c9d50 100644 --- a/crates/g3-providers/src/oauth.rs +++ b/crates/g3-providers/src/oauth.rs @@ -102,7 +102,7 @@ async fn get_workspace_endpoints(host: &str) -> Result { if !resp.status().is_success() { return Err(anyhow::anyhow!( "Failed to get OIDC configuration from {}", - oidc_url.to_string() + oidc_url )); } diff --git a/crates/g3-providers/src/openai.rs b/crates/g3-providers/src/openai.rs index e8b4dab..52ad6b0 100644 --- a/crates/g3-providers/src/openai.rs +++ b/crates/g3-providers/src/openai.rs @@ -259,7 +259,7 @@ impl LLMProvider for OpenAIProvider { let response = self .client - .post(&format!("{}/chat/completions", self.base_url)) + .post(format!("{}/chat/completions", self.base_url)) .header("Authorization", format!("Bearer {}", self.api_key)) .json(&body) .send() @@ -318,7 +318,7 @@ impl LLMProvider for OpenAIProvider { let response = self .client - .post(&format!("{}/chat/completions", self.base_url)) + .post(format!("{}/chat/completions", self.base_url)) .header("Authorization", format!("Bearer {}", self.api_key)) .json(&body) .send()