Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/chat-cli/src/cli/chat/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ pub enum SlashCommand {
impl SlashCommand {
pub async fn execute(self, os: &mut Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
match self {
Self::Quit => Ok(ChatState::Exit),
Self::Quit => {
// Flush all pending retention checks before quitting
session.conversation.flush_all_retention_metrics(os, "quit").await.ok();
Ok(ChatState::Exit)
},
Self::Clear(args) => args.execute(session).await,
Self::Agent(subcommand) => subcommand.execute(os, session).await,
Self::Profile => {
Expand Down
121 changes: 121 additions & 0 deletions crates/chat-cli/src/cli/chat/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ impl ConversationState {
}

/// Enter tangent mode - creates checkpoint of current state
/// Allows exploring side topics without affecting main conversation
pub fn enter_tangent_mode(&mut self) {
if self.tangent_state.is_none() {
self.tangent_state = Some(self.create_checkpoint());
Expand Down Expand Up @@ -960,6 +961,126 @@ Return only the JSON configuration, no additional text.",

Ok(())
}

pub async fn check_due_retention_metrics(&mut self, os: &Os) -> Result<Vec<(String, usize, usize)>, ChatError> {
let mut all_results = Vec::new();
let message_id = self.message_id().map(|s| s.to_string());

for (path, tracker) in &mut self.file_line_tracker {
match os.fs.read_to_string(path).await {
Ok(content) => {
let results = tracker.check_due_retention(&content);

for (conversation_id, tool_use_id, retained, total, source) in results {
debug!("Retention check for {}: {}/{} lines retained, tool_use_id: {}, source: {}",
path, retained, total, tool_use_id, source);

// Send retention metric with source
os.telemetry
.send_agent_contribution_metric_with_source(
&os.database,
conversation_id,
message_id.clone(),
Some(tool_use_id.clone()),
None,
None,
None,
Some(retained),
Some(total),
Some(source),
)
.await
.ok();

all_results.push((tool_use_id, retained, total));
}
}
Err(_) => {
// File not found - emit metrics for all pending checks with file_not_found reason
for check in &tracker.pending_retention_checks {
debug!("File not found during retention check: {}, tool_use_id: {}", path, check.tool_use_id);

os.telemetry
.send_agent_contribution_metric_with_source(
&os.database,
check.conversation_id.clone(),
message_id.clone(),
Some(check.tool_use_id.clone()),
None,
None,
None,
Some(0), // retained = 0 since file doesn't exist
Some(check.lines.len()),
Some("file_not_found".to_string()),
)
.await
.ok();
}
// Clear pending checks since file is gone
tracker.pending_retention_checks.clear();
}
}
}
Ok(all_results)
}

pub async fn flush_all_retention_metrics(&mut self, os: &Os, source: &str) -> Result<(), ChatError> {
let message_id = self.message_id().map(|s| s.to_string());

for (path, tracker) in &mut self.file_line_tracker {
match os.fs.read_to_string(path).await {
Ok(content) => {
let results = tracker.flush_all_retention_checks(&content, source);

for (conversation_id, tool_use_id, retained, total, source) in results {
debug!("Flushing retention check for {}: {}/{} lines retained, tool_use_id: {}, source: {}",
path, retained, total, tool_use_id, source);

os.telemetry
.send_agent_contribution_metric_with_source(
&os.database,
conversation_id,
message_id.clone(),
Some(tool_use_id.clone()),
None,
None,
None,
Some(retained),
Some(total),
Some(source),
)
.await
.ok();
}
}
Err(_) => {
// File not found - emit metrics for all pending checks with file_not_found reason
for check in &tracker.pending_retention_checks {
debug!("File not found during flush: {}, tool_use_id: {}", path, check.tool_use_id);

os.telemetry
.send_agent_contribution_metric_with_source(
&os.database,
check.conversation_id.clone(),
message_id.clone(),
Some(check.tool_use_id.clone()),
None,
None,
None,
Some(0), // retained = 0 since file doesn't exist
Some(check.lines.len()),
Some("file_not_found".to_string()),
)
.await
.ok();
}
// Clear pending checks since file is gone
tracker.pending_retention_checks.clear();
}
}
}
Ok(())
}
}

pub fn format_tool_spec(tool_spec: HashMap<String, ToolSpec>) -> HashMap<ToolOrigin, Vec<Tool>> {
Expand Down
98 changes: 98 additions & 0 deletions crates/chat-cli/src/cli/chat/line_tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use serde::{
Deserialize,
Serialize,
};
use std::time::{SystemTime, UNIX_EPOCH};

/// Contains metadata for tracking user and agent contribution metrics for a given file for
/// `fs_write` tool uses.
Expand All @@ -19,6 +20,17 @@ pub struct FileLineTracker {
pub lines_removed_by_agent: usize,
/// Whether or not this is the first `fs_write` invocation
pub is_first_write: bool,
/// Pending retention checks scheduled for 1 minute (changed from 15 minutes for testing)
#[serde(default)]
pub pending_retention_checks: Vec<RetentionCheck>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetentionCheck {
pub lines: Vec<String>,
pub scheduled_time: u64,
pub conversation_id: String,
pub tool_use_id: String,
}

impl Default for FileLineTracker {
Expand All @@ -30,6 +42,7 @@ impl Default for FileLineTracker {
lines_added_by_agent: 0,
lines_removed_by_agent: 0,
is_first_write: true,
pending_retention_checks: Vec::new(),
}
}
}
Expand All @@ -42,4 +55,89 @@ impl FileLineTracker {
pub fn lines_by_agent(&self) -> isize {
(self.lines_added_by_agent + self.lines_removed_by_agent) as isize
}

pub fn schedule_retention_check(&mut self, lines: Vec<String>, conversation_id: String, tool_use_id: String) {
let scheduled_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() + 900; // 15 minutes from now

self.pending_retention_checks.push(RetentionCheck {
lines,
scheduled_time,
conversation_id,
tool_use_id,
});
}

pub fn flush_pending_checks_for_agent_rewrite(&mut self, file_content: &str) -> Vec<(String, String, usize, usize, String)> {
let mut results = Vec::new();

for check in self.pending_retention_checks.drain(..) {
let retained = check.lines.iter()
.filter(|line| file_content.contains(*line))
.count();

results.push((
check.conversation_id,
check.tool_use_id,
retained,
check.lines.len(),
"agent_rewrite".to_string(),
));
}

results
}

pub fn check_due_retention(&mut self, file_content: &str) -> Vec<(String, String, usize, usize, String)> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();

let mut results = Vec::new();
let mut remaining_checks = Vec::new();

for check in self.pending_retention_checks.drain(..) {
if now >= check.scheduled_time {
let retained = check.lines.iter()
.filter(|line| file_content.contains(*line))
.count();

results.push((
check.conversation_id,
check.tool_use_id,
retained,
check.lines.len(),
"15min_check".to_string(),
));
} else {
remaining_checks.push(check);
}
}

self.pending_retention_checks = remaining_checks;
results
}

pub fn flush_all_retention_checks(&mut self, file_content: &str, source: &str) -> Vec<(String, String, usize, usize, String)> {
let mut results = Vec::new();

for check in self.pending_retention_checks.drain(..) {
let retained = check.lines.iter()
.filter(|line| file_content.contains(*line))
.count();

results.push((
check.conversation_id,
check.tool_use_id,
retained,
check.lines.len(),
source.to_string(),
));
}

results
}
}
Loading