Skip to main content

opendev_runtime/
tool_summarizer.rs

1//! Tool result summarizer — creates concise summaries for LLM context.
2//!
3//! Mirrors Python's `opendev/core/utils/tool_result_summarizer.py`.
4//! Summaries are stored in `ToolCall.result_summary` to prevent context
5//! bloat while preserving semantic meaning for the LLM.
6
7/// Create a concise 1-2 line summary of a tool result for LLM context.
8///
9/// This prevents context bloat while maintaining semantic meaning.
10/// Summaries are typically 50-200 chars.
11pub fn summarize_tool_result(tool_name: &str, output: Option<&str>, error: Option<&str>) -> String {
12    // Error case
13    if let Some(err) = error {
14        let truncated = if err.len() > 200 { &err[..200] } else { err };
15        return format!("Error: {truncated}");
16    }
17
18    let result_str = output.unwrap_or("");
19
20    if result_str.is_empty() {
21        return "Success (no output)".to_string();
22    }
23
24    match tool_name {
25        // File read operations
26        "read_file" | "Read" => {
27            let lines = result_str.lines().count();
28            let chars = result_str.len();
29            format!("Read file ({lines} lines, {chars} chars)")
30        }
31
32        // File write operations
33        "write_file" | "Write" => "File written successfully".to_string(),
34
35        // File edit operations
36        "edit_file" | "Edit" => "File edited successfully".to_string(),
37
38        // Delete operations
39        "delete_file" | "Delete" => "File deleted".to_string(),
40
41        // Search operations
42        "search" | "Grep" | "file_search" => {
43            if result_str.contains("No matches found") || result_str.trim().is_empty() {
44                "Search completed (0 matches)".to_string()
45            } else {
46                let match_count = result_str.lines().count();
47                format!("Search completed ({match_count} matches found)")
48            }
49        }
50
51        // Directory listing
52        "list_files" | "list_directory" | "List" => {
53            let file_count = if result_str.is_empty() {
54                0
55            } else {
56                result_str.lines().count()
57            };
58            format!("Listed directory ({file_count} items)")
59        }
60
61        // Command execution
62        "run_command" | "Run" | "bash_execute" | "Bash" => {
63            let lines = result_str.lines().count();
64            if lines > 10 {
65                format!("Command executed ({lines} lines of output)")
66            } else if result_str.len() < 100 {
67                format!("Output: {}", &result_str[..result_str.len().min(100)])
68            } else {
69                "Command executed successfully".to_string()
70            }
71        }
72
73        // Web operations
74        "fetch_url" | "Fetch" | "web_fetch" | "web_search" => {
75            "Content fetched successfully".to_string()
76        }
77
78        // Screenshot operations
79        "capture_screenshot" | "web_screenshot" | "analyze_image" => {
80            "Image processed successfully".to_string()
81        }
82
83        // Git operations
84        "git" => {
85            let lines = result_str.lines().count();
86            if lines > 10 {
87                format!("Git operation completed ({lines} lines)")
88            } else if result_str.len() < 100 {
89                format!("Output: {}", &result_str[..result_str.len().min(100)])
90            } else {
91                "Git operation completed".to_string()
92            }
93        }
94
95        // Todo tools
96        "write_todos" => {
97            let count = result_str
98                .lines()
99                .filter(|l| {
100                    let t = l.trim();
101                    t.starts_with("[todo]") || t.starts_with("[doing]") || t.starts_with("[done]")
102                })
103                .count();
104            if count == 1 {
105                "Created 1 todo".to_string()
106            } else if count > 1 {
107                format!("Created {count} todos")
108            } else {
109                "Todos updated".to_string()
110            }
111        }
112        "update_todo" => "Todo updated".to_string(),
113        "complete_todo" => "Todo completed".to_string(),
114        "list_todos" => {
115            let count = result_str
116                .lines()
117                .filter(|l| {
118                    let t = l.trim();
119                    t.starts_with("[todo]") || t.starts_with("[doing]") || t.starts_with("[done]")
120                })
121                .count();
122            format!("{count} todos listed")
123        }
124        "clear_todos" => "All todos cleared".to_string(),
125
126        // Generic fallback
127        _ => {
128            if result_str.len() < 100 {
129                result_str.to_string()
130            } else {
131                let chars = result_str.len();
132                let lines = result_str.lines().count();
133                format!("Success ({lines} lines, {chars} chars)")
134            }
135        }
136    }
137}
138
139/// Truncate a string at a char boundary, never in the middle of a multi-byte char.
140pub fn safe_truncate(s: &str, max_bytes: usize) -> &str {
141    if s.len() <= max_bytes {
142        return s;
143    }
144    // Walk back from max_bytes to find a char boundary
145    let mut end = max_bytes;
146    while end > 0 && !s.is_char_boundary(end) {
147        end -= 1;
148    }
149    &s[..end]
150}
151
152/// Build a rich result string from a background agent's completion.
153///
154/// Uses the LLM's own summary (`content`) as primary content. When the summary
155/// is too short (< 500 chars), appends raw `spawn_subagent` tool outputs as a
156/// reference appendix so the foreground agent has the actual data.
157pub fn build_background_result(
158    content: &str,
159    messages: &[serde_json::Value],
160    total_budget: usize,
161) -> String {
162    let mut result = String::new();
163
164    // 1. Always include agent's own summary (cap at 2/3 of budget)
165    let content_cap = total_budget * 2 / 3;
166    let trimmed = safe_truncate(content, content_cap);
167    result.push_str(trimmed);
168    if content.len() > content_cap {
169        result.push_str("... [truncated]");
170    }
171
172    // 2. If summary is thin, append raw subagent outputs
173    if content.len() < 500 {
174        let subagent_outputs: Vec<&str> = messages
175            .iter()
176            .filter_map(|m| {
177                let role = m.get("role")?.as_str()?;
178                let name = m.get("name")?.as_str()?;
179                let text = m.get("content")?.as_str()?;
180                if role == "tool" && name == "spawn_subagent" && !text.is_empty() {
181                    Some(text)
182                } else {
183                    None
184                }
185            })
186            .collect();
187
188        if !subagent_outputs.is_empty() {
189            let remaining = total_budget.saturating_sub(result.len() + 100);
190            let per_agent = remaining / subagent_outputs.len();
191
192            result.push_str("\n\n## Subagent Outputs\n");
193            for (i, output) in subagent_outputs.iter().enumerate() {
194                result.push_str(&format!("\n### Subagent {}\n", i + 1));
195                let trimmed = safe_truncate(output, per_agent);
196                result.push_str(trimmed);
197                if output.len() > per_agent {
198                    result.push_str("... [truncated]");
199                }
200                result.push('\n');
201            }
202        }
203    }
204
205    // 3. Final safety cap
206    if result.len() > total_budget {
207        let end = safe_truncate(&result, total_budget).len();
208        result.truncate(end);
209        result.push_str("... [truncated]");
210    }
211    result
212}
213
214#[cfg(test)]
215#[path = "tool_summarizer_tests.rs"]
216mod tests;