Skip to main content

atman_runtime/
activity.rs

1use std::path::Path;
2
3#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4pub struct EditMetrics {
5    pub hunks: usize,
6    pub insertions: usize,
7    pub deletions: usize,
8}
9
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct ActivitySummary {
12    pub attempted_calls: usize,
13    pub completed_calls: usize,
14    pub failed_calls: usize,
15    pub applied_edits: usize,
16    pub files: usize,
17    pub hunks: usize,
18    pub insertions: usize,
19    pub deletions: usize,
20}
21
22#[derive(Debug, Clone, Default)]
23pub(crate) struct ActivityAccumulator {
24    summary: ActivitySummary,
25    attempted: std::collections::HashSet<(String, String)>,
26    completed: std::collections::HashSet<(String, String)>,
27    files: std::collections::BTreeSet<String>,
28}
29
30impl ActivityAccumulator {
31    pub(crate) fn observe(&mut self, event: &crate::event::Event) {
32        match event {
33            crate::event::Event::ToolNode {
34                run_id,
35                tool_use_id,
36                ..
37            } => {
38                if self
39                    .attempted
40                    .insert((run_id.to_string(), tool_use_id.clone()))
41                {
42                    self.summary.attempted_calls += 1;
43                }
44            }
45            crate::event::Event::ToolResultMsg {
46                flow_run_id,
47                message,
48                ..
49            } => {
50                for part in &message.parts {
51                    if let crate::message::MessagePart::ToolResult {
52                        tool_use_id,
53                        is_error,
54                        ..
55                    } = part
56                    {
57                        let key = (
58                            flow_run_id
59                                .as_ref()
60                                .map(ToString::to_string)
61                                .unwrap_or_default(),
62                            tool_use_id.clone(),
63                        );
64                        if self.completed.insert(key) {
65                            self.summary.completed_calls += 1;
66                            self.summary.failed_calls += usize::from(*is_error);
67                        }
68                    }
69                }
70            }
71            crate::event::Event::FileEditApplied { path, metrics, .. } => {
72                self.summary.applied_edits += 1;
73                self.files.insert(path.clone());
74                self.summary.hunks += metrics.hunks;
75                self.summary.insertions += metrics.insertions;
76                self.summary.deletions += metrics.deletions;
77            }
78            _ => {}
79        }
80        self.summary.files = self.files.len();
81    }
82
83    pub(crate) fn summary(&self) -> ActivitySummary {
84        self.summary.clone()
85    }
86
87    pub(crate) fn file_paths(&self) -> Vec<String> {
88        self.files.iter().cloned().collect()
89    }
90}
91
92pub fn summarize_events(events: &[crate::event::EventEnvelope]) -> ActivitySummary {
93    let mut activity = ActivityAccumulator::default();
94    for envelope in events {
95        activity.observe(&envelope.event);
96    }
97    activity.summary()
98}
99
100pub fn edit_metrics(before: &str, after: &str) -> EditMetrics {
101    let diff = similar::TextDiff::from_lines(before, after);
102    let mut metrics = EditMetrics::default();
103    for change in diff.iter_all_changes() {
104        match change.tag() {
105            similar::ChangeTag::Insert => metrics.insertions += 1,
106            similar::ChangeTag::Delete => metrics.deletions += 1,
107            similar::ChangeTag::Equal => {}
108        }
109    }
110    metrics.hunks = diff
111        .grouped_ops(3)
112        .into_iter()
113        .filter(|group| {
114            group
115                .iter()
116                .any(|op| !matches!(op.tag(), similar::DiffTag::Equal))
117        })
118        .count();
119    metrics
120}
121
122pub fn emit_file_edit_applied(
123    ctx: &crate::tool::ToolCtx,
124    tool_name: &str,
125    path: &Path,
126    before: &str,
127    after: &str,
128) {
129    let metrics = edit_metrics(before, after);
130    if metrics.insertions == 0 && metrics.deletions == 0 {
131        return;
132    }
133    let path = path
134        .canonicalize()
135        .unwrap_or_else(|_| path.to_path_buf())
136        .to_string_lossy()
137        .into_owned();
138    if let Some(sink) = &ctx.events {
139        sink.emit(crate::event::Event::FileEditApplied {
140            turn_id: ctx.turn_id.clone(),
141            flow_run_id: ctx.flow_run_id.clone(),
142            tool_use_id: ctx.tool_use_id.clone(),
143            tool_name: tool_name.to_string(),
144            path: path.clone(),
145            metrics,
146        });
147    }
148    if let Some(tx) = &ctx.stream_tx {
149        let _ = tx.send(crate::stream::StreamFrame::FileEditApplied {
150            turn_id: ctx.turn_id.as_ref().map(ToString::to_string),
151            run_id: ctx.flow_run_id.as_ref().map(ToString::to_string),
152            tool_use_id: ctx.tool_use_id.clone(),
153            tool_name: tool_name.to_string(),
154            path,
155            metrics,
156        });
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn edit_metrics_count_visual_line_changes_and_hunks() {
166        let metrics = edit_metrics("one\ntwo\nthree\n", "one\nchanged\nthree\nadded\n");
167        assert_eq!(
168            metrics,
169            EditMetrics {
170                hunks: 1,
171                insertions: 2,
172                deletions: 1,
173            }
174        );
175    }
176}