Move fixed_filter_json from g3-core to g3-cli
Properly separates UI display concern from core library: - fixed_filter_json module now lives in g3-cli (UI layer) - UiWriter trait gains filter_json_tool_calls() and reset_json_filter() methods - g3-core delegates filtering to UI layer via trait methods - Different UiWriter implementations can choose their own filtering behavior - ConsoleUiWriter filters JSON tool calls for clean terminal display - MachineUiWriter/NullUiWriter use default pass-through Benefits: - Proper separation of concerns - Core stays clean without display-specific logic - Testability - filter can be tested independently in g3-cli
This commit is contained in:
@@ -139,8 +139,16 @@ fn try_extract_from_session_log(
|
||||
session_id: &str,
|
||||
config: &FeedbackExtractionConfig,
|
||||
) -> Option<String> {
|
||||
let logs_path = config.logs_dir.clone().unwrap_or_else(logs_dir);
|
||||
let log_file_path = logs_path.join(format!("g3_session_{}.json", session_id));
|
||||
// Try new .g3/sessions/<session_id>/session.json path first
|
||||
let log_file_path = crate::get_session_file(session_id);
|
||||
|
||||
// Fall back to old logs/ path if new path doesn't exist
|
||||
let log_file_path = if log_file_path.exists() {
|
||||
log_file_path
|
||||
} else {
|
||||
let logs_path = config.logs_dir.clone().unwrap_or_else(logs_dir);
|
||||
logs_path.join(format!("g3_session_{}.json", session_id))
|
||||
};
|
||||
|
||||
if !log_file_path.exists() {
|
||||
debug!("Session log file not found: {:?}", log_file_path);
|
||||
@@ -275,8 +283,16 @@ fn try_extract_from_conversation_history(
|
||||
session_id: &str,
|
||||
config: &FeedbackExtractionConfig,
|
||||
) -> Option<String> {
|
||||
let logs_path = config.logs_dir.clone().unwrap_or_else(logs_dir);
|
||||
let log_file_path = logs_path.join(format!("g3_session_{}.json", session_id));
|
||||
// Try new .g3/sessions/<session_id>/session.json path first
|
||||
let log_file_path = crate::get_session_file(session_id);
|
||||
|
||||
// Fall back to old logs/ path if new path doesn't exist
|
||||
let log_file_path = if log_file_path.exists() {
|
||||
log_file_path
|
||||
} else {
|
||||
let logs_path = config.logs_dir.clone().unwrap_or_else(logs_dir);
|
||||
logs_path.join(format!("g3_session_{}.json", session_id))
|
||||
};
|
||||
|
||||
if !log_file_path.exists() {
|
||||
return None;
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
// FINAL CORRECTED implementation of filter_json_tool_calls function according to specification
|
||||
// 1. Detect tool call start with regex '\w*{\w*"tool"\w*:\w*"' on the very next newline
|
||||
// 2. Enter suppression mode and use brace counting to find complete JSON
|
||||
// 3. Only elide JSON content between first '{' and last '}' (inclusive)
|
||||
// 4. Return everything else as the final filtered string
|
||||
|
||||
//! JSON tool call filtering for streaming LLM responses.
|
||||
//!
|
||||
//! This module filters out JSON tool calls from LLM output streams while preserving
|
||||
//! regular text content. It uses a state machine to handle streaming chunks.
|
||||
|
||||
use regex::Regex;
|
||||
use std::cell::RefCell;
|
||||
use tracing::debug;
|
||||
|
||||
// Thread-local state for tracking JSON tool call suppression
|
||||
thread_local! {
|
||||
static FIXED_JSON_TOOL_STATE: RefCell<FixedJsonToolState> = RefCell::new(FixedJsonToolState::new());
|
||||
}
|
||||
|
||||
/// Internal state for tracking JSON tool call filtering across streaming chunks.
|
||||
#[derive(Debug, Clone)]
|
||||
struct FixedJsonToolState {
|
||||
/// True when actively suppressing a confirmed tool call
|
||||
suppression_mode: bool,
|
||||
/// True when buffering potential JSON (saw { but not yet confirmed as tool call)
|
||||
potential_json_mode: bool,
|
||||
/// Tracks nesting depth of braces within JSON
|
||||
brace_depth: i32,
|
||||
buffer: String,
|
||||
json_start_in_buffer: Option<usize>, // Position where confirmed JSON tool call starts
|
||||
content_returned_up_to: usize, // Track how much content we've already returned
|
||||
potential_json_start: Option<usize>, // Where the potential JSON started
|
||||
}
|
||||
|
||||
impl FixedJsonToolState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
suppression_mode: false,
|
||||
potential_json_mode: false,
|
||||
brace_depth: 0,
|
||||
buffer: String::new(),
|
||||
json_start_in_buffer: None,
|
||||
content_returned_up_to: 0,
|
||||
potential_json_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.suppression_mode = false;
|
||||
self.potential_json_mode = false;
|
||||
self.brace_depth = 0;
|
||||
self.buffer.clear();
|
||||
self.json_start_in_buffer = None;
|
||||
self.content_returned_up_to = 0;
|
||||
self.potential_json_start = None;
|
||||
}
|
||||
}
|
||||
|
||||
// FINAL CORRECTED implementation according to specification
|
||||
|
||||
/// Filters JSON tool calls from streaming LLM content.
|
||||
///
|
||||
/// Processes content chunks and removes JSON tool calls while preserving regular text.
|
||||
/// Maintains state across calls to handle tool calls spanning multiple chunks.
|
||||
pub fn fixed_filter_json_tool_calls(content: &str) -> String {
|
||||
if content.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
FIXED_JSON_TOOL_STATE.with(|state| {
|
||||
let mut state = state.borrow_mut();
|
||||
|
||||
// Add new content to buffer
|
||||
state.buffer.push_str(content);
|
||||
|
||||
// If we're already in suppression mode, continue brace counting
|
||||
if state.suppression_mode {
|
||||
// Count braces in the new content only
|
||||
for ch in content.chars() {
|
||||
match ch {
|
||||
'{' => state.brace_depth += 1,
|
||||
'}' => {
|
||||
state.brace_depth -= 1;
|
||||
// Exit suppression mode when all braces are closed
|
||||
if state.brace_depth <= 0 {
|
||||
debug!("JSON tool call completed - exiting suppression mode");
|
||||
|
||||
// Extract the complete result with JSON filtered out
|
||||
let result = extract_fixed_content(
|
||||
&state.buffer,
|
||||
state.json_start_in_buffer.unwrap_or(0),
|
||||
);
|
||||
|
||||
// Return only the part we haven't returned yet
|
||||
let new_content = if result.len() > state.content_returned_up_to {
|
||||
result[state.content_returned_up_to..].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
state.reset();
|
||||
return new_content;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL FIX: After counting braces, if still in suppression mode,
|
||||
// check if a new tool call pattern appears. This handles truncated JSON
|
||||
// followed by complete JSON.
|
||||
if state.suppression_mode {
|
||||
let current_json_start = state.json_start_in_buffer.unwrap();
|
||||
// Don't require newline - the new JSON might be concatenated directly
|
||||
let tool_call_regex = Regex::new(r#"\{\s*"tool"\s*:\s*""#).unwrap();
|
||||
|
||||
// Look for new tool call patterns after the current one
|
||||
if let Some(captures) = tool_call_regex.find(&state.buffer[current_json_start + 1..]) {
|
||||
let new_json_start = current_json_start + 1 + captures.start() + captures.as_str().find('{').unwrap();
|
||||
|
||||
debug!("Detected new tool call at position {} while processing incomplete one at {} - discarding old", new_json_start, current_json_start);
|
||||
|
||||
// The previous JSON was incomplete/malformed
|
||||
// Return content before the old JSON (if any)
|
||||
let content_before_old_json = if current_json_start > state.content_returned_up_to {
|
||||
state.buffer[state.content_returned_up_to..current_json_start].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Update state to skip the incomplete JSON and position at the new one
|
||||
// We'll process the new JSON on the next call
|
||||
state.content_returned_up_to = new_json_start;
|
||||
state.suppression_mode = false;
|
||||
state.json_start_in_buffer = None;
|
||||
state.brace_depth = 0;
|
||||
|
||||
return content_before_old_json;
|
||||
}
|
||||
}
|
||||
|
||||
// Still in suppression mode, return empty string (content is being accumulated)
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Check if we're in potential JSON mode (saw { but waiting to confirm it's a tool call)
|
||||
if state.potential_json_mode {
|
||||
// Check if the buffer contains a confirmed tool call pattern
|
||||
let tool_call_regex = Regex::new(r#"(?m)^\s*\{\s*"tool"\s*:\s*""#).unwrap();
|
||||
|
||||
if let Some(captures) = tool_call_regex.find(&state.buffer) {
|
||||
// Confirmed! This is a tool call - enter suppression mode
|
||||
let match_text = captures.as_str();
|
||||
if let Some(brace_offset) = match_text.find('{') {
|
||||
let json_start = captures.start() + brace_offset;
|
||||
|
||||
debug!("Confirmed JSON tool call at position {} - entering suppression mode", json_start);
|
||||
|
||||
state.potential_json_mode = false;
|
||||
state.suppression_mode = true;
|
||||
state.brace_depth = 0;
|
||||
state.json_start_in_buffer = Some(json_start);
|
||||
|
||||
// Count braces from json_start to see if JSON is complete
|
||||
let buffer_slice = state.buffer[json_start..].to_string();
|
||||
for ch in buffer_slice.chars() {
|
||||
match ch {
|
||||
'{' => state.brace_depth += 1,
|
||||
'}' => {
|
||||
state.brace_depth -= 1;
|
||||
if state.brace_depth <= 0 {
|
||||
debug!("JSON tool call completed immediately");
|
||||
let result = extract_fixed_content(&state.buffer, json_start);
|
||||
let new_content = if result.len() > state.content_returned_up_to {
|
||||
result[state.content_returned_up_to..].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
state.reset();
|
||||
return new_content;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// JSON incomplete, stay in suppression mode, return nothing
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can rule out this being a tool call
|
||||
// If we have enough content after the { and it doesn't match the pattern, release it
|
||||
if let Some(potential_start) = state.potential_json_start {
|
||||
let content_after_brace = &state.buffer[potential_start..];
|
||||
|
||||
// Rule out as a tool call if:
|
||||
// 1. Closing } appears before we see the full pattern
|
||||
// 2. Content clearly doesn't match the tool call pattern
|
||||
// 3. Newline appears after the opening brace (tool calls should be compact)
|
||||
|
||||
let has_closing_brace = content_after_brace.contains('}');
|
||||
let has_newline = content_after_brace[1..].contains('\n'); // Skip first char which is {
|
||||
let long_enough = content_after_brace.len() >= 10;
|
||||
|
||||
// Detect non-tool JSON patterns:
|
||||
// - { followed by " and a key that doesn't start with "tool"
|
||||
// - { followed by "t" but not "to"
|
||||
// - { followed by "to" but not "too", etc.
|
||||
let not_tool_pattern = Regex::new(r#"^\{\s*"(?:[^t]|t(?:[^o]|o(?:[^o]|o(?:[^l]|l[^"\s:]))))"#).unwrap();
|
||||
let definitely_not_tool = not_tool_pattern.is_match(content_after_brace);
|
||||
|
||||
if has_closing_brace || has_newline || (long_enough && definitely_not_tool) {
|
||||
debug!("Potential JSON ruled out - not a tool call");
|
||||
state.potential_json_mode = false;
|
||||
state.potential_json_start = None;
|
||||
|
||||
// Return the buffered content we've been holding
|
||||
let new_content = if state.buffer.len() > state.content_returned_up_to {
|
||||
state.buffer[state.content_returned_up_to..].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
state.content_returned_up_to = state.buffer.len();
|
||||
return new_content;
|
||||
}
|
||||
}
|
||||
|
||||
// Still in potential mode, keep buffering
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Detect potential JSON start: { at the beginning of a line
|
||||
let potential_json_regex = Regex::new(r"(?m)^\s*\{\s*").unwrap();
|
||||
|
||||
if let Some(captures) = potential_json_regex.find(&state.buffer[state.content_returned_up_to..]) {
|
||||
let match_start = state.content_returned_up_to + captures.start();
|
||||
let brace_pos = match_start + captures.as_str().find('{').unwrap();
|
||||
|
||||
debug!("Potential JSON detected at position {} - entering buffering mode", brace_pos);
|
||||
|
||||
// Fast path: check if this is already a confirmed tool call
|
||||
let tool_call_regex = Regex::new(r#"(?m)^\s*\{\s*"tool"\s*:\s*""#).unwrap();
|
||||
if tool_call_regex.is_match(&state.buffer[brace_pos..]) {
|
||||
// This is a confirmed tool call! Process it immediately
|
||||
let json_start = brace_pos;
|
||||
debug!("Immediately confirmed tool call at position {}", json_start);
|
||||
|
||||
// Return content before JSON
|
||||
let content_before = if json_start > state.content_returned_up_to {
|
||||
state.buffer[state.content_returned_up_to..json_start].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
state.content_returned_up_to = json_start;
|
||||
state.suppression_mode = true;
|
||||
state.brace_depth = 0;
|
||||
state.json_start_in_buffer = Some(json_start);
|
||||
|
||||
// Count braces to see if JSON is complete
|
||||
let buffer_slice = state.buffer[json_start..].to_string();
|
||||
for ch in buffer_slice.chars() {
|
||||
match ch {
|
||||
'{' => state.brace_depth += 1,
|
||||
'}' => {
|
||||
state.brace_depth -= 1;
|
||||
if state.brace_depth <= 0 {
|
||||
debug!("JSON tool call completed in same chunk");
|
||||
let result = extract_fixed_content(&state.buffer, json_start);
|
||||
let content_after = if result.len() > json_start {
|
||||
&result[json_start..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let final_result = format!("{}{}", content_before, content_after);
|
||||
state.reset();
|
||||
return final_result;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// JSON incomplete, return content before and stay in suppression mode
|
||||
return content_before;
|
||||
}
|
||||
|
||||
// Return content before the potential JSON
|
||||
let content_before = if brace_pos > state.content_returned_up_to {
|
||||
state.buffer[state.content_returned_up_to..brace_pos].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
state.content_returned_up_to = brace_pos;
|
||||
state.potential_json_mode = true;
|
||||
state.potential_json_start = Some(brace_pos);
|
||||
|
||||
// Optimization: immediately check if we can rule this out for single-chunk processing
|
||||
let content_after_brace = &state.buffer[brace_pos..];
|
||||
let has_closing_brace = content_after_brace.contains('}');
|
||||
let has_newline = content_after_brace.len() > 1 && content_after_brace[1..].contains('\n');
|
||||
let long_enough = content_after_brace.len() >= 10;
|
||||
|
||||
let not_tool_pattern = Regex::new(r#"^\{\s*"(?:[^t]|t(?:[^o]|o(?:[^o]|o(?:[^l]|l[^"\s:]))))"#).unwrap();
|
||||
let definitely_not_tool = not_tool_pattern.is_match(content_after_brace);
|
||||
|
||||
if has_closing_brace || has_newline || (long_enough && definitely_not_tool) {
|
||||
debug!("Immediately ruled out as not a tool call");
|
||||
state.potential_json_mode = false;
|
||||
state.potential_json_start = None;
|
||||
|
||||
// Return all the buffered content
|
||||
let new_content = if state.buffer.len() > state.content_returned_up_to {
|
||||
state.buffer[state.content_returned_up_to..].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
state.content_returned_up_to = state.buffer.len();
|
||||
return format!("{}{}", content_before, new_content);
|
||||
}
|
||||
|
||||
return content_before;
|
||||
}
|
||||
|
||||
// Check for tool call pattern using corrected regex
|
||||
let tool_call_regex = Regex::new(r#"(?m)^\s*\{\s*"tool"\s*:\s*"[^"]*""#).unwrap();
|
||||
|
||||
if let Some(captures) = tool_call_regex.find(&state.buffer) {
|
||||
let match_text = captures.as_str();
|
||||
|
||||
// Find the position of the opening brace in the match
|
||||
if let Some(brace_offset) = match_text.find('{') {
|
||||
let json_start = captures.start() + brace_offset;
|
||||
|
||||
debug!(
|
||||
"Detected JSON tool call at position {} - entering suppression mode",
|
||||
json_start
|
||||
);
|
||||
|
||||
// Return content before JSON that we haven't returned yet
|
||||
let content_before_json = if json_start >= state.content_returned_up_to {
|
||||
state.buffer[state.content_returned_up_to..json_start].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
state.content_returned_up_to = json_start;
|
||||
|
||||
// Enter suppression mode
|
||||
state.suppression_mode = true;
|
||||
state.brace_depth = 0;
|
||||
state.json_start_in_buffer = Some(json_start);
|
||||
|
||||
// Count braces from the JSON start to see if it's complete
|
||||
let buffer_clone = state.buffer.clone();
|
||||
for ch in buffer_clone[json_start..].chars() {
|
||||
match ch {
|
||||
'{' => state.brace_depth += 1,
|
||||
'}' => {
|
||||
state.brace_depth -= 1;
|
||||
if state.brace_depth <= 0 {
|
||||
// JSON is complete in this chunk
|
||||
debug!("JSON tool call completed in same chunk");
|
||||
let result = extract_fixed_content(&buffer_clone, json_start);
|
||||
|
||||
// Return content before JSON plus content after JSON
|
||||
let content_after_json = if result.len() > json_start {
|
||||
&result[json_start..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let final_result =
|
||||
format!("{}{}", content_before_json, content_after_json);
|
||||
state.reset();
|
||||
return final_result;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON is incomplete, return only the content before JSON
|
||||
return content_before_json;
|
||||
}
|
||||
}
|
||||
|
||||
// No JSON tool call detected, return only the new content we haven't returned yet
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Extracts content from buffer, removing the JSON tool call.
|
||||
///
|
||||
/// Given a buffer and the start position of a JSON tool call, this function:
|
||||
/// 1. Extracts all content before the JSON
|
||||
/// 2. Finds the end of the JSON (matching closing brace)
|
||||
/// 3. Extracts all content after the JSON
|
||||
/// 4. Returns the concatenation of before + after (JSON removed)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `full_content` - The full content buffer
|
||||
/// * `json_start` - Position where the JSON tool call begins
|
||||
fn extract_fixed_content(full_content: &str, json_start: usize) -> String {
|
||||
// Find the end of the JSON using proper brace counting with string handling
|
||||
let mut brace_depth = 0;
|
||||
let mut json_end = json_start;
|
||||
let mut in_string = false;
|
||||
let mut escape_next = false;
|
||||
|
||||
for (i, ch) in full_content[json_start..].char_indices() {
|
||||
if escape_next {
|
||||
escape_next = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\\' if in_string => escape_next = true,
|
||||
'"' if !escape_next => in_string = !in_string,
|
||||
'{' if !in_string => {
|
||||
brace_depth += 1;
|
||||
}
|
||||
'}' if !in_string => {
|
||||
brace_depth -= 1;
|
||||
if brace_depth == 0 {
|
||||
json_end = json_start + i + 1; // +1 to include the closing brace
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Return content before and after the JSON (excluding the JSON itself)
|
||||
let before = &full_content[..json_start];
|
||||
let after = if json_end < full_content.len() {
|
||||
&full_content[json_end..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!("{}{}", before, after)
|
||||
}
|
||||
|
||||
/// Resets the global JSON filtering state.
|
||||
///
|
||||
/// Call this between independent filtering sessions to ensure clean state.
|
||||
/// This is particularly important in tests and when starting new conversations.
|
||||
pub fn reset_fixed_json_tool_state() {
|
||||
FIXED_JSON_TOOL_STATE.with(|state| {
|
||||
let mut state = state.borrow_mut();
|
||||
state.reset();
|
||||
});
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
//! Tests for JSON tool call filtering.
|
||||
//!
|
||||
//! These tests verify that the filter correctly identifies and removes JSON tool calls
|
||||
//! from LLM output streams while preserving all other content.
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixed_filter_tests {
|
||||
use crate::fixed_filter_json::{fixed_filter_json_tool_calls, reset_fixed_json_tool_state};
|
||||
use regex::Regex;
|
||||
|
||||
/// Test that regular text without tool calls passes through unchanged.
|
||||
#[test]
|
||||
fn test_no_tool_call_passthrough() {
|
||||
reset_fixed_json_tool_state();
|
||||
let input = "This is regular text without any tool calls.";
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
/// Test detection and removal of a complete tool call in a single chunk.
|
||||
#[test]
|
||||
fn test_simple_tool_call_detection() {
|
||||
reset_fixed_json_tool_state();
|
||||
let input = r#"Some text before
|
||||
{"tool": "shell", "args": {"command": "ls"}}
|
||||
Some text after"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "Some text before\n\nSome text after";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Test handling of tool calls that arrive across multiple streaming chunks.
|
||||
#[test]
|
||||
fn test_streaming_chunks() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Simulate streaming where the tool call comes in multiple chunks
|
||||
let chunks = vec![
|
||||
"Some text before\n",
|
||||
"{\"tool\": \"",
|
||||
"shell\", \"args\": {",
|
||||
"\"command\": \"ls\"",
|
||||
"}}\nText after",
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
for chunk in chunks {
|
||||
let result = fixed_filter_json_tool_calls(chunk);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// The final accumulated result should have the JSON filtered out
|
||||
let final_result: String = results.join("");
|
||||
let expected = "Some text before\n\nText after";
|
||||
assert_eq!(final_result, expected);
|
||||
}
|
||||
|
||||
/// Test correct handling of nested braces within JSON strings.
|
||||
#[test]
|
||||
fn test_nested_braces_in_tool_call() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
let input = r#"Text before
|
||||
{"tool": "write_file", "args": {"file_path": "test.json", "content": "{\"nested\": \"value\"}"}}
|
||||
Text after"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "Text before\n\nText after";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Verify the regex pattern matches the specification with flexible whitespace.
|
||||
#[test]
|
||||
fn test_regex_pattern_specification() {
|
||||
// Test the corrected regex pattern that's more flexible with whitespace
|
||||
let pattern = Regex::new(r#"(?m)^\s*\{\s*"tool"\s*:"#).unwrap();
|
||||
|
||||
let test_cases = vec![
|
||||
(
|
||||
r#"line
|
||||
{"tool":"#,
|
||||
true,
|
||||
),
|
||||
(
|
||||
r#"line
|
||||
{"tool" :"#,
|
||||
true,
|
||||
),
|
||||
(
|
||||
r#"line
|
||||
{ "tool":"#,
|
||||
true,
|
||||
), // Space after { DOES match with \s*
|
||||
(
|
||||
r#"line
|
||||
{"tool123":"#,
|
||||
false,
|
||||
), // "tool123" is not exactly "tool"
|
||||
(
|
||||
r#"line
|
||||
{"tool" : "#,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (input, should_match) in test_cases {
|
||||
let matches = pattern.is_match(input);
|
||||
assert_eq!(
|
||||
matches, should_match,
|
||||
"Pattern matching failed for: {}",
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that tool calls must appear at the start of a line (after newline).
|
||||
#[test]
|
||||
fn test_newline_requirement() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// According to spec, tool call should be detected "on the very next newline"
|
||||
// Our current regex matches any line that contains the pattern, not just after newlines
|
||||
let input_with_newline = "Text\n{\"tool\": \"shell\", \"args\": {\"command\": \"ls\"}}";
|
||||
let input_without_newline = "Text {\"tool\": \"shell\", \"args\": {\"command\": \"ls\"}}";
|
||||
|
||||
let result1 = fixed_filter_json_tool_calls(input_with_newline);
|
||||
reset_fixed_json_tool_state();
|
||||
let result2 = fixed_filter_json_tool_calls(input_without_newline);
|
||||
|
||||
// With the new aggressive filtering, only the newline case should trigger suppression
|
||||
// The pattern requires { to be at the start of a line (after ^)
|
||||
assert_eq!(result1, "Text\n");
|
||||
// Without newline before {, it should pass through unchanged
|
||||
assert_eq!(result2, input_without_newline);
|
||||
}
|
||||
|
||||
/// Test handling of escaped quotes within JSON strings.
|
||||
#[test]
|
||||
fn test_json_with_escaped_quotes() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
let input = r#"Text
|
||||
{"tool": "write_file", "args": {"content": "He said \"hello\" to me"}}
|
||||
More text"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "Text\n\nMore text";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Test graceful handling of incomplete/malformed JSON.
|
||||
#[test]
|
||||
fn test_edge_case_malformed_json() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test what happens with malformed JSON that starts like a tool call
|
||||
let input = r#"Text
|
||||
{"tool": "shell", "args": {"command": "ls"
|
||||
More text"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
// Should handle gracefully - since JSON is incomplete, it should return content before JSON
|
||||
let expected = "Text\n";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Test processing multiple independent tool calls sequentially.
|
||||
#[test]
|
||||
fn test_multiple_tool_calls_sequential() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test processing multiple tool calls one at a time
|
||||
let input1 = r#"First text
|
||||
{"tool": "shell", "args": {"command": "ls"}}
|
||||
Middle text"#;
|
||||
let result1 = fixed_filter_json_tool_calls(input1);
|
||||
let expected1 = "First text\n\nMiddle text";
|
||||
assert_eq!(result1, expected1);
|
||||
|
||||
// Reset and process second tool call
|
||||
reset_fixed_json_tool_state();
|
||||
let input2 = r#"More text
|
||||
{"tool": "read_file", "args": {"file_path": "test.txt"}}
|
||||
Final text"#;
|
||||
let result2 = fixed_filter_json_tool_calls(input2);
|
||||
let expected2 = "More text\n\nFinal text";
|
||||
assert_eq!(result2, expected2);
|
||||
}
|
||||
|
||||
/// Test tool calls with complex multi-line arguments.
|
||||
#[test]
|
||||
fn test_tool_call_with_complex_args() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
let input = r#"Before
|
||||
{"tool": "str_replace", "args": {"file_path": "test.rs", "diff": "--- old\n-old line\n+++ new\n+new line", "start": 0, "end": 100}}
|
||||
After"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "Before\n\nAfter";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Test input containing only a tool call with no surrounding text.
|
||||
#[test]
|
||||
fn test_tool_call_only() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
let input = r#"
|
||||
{"tool": "final_output", "args": {"summary": "Task completed successfully"}}"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "\n";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Test accurate brace counting with deeply nested structures.
|
||||
#[test]
|
||||
fn test_brace_counting_accuracy() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test complex nested structure
|
||||
let input = r#"Start
|
||||
{"tool": "write_file", "args": {"content": "function() { return {a: 1, b: {c: 2}}; }", "file_path": "test.js"}}
|
||||
End"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "Start\n\nEnd";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Test that braces within strings don't affect brace counting.
|
||||
#[test]
|
||||
fn test_string_escaping_in_json() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test JSON with escaped quotes and braces in strings
|
||||
let input = r#"Text
|
||||
{"tool": "shell", "args": {"command": "echo \"Hello {world}\" > file.txt"}}
|
||||
More"#;
|
||||
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "Text\n\nMore";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Verify compliance with the exact specification requirements.
|
||||
#[test]
|
||||
fn test_specification_compliance() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test the exact specification requirements:
|
||||
// 1. Detect start with regex '\w*{\w*"tool"\w*:\w*"' on newline
|
||||
// 2. Enter suppression mode and use brace counting
|
||||
// 3. Elide only JSON between first '{' and last '}' (inclusive)
|
||||
// 4. Return everything else
|
||||
|
||||
let input = "Before text\nSome more text\n{\"tool\": \"test\", \"args\": {}}\nAfter text\nMore after";
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
let expected = "Before text\nSome more text\n\nAfter text\nMore after";
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
/// Test that non-tool JSON objects are not filtered.
|
||||
#[test]
|
||||
fn test_no_false_positives() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test that we don't incorrectly identify non-tool JSON as tool calls
|
||||
let input = r#"Some text
|
||||
{"not_tool": "value", "other": "data"}
|
||||
More text"#;
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
// Should pass through unchanged since it doesn't match the tool pattern
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
/// Test patterns that look similar to tool calls but aren't exact matches.
|
||||
#[test]
|
||||
fn test_partial_tool_patterns() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test patterns that look like tool calls but aren't complete
|
||||
let test_cases = vec![
|
||||
"Text\n{\"too\": \"value\"}", // "too" not "tool"
|
||||
"Text\n{\"tools\": \"value\"}", // "tools" not "tool"
|
||||
"Text\n{\"tool\": }", // Missing value after colon
|
||||
];
|
||||
|
||||
for input in test_cases {
|
||||
reset_fixed_json_tool_state();
|
||||
let result = fixed_filter_json_tool_calls(input);
|
||||
// These should all pass through unchanged
|
||||
assert_eq!(result, input, "Input should pass through: {}", input);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test streaming with very small chunks (character-by-character).
|
||||
#[test]
|
||||
fn test_streaming_edge_cases() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Test streaming with very small chunks
|
||||
let chunks = vec![
|
||||
"Text\n", "{", "\"", "tool", "\"", ":", " ", "\"", "test", "\"", "}", "\nAfter",
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
for chunk in chunks {
|
||||
let result = fixed_filter_json_tool_calls(chunk);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let final_result: String = results.join("");
|
||||
// With the new aggressive filtering, the JSON should be completely filtered out
|
||||
// even when it arrives in very small chunks
|
||||
let expected = "Text\n\nAfter";
|
||||
assert_eq!(final_result, expected);
|
||||
}
|
||||
|
||||
/// Debug test with detailed logging for streaming behavior.
|
||||
#[test]
|
||||
fn test_streaming_debug() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Debug the exact failing case
|
||||
let chunks = vec![
|
||||
"Some text before\n",
|
||||
"{\"tool\": \"",
|
||||
"shell\", \"args\": {",
|
||||
"\"command\": \"ls\"",
|
||||
"}}\nText after",
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let result = fixed_filter_json_tool_calls(chunk);
|
||||
println!("Chunk {}: {:?} -> {:?}", i, chunk, result);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let final_result: String = results.join("");
|
||||
println!("Final result: {:?}", final_result);
|
||||
println!("Expected: {:?}", "Some text before\n\nText after");
|
||||
|
||||
let expected = "Some text before\n\nText after";
|
||||
assert_eq!(final_result, expected);
|
||||
}
|
||||
|
||||
/// Test handling of truncated JSON followed by complete JSON (the json_err pattern)
|
||||
#[test]
|
||||
fn test_truncated_then_complete_json() {
|
||||
reset_fixed_json_tool_state();
|
||||
|
||||
// Simulate the pattern from json_err trace:
|
||||
// 1. Incomplete/truncated JSON appears
|
||||
// 2. Then the same complete JSON appears
|
||||
let chunks = vec![
|
||||
"Some text\n",
|
||||
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli"#, // Truncated
|
||||
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli/src/lib.rs"}}"#, // Complete
|
||||
"\nMore text",
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let result = fixed_filter_json_tool_calls(chunk);
|
||||
println!("Chunk {}: {:?} -> {:?}", i, chunk, result);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let final_result: String = results.join("");
|
||||
println!("Final result: {:?}", final_result);
|
||||
|
||||
// The truncated JSON should be discarded when the complete one appears
|
||||
// Both JSONs should be filtered out, leaving only the text
|
||||
let expected = "Some text\n\nMore text";
|
||||
assert_eq!(
|
||||
final_result, expected,
|
||||
"Failed to handle truncated JSON followed by complete JSON"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,6 @@ pub use prompts::get_agent_system_prompt;
|
||||
mod task_result_comprehensive_tests;
|
||||
use crate::ui_writer::UiWriter;
|
||||
|
||||
// Make fixed_filter_json public so it can be accessed from g3-cli
|
||||
pub mod fixed_filter_json;
|
||||
#[cfg(test)]
|
||||
mod fixed_filter_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tilde_expansion_tests;
|
||||
|
||||
@@ -32,7 +27,6 @@ mod error_handling_test;
|
||||
mod prompts;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use g3_computer_control::WebDriverController;
|
||||
use g3_config::Config;
|
||||
use g3_execution::CodeExecutor;
|
||||
@@ -42,9 +36,7 @@ use prompts::{get_system_prompt_for_native, SYSTEM_PROMPT_FOR_NON_NATIVE_TOOL_US
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -84,6 +76,52 @@ pub fn logs_dir() -> std::path::PathBuf {
|
||||
/// Used to direct all logs to the workspace directory
|
||||
pub const G3_WORKSPACE_PATH_ENV: &str = "G3_WORKSPACE_PATH";
|
||||
|
||||
/// Get the base .g3 directory path
|
||||
/// This is the root for all g3 session data in the current workspace
|
||||
pub fn get_g3_dir() -> std::path::PathBuf {
|
||||
if let Ok(workspace_path) = std::env::var(G3_WORKSPACE_PATH_ENV) {
|
||||
std::path::PathBuf::from(workspace_path).join(".g3")
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_default().join(".g3")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the session directory for a specific session ID
|
||||
/// Returns .g3/session/<session_id>/
|
||||
pub fn get_session_logs_dir(session_id: &str) -> std::path::PathBuf {
|
||||
get_g3_dir().join("sessions").join(session_id)
|
||||
}
|
||||
|
||||
/// Ensure the session directory exists for a specific session ID
|
||||
/// Creates .g3/session/<session_id>/ and subdirectories
|
||||
pub fn ensure_session_dir(session_id: &str) -> std::io::Result<std::path::PathBuf> {
|
||||
let session_dir = get_session_logs_dir(session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
|
||||
// Create subdirectories
|
||||
std::fs::create_dir_all(session_dir.join("thinned"))?;
|
||||
|
||||
Ok(session_dir)
|
||||
}
|
||||
|
||||
/// Get the thinned content directory for a session
|
||||
/// Returns .g3/session/<session_id>/thinned/
|
||||
pub fn get_thinned_dir(session_id: &str) -> std::path::PathBuf {
|
||||
get_session_logs_dir(session_id).join("thinned")
|
||||
}
|
||||
|
||||
/// Get the path to the session.json file for a session
|
||||
/// Returns .g3/session/<session_id>/session.json
|
||||
pub fn get_session_file(session_id: &str) -> std::path::PathBuf {
|
||||
get_session_logs_dir(session_id).join("session.json")
|
||||
}
|
||||
|
||||
/// Get the path to the context summary file for a session
|
||||
/// Returns .g3/session/<session_id>/context_summary.txt
|
||||
pub fn get_context_summary_file(session_id: &str) -> std::path::PathBuf {
|
||||
get_session_logs_dir(session_id).join("context_summary.txt")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub tool: String,
|
||||
@@ -733,7 +771,8 @@ 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, usize) {
|
||||
/// If session_id is provided, thinned content is saved to .g3/session/<session_id>/thinned/
|
||||
pub fn thin_context(&mut self, session_id: Option<&str>) -> (String, usize) {
|
||||
let current_percentage = self.percentage_used() as u32;
|
||||
let current_threshold = (current_percentage / 10) * 10;
|
||||
|
||||
@@ -748,15 +787,28 @@ Format this as a detailed but concise summary that can be used to resume the con
|
||||
let mut tool_call_leaned_count = 0;
|
||||
let mut chars_saved = 0;
|
||||
|
||||
// Create ~/tmp directory if it doesn't exist
|
||||
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 (
|
||||
"⚠️ Context thinning failed: could not create ~/tmp directory".to_string(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
// Determine output directory: use session dir if available, otherwise ~/tmp
|
||||
let tmp_dir = if let Some(sid) = session_id {
|
||||
let thinned_dir = get_thinned_dir(sid);
|
||||
if let Err(e) = std::fs::create_dir_all(&thinned_dir) {
|
||||
warn!("Failed to create thinned directory: {}", e);
|
||||
return (
|
||||
"⚠️ Context thinning failed: could not create thinned directory".to_string(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
thinned_dir.to_string_lossy().to_string()
|
||||
} else {
|
||||
let fallback_dir = shellexpand::tilde("~/tmp").to_string();
|
||||
if let Err(e) = std::fs::create_dir_all(&fallback_dir) {
|
||||
warn!("Failed to create ~/tmp directory: {}", e);
|
||||
return (
|
||||
"⚠️ Context thinning failed: could not create ~/tmp directory".to_string(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
fallback_dir
|
||||
};
|
||||
|
||||
// Scan the first third of messages
|
||||
for i in 0..first_third_end {
|
||||
@@ -970,7 +1022,8 @@ Format this as a detailed but concise summary that can be used to resume the con
|
||||
/// Perform context thinning on the ENTIRE conversation history (not just first third)
|
||||
/// This is the "skinnify" variant that processes all messages
|
||||
/// Returns a summary message about what was thinned
|
||||
pub fn thin_context_all(&mut self) -> (String, usize) {
|
||||
/// If session_id is provided, thinned content is saved to .g3/session/<session_id>/thinned/
|
||||
pub fn thin_context_all(&mut self, session_id: Option<&str>) -> (String, usize) {
|
||||
let current_percentage = self.percentage_used() as u32;
|
||||
|
||||
// Calculate the total messages - process ALL of them
|
||||
@@ -980,15 +1033,28 @@ Format this as a detailed but concise summary that can be used to resume the con
|
||||
let mut tool_call_leaned_count = 0;
|
||||
let mut chars_saved = 0;
|
||||
|
||||
// Create ~/tmp directory if it doesn't exist
|
||||
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 (
|
||||
"⚠️ Context skinnifying failed: could not create ~/tmp directory".to_string(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
// Determine output directory: use session dir if available, otherwise ~/tmp
|
||||
let tmp_dir = if let Some(sid) = session_id {
|
||||
let thinned_dir = get_thinned_dir(sid);
|
||||
if let Err(e) = std::fs::create_dir_all(&thinned_dir) {
|
||||
warn!("Failed to create thinned directory: {}", e);
|
||||
return (
|
||||
"⚠️ Context skinnifying failed: could not create thinned directory".to_string(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
thinned_dir.to_string_lossy().to_string()
|
||||
} else {
|
||||
let fallback_dir = shellexpand::tilde("~/tmp").to_string();
|
||||
if let Err(e) = std::fs::create_dir_all(&fallback_dir) {
|
||||
warn!("Failed to create ~/tmp directory: {}", e);
|
||||
return (
|
||||
"⚠️ Context skinnifying failed: could not create ~/tmp directory".to_string(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
fallback_dir
|
||||
};
|
||||
|
||||
// Scan ALL messages (not just first third)
|
||||
for i in 0..total_messages {
|
||||
@@ -1825,7 +1891,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// Step 1: Try thinnify (first third of context)
|
||||
self.ui_writer.print_context_status("🥒 Step 1: Trying thinnify...\n");
|
||||
let (thin_msg, thin_saved) = self.context_window.thin_context();
|
||||
let (thin_msg, thin_saved) = self.context_window.thin_context(self.session_id.as_deref());
|
||||
self.thinning_events.push(thin_saved);
|
||||
self.ui_writer.print_context_thinning(&thin_msg);
|
||||
|
||||
@@ -1843,7 +1909,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// Step 2: Try skinnify (entire context)
|
||||
self.ui_writer.print_context_status("🦴 Step 2: Trying skinnify...\n");
|
||||
let (skinny_msg, skinny_saved) = self.context_window.thin_context_all();
|
||||
let (skinny_msg, skinny_saved) = self.context_window.thin_context_all(self.session_id.as_deref());
|
||||
self.thinning_events.push(skinny_saved);
|
||||
self.ui_writer.print_context_thinning(&skinny_msg);
|
||||
|
||||
@@ -1887,7 +1953,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// Step 1: Try thinnify (first third of context)
|
||||
self.ui_writer.print_context_status("🥒 Step 1: Trying thinnify...\n");
|
||||
let (thin_msg, thin_saved) = self.context_window.thin_context();
|
||||
let (thin_msg, thin_saved) = self.context_window.thin_context(self.session_id.as_deref());
|
||||
self.thinning_events.push(thin_saved);
|
||||
self.ui_writer.print_context_thinning(&thin_msg);
|
||||
|
||||
@@ -1904,7 +1970,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// Step 2: Try skinnify (entire context)
|
||||
self.ui_writer.print_context_status("🦴 Step 2: Trying skinnify...\n");
|
||||
let (skinny_msg, skinny_saved) = self.context_window.thin_context_all();
|
||||
let (skinny_msg, skinny_saved) = self.context_window.thin_context_all(self.session_id.as_deref());
|
||||
self.thinning_events.push(skinny_saved);
|
||||
self.ui_writer.print_context_thinning(&skinny_msg);
|
||||
|
||||
@@ -2066,60 +2132,6 @@ impl<W: UiWriter> Agent<W> {
|
||||
Ok(context_length)
|
||||
}
|
||||
|
||||
fn tool_log_handle() -> Option<&'static Mutex<std::fs::File>> {
|
||||
static TOOL_LOG: OnceLock<Option<Mutex<std::fs::File>>> = OnceLock::new();
|
||||
|
||||
TOOL_LOG
|
||||
.get_or_init(|| {
|
||||
let logs_dir = get_logs_dir();
|
||||
if let Err(e) = std::fs::create_dir_all(&logs_dir) {
|
||||
error!("Failed to create logs directory for tool log: {}", e);
|
||||
return None;
|
||||
}
|
||||
|
||||
let ts = Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let path = logs_dir.join(format!("tool_calls_{}.log", ts));
|
||||
|
||||
match OpenOptions::new().create(true).append(true).open(&path) {
|
||||
Ok(file) => Some(Mutex::new(file)),
|
||||
Err(e) => {
|
||||
error!("Failed to open tool log file {:?}: {}", path, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
fn log_tool_call(&self, tool_call: &ToolCall, response: &str) {
|
||||
if let Some(handle) = Self::tool_log_handle() {
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let args_str = serde_json::to_string(&tool_call.args)
|
||||
.unwrap_or_else(|_| "<unserializable>".to_string());
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.replace('\n', "\\n")
|
||||
}
|
||||
fn truncate(s: &str, limit: usize) -> String {
|
||||
s.chars().take(limit).collect()
|
||||
}
|
||||
|
||||
let args_snippet = truncate(&sanitize(&args_str), 80);
|
||||
let response_snippet = truncate(&sanitize(response), 80);
|
||||
|
||||
let tool_field = format!("{:<15}", tool_call.tool);
|
||||
let line = format!(
|
||||
"{} {} {} 🟩 {}\n",
|
||||
timestamp, tool_field, args_snippet, response_snippet
|
||||
);
|
||||
|
||||
if let Ok(mut file) = handle.lock() {
|
||||
let _ = file.write_all(line.as_bytes());
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_provider_info(&self) -> Result<(String, String)> {
|
||||
let provider = self.providers.get(None)?;
|
||||
Ok((provider.name().to_string(), provider.model().to_string()))
|
||||
@@ -2226,7 +2238,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
) -> Result<TaskResult> {
|
||||
// Reset the JSON tool call filter state at the start of each new task
|
||||
// This prevents the filter from staying in suppression mode between user interactions
|
||||
fixed_filter_json::reset_fixed_json_tool_state();
|
||||
self.ui_writer.reset_json_filter();
|
||||
|
||||
// Validate that the system prompt is the first message (critical invariant)
|
||||
self.validate_system_prompt_is_first();
|
||||
@@ -2441,19 +2453,21 @@ impl<W: UiWriter> Agent<W> {
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
let logs_dir = get_logs_dir();
|
||||
if !logs_dir.exists() {
|
||||
// Use new .g3/session/<session_id>/ structure if we have a session ID
|
||||
let filename = if let Some(ref session_id) = self.session_id {
|
||||
// Ensure session directory exists
|
||||
if let Err(e) = ensure_session_dir(session_id) {
|
||||
error!("Failed to create session directory: {}", e);
|
||||
return;
|
||||
}
|
||||
get_session_file(session_id)
|
||||
} else {
|
||||
// Fallback to old logs/ directory for sessions without ID
|
||||
let logs_dir = get_logs_dir();
|
||||
if let Err(e) = std::fs::create_dir_all(&logs_dir) {
|
||||
error!("Failed to create logs directory: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use session-based filename if we have a session ID, otherwise fall back to timestamp
|
||||
let filename = if let Some(ref session_id) = self.session_id {
|
||||
logs_dir.join(format!("g3_session_{}.json", session_id))
|
||||
} else {
|
||||
logs_dir.join(format!("g3_context_{}.json", timestamp))
|
||||
};
|
||||
|
||||
@@ -2529,18 +2543,15 @@ impl<W: UiWriter> Agent<W> {
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
let logs_dir = get_logs_dir();
|
||||
if !logs_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(&logs_dir) {
|
||||
error!("Failed to create logs directory: {}", e);
|
||||
return;
|
||||
}
|
||||
// Ensure session directory exists
|
||||
if let Err(e) = ensure_session_dir(session_id) {
|
||||
error!("Failed to create session directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate filename using same pattern as save_context_window
|
||||
let filename = logs_dir.join(format!("context_window_{}.txt", session_id));
|
||||
let symlink_path = logs_dir.join("current_context_window");
|
||||
// Use new .g3/session/<session_id>/ structure
|
||||
let filename = get_context_summary_file(session_id);
|
||||
let symlink_path = get_g3_dir().join("sessions").join("current_context_window");
|
||||
|
||||
// Build the summary content
|
||||
let mut summary_lines = Vec::new();
|
||||
@@ -2851,7 +2862,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
/// 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();
|
||||
let (message, chars_saved) = self.context_window.thin_context(self.session_id.as_deref());
|
||||
self.thinning_events.push(chars_saved);
|
||||
message
|
||||
}
|
||||
@@ -2860,7 +2871,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
/// Unlike force_thin which only processes the first third, this processes all messages
|
||||
pub fn force_thin_all(&mut self) -> String {
|
||||
info!("Manual full context skinnifying triggered");
|
||||
let (message, chars_saved) = self.context_window.thin_context_all();
|
||||
let (message, chars_saved) = self.context_window.thin_context_all(self.session_id.as_deref());
|
||||
self.thinning_events.push(chars_saved);
|
||||
message
|
||||
}
|
||||
@@ -3106,9 +3117,8 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
};
|
||||
|
||||
// Get the session log path
|
||||
let logs_dir = get_logs_dir();
|
||||
let session_log_path = logs_dir.join(format!("g3_session_{}.json", session_id));
|
||||
// Get the session log path (now in .g3/sessions/<session_id>/session.json)
|
||||
let session_log_path = get_session_file(&session_id);
|
||||
|
||||
// Get current TODO content
|
||||
let todo_snapshot = std::fs::read_to_string(get_todo_path()).ok();
|
||||
@@ -3889,7 +3899,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
self.context_window.percentage_used() as u32
|
||||
));
|
||||
|
||||
let (thin_summary, chars_saved) = self.context_window.thin_context();
|
||||
let (thin_summary, chars_saved) = self.context_window.thin_context(self.session_id.as_deref());
|
||||
self.thinning_events.push(chars_saved);
|
||||
self.ui_writer.print_context_thinning(&thin_summary);
|
||||
|
||||
@@ -4289,7 +4299,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
);
|
||||
let mut modified_tool_call = tool_call.clone();
|
||||
modified_tool_call.tool = prefixed_tool_name;
|
||||
self.log_tool_call(&modified_tool_call, &warning_msg);
|
||||
debug!("{}", warning_msg);
|
||||
continue; // Skip execution of duplicate
|
||||
}
|
||||
|
||||
@@ -4308,7 +4318,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
// Log to tool log with red prefix
|
||||
let mut modified_tool_call = tool_call.clone();
|
||||
modified_tool_call.tool = prefixed_tool_name;
|
||||
self.log_tool_call(&modified_tool_call, &warning_msg);
|
||||
debug!("{}", warning_msg);
|
||||
continue; // Skip execution of duplicate
|
||||
}
|
||||
|
||||
@@ -4323,7 +4333,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
// Check if we should thin the context BEFORE executing the tool
|
||||
if self.context_window.should_thin() {
|
||||
let (thin_summary, chars_saved) =
|
||||
self.context_window.thin_context();
|
||||
self.context_window.thin_context(self.session_id.as_deref());
|
||||
self.thinning_events.push(chars_saved);
|
||||
// Print the thinning summary to the user
|
||||
self.ui_writer.print_context_thinning(&thin_summary);
|
||||
@@ -4348,7 +4358,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// Filter out JSON tool calls from the display
|
||||
let filtered_content =
|
||||
fixed_filter_json::fixed_filter_json_tool_calls(&clean_content);
|
||||
self.ui_writer.filter_json_tool_calls(&clean_content);
|
||||
let final_display_content = filtered_content.trim();
|
||||
|
||||
// Display any new content before tool execution
|
||||
@@ -4634,7 +4644,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// Reset the JSON tool call filter state after each tool execution
|
||||
// This ensures the filter doesn't stay in suppression mode for subsequent streaming content
|
||||
fixed_filter_json::reset_fixed_json_tool_state();
|
||||
self.ui_writer.reset_json_filter();
|
||||
|
||||
// Reset parser for next iteration - this clears the text buffer
|
||||
parser.reset();
|
||||
@@ -4667,7 +4677,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
if !clean_content.is_empty() {
|
||||
let filtered_content =
|
||||
fixed_filter_json::fixed_filter_json_tool_calls(&clean_content);
|
||||
self.ui_writer.filter_json_tool_calls(&clean_content);
|
||||
|
||||
if !filtered_content.is_empty() {
|
||||
if !response_started {
|
||||
@@ -4712,9 +4722,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
.replace("<</SYS>>", "");
|
||||
|
||||
let filtered_text =
|
||||
fixed_filter_json::fixed_filter_json_tool_calls(
|
||||
&clean_text,
|
||||
);
|
||||
self.ui_writer.filter_json_tool_calls(&clean_text);
|
||||
|
||||
// Only use this if we truly have nothing else
|
||||
if !filtered_text.trim().is_empty() && full_response.is_empty()
|
||||
@@ -5074,7 +5082,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
Ok(s) => s.clone(),
|
||||
Err(e) => format!("ERROR: {}", e),
|
||||
};
|
||||
self.log_tool_call(tool_call, &log_str);
|
||||
debug!("Tool {} completed: {}", tool_call.tool, &log_str.chars().take(100).collect::<String>());
|
||||
result
|
||||
}
|
||||
|
||||
@@ -6878,7 +6886,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: JSON tool call filtering is now handled by fixed_filter_json::fixed_filter_json_tool_calls
|
||||
// Note: JSON tool call filtering is now handled by UiWriter::filter_json_tool_calls (implemented in g3-cli)
|
||||
|
||||
// Apply unified diff to an input string with optional [start, end) bounds
|
||||
pub fn apply_unified_diff_to_string(
|
||||
|
||||
@@ -69,6 +69,18 @@ pub trait UiWriter: Send + Sync {
|
||||
/// Print the final output summary with markdown formatting
|
||||
/// Shows a spinner while formatting, then renders the markdown
|
||||
fn print_final_output(&self, summary: &str);
|
||||
|
||||
/// Filter JSON tool calls from streaming content for display.
|
||||
/// This is a UI concern - the raw content should be preserved for logging.
|
||||
/// Default implementation passes through unchanged.
|
||||
fn filter_json_tool_calls(&self, content: &str) -> String {
|
||||
content.to_string()
|
||||
}
|
||||
|
||||
/// Reset the JSON tool call filter state.
|
||||
/// Called at the start of a new response to clear any partial state.
|
||||
/// Default implementation does nothing.
|
||||
fn reset_json_filter(&self) {}
|
||||
}
|
||||
|
||||
/// A no-op implementation for when UI output is not needed
|
||||
|
||||
@@ -69,7 +69,7 @@ fn test_thin_context_basic() {
|
||||
|
||||
// Trigger thinning at 50%
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
println!("Thinning summary: {}", summary);
|
||||
|
||||
@@ -130,7 +130,7 @@ fn test_thin_write_file_tool_calls() {
|
||||
|
||||
// Trigger thinning at 50%
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
println!("Thinning summary: {}", summary);
|
||||
|
||||
@@ -190,7 +190,7 @@ fn test_thin_str_replace_tool_calls() {
|
||||
|
||||
// Trigger thinning at 50%
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
println!("Thinning summary: {}", summary);
|
||||
|
||||
@@ -224,7 +224,7 @@ fn test_thin_context_no_large_results() {
|
||||
}
|
||||
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
// Should report no large results found
|
||||
assert!(summary.contains("no large tool results or tool calls found"));
|
||||
@@ -253,7 +253,7 @@ fn test_thin_context_only_affects_first_third() {
|
||||
}
|
||||
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
// First third is 4 messages (indices 0-3), so only indices 1 and 3 should be thinned
|
||||
// That's 2 tool results
|
||||
|
||||
@@ -30,7 +30,7 @@ fn test_todo_read_results_not_thinned() {
|
||||
|
||||
// Trigger thinning at 50%
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
println!("Thinning summary: {}", summary);
|
||||
|
||||
@@ -87,7 +87,7 @@ fn test_todo_write_results_not_thinned() {
|
||||
|
||||
// Trigger thinning at 50%
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
println!("Thinning summary: {}", summary);
|
||||
|
||||
@@ -135,7 +135,7 @@ fn test_non_todo_results_still_thinned() {
|
||||
|
||||
// Trigger thinning at 50%
|
||||
context.used_tokens = 5000;
|
||||
let (summary, _chars_saved) = context.thin_context();
|
||||
let (summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
println!("Thinning summary: {}", summary);
|
||||
|
||||
@@ -185,7 +185,7 @@ fn test_todo_read_with_spaces_in_tool_name() {
|
||||
|
||||
// Trigger thinning
|
||||
context.used_tokens = 5000;
|
||||
let (_summary, _chars_saved) = context.thin_context();
|
||||
let (_summary, _chars_saved) = context.thin_context(None);
|
||||
|
||||
// Verify TODO result was not thinned
|
||||
let first_third_end = context.conversation_history.len() / 3;
|
||||
|
||||
Reference in New Issue
Block a user