use adk_core::Event;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq)]
pub struct ToolCallRecord {
pub name: String,
pub args: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceDiagnostic {
pub pattern_type: TracePattern,
pub tool_names: Vec<String>,
pub occurrence_count: usize,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TracePattern {
RedundantCall,
ExecutionLoop,
ExcessiveRetries,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceAnalysis {
pub total_tool_calls: usize,
pub unique_tools: usize,
pub useful_tool_calls: usize,
pub efficiency_score: f64,
pub diagnostics: Vec<TraceDiagnostic>,
}
pub struct TraceAnalyzer;
impl TraceAnalyzer {
pub fn new() -> Self {
Self
}
pub fn analyze(&self, events: &[Event]) -> TraceAnalysis {
let calls = Self::extract_tool_calls(events);
self.analyze_tool_calls(&calls)
}
pub fn analyze_tool_calls(&self, calls: &[ToolCallRecord]) -> TraceAnalysis {
let total_tool_calls = calls.len();
if total_tool_calls == 0 {
return TraceAnalysis {
total_tool_calls: 0,
unique_tools: 0,
useful_tool_calls: 0,
efficiency_score: 1.0,
diagnostics: Vec::new(),
};
}
let unique_tools = {
let mut set = HashSet::new();
for call in calls {
set.insert(call.name.as_str());
}
set.len()
};
let redundant_diagnostics = Self::detect_redundant_calls(calls);
let loop_diagnostics = Self::detect_loops(calls);
let redundant_count: usize = redundant_diagnostics.iter().map(|d| d.occurrence_count).sum();
let loop_count: usize = loop_diagnostics.iter().map(|d| d.occurrence_count).sum();
let wasted = redundant_count + loop_count;
let useful_tool_calls = total_tool_calls.saturating_sub(wasted);
let efficiency_score = useful_tool_calls as f64 / total_tool_calls as f64;
let mut diagnostics = Vec::new();
diagnostics.extend(redundant_diagnostics);
diagnostics.extend(loop_diagnostics);
TraceAnalysis {
total_tool_calls,
unique_tools,
useful_tool_calls,
efficiency_score,
diagnostics,
}
}
fn extract_tool_calls(events: &[Event]) -> Vec<ToolCallRecord> {
let mut calls = Vec::new();
for event in events {
if let Some(content) = &event.llm_response.content {
for part in &content.parts {
if let adk_core::Part::FunctionCall { name, args, .. } = part {
calls.push(ToolCallRecord { name: name.clone(), args: args.clone() });
}
}
}
}
calls
}
fn detect_redundant_calls(calls: &[ToolCallRecord]) -> Vec<TraceDiagnostic> {
if calls.len() < 2 {
return Vec::new();
}
let mut diagnostics: Vec<TraceDiagnostic> = Vec::new();
let mut i = 0;
while i < calls.len() - 1 {
if calls[i].name == calls[i + 1].name && calls[i].args == calls[i + 1].args {
let tool_name = calls[i].name.clone();
let mut count = 0;
let mut j = i + 1;
while j < calls.len()
&& calls[j].name == calls[i].name
&& calls[j].args == calls[i].args
{
count += 1;
j += 1;
}
diagnostics.push(TraceDiagnostic {
pattern_type: TracePattern::RedundantCall,
tool_names: vec![tool_name.clone()],
occurrence_count: count,
description: format!(
"Tool '{}' called {} consecutive time(s) with identical arguments",
tool_name, count
),
});
i = j;
} else {
i += 1;
}
}
diagnostics
}
fn detect_loops(calls: &[ToolCallRecord]) -> Vec<TraceDiagnostic> {
if calls.len() < 3 {
return Vec::new();
}
let names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect();
let n = names.len();
let mut diagnostics: Vec<TraceDiagnostic> = Vec::new();
let mut covered: Vec<bool> = vec![false; n];
for pattern_len in 1..=(n / 3) {
let mut i = 0;
while i + pattern_len * 3 <= n {
if covered[i] {
i += 1;
continue;
}
let pattern = &names[i..i + pattern_len];
let mut repetitions = 1;
let mut j = i + pattern_len;
while j + pattern_len <= n && &names[j..j + pattern_len] == pattern {
repetitions += 1;
j += pattern_len;
}
if repetitions >= 3 {
let loop_tool_names: Vec<String> =
pattern.iter().map(|s| (*s).to_string()).collect();
let wasted_iterations = (repetitions - 1) * pattern_len;
for item in
covered.iter_mut().take(i + repetitions * pattern_len).skip(i + pattern_len)
{
*item = true;
}
diagnostics.push(TraceDiagnostic {
pattern_type: TracePattern::ExecutionLoop,
tool_names: loop_tool_names.clone(),
occurrence_count: wasted_iterations,
description: format!(
"Pattern {:?} repeated {} times ({} wasted iterations)",
loop_tool_names, repetitions, wasted_iterations
),
});
i = j;
} else {
i += 1;
}
}
}
diagnostics
}
}
impl Default for TraceAnalyzer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_empty_calls() {
let analyzer = TraceAnalyzer::new();
let analysis = analyzer.analyze_tool_calls(&[]);
assert_eq!(analysis.total_tool_calls, 0);
assert_eq!(analysis.unique_tools, 0);
assert_eq!(analysis.useful_tool_calls, 0);
assert_eq!(analysis.efficiency_score, 1.0);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn test_no_redundancy() {
let analyzer = TraceAnalyzer::new();
let calls = vec![
ToolCallRecord { name: "read_file".into(), args: json!({"path": "a.txt"}) },
ToolCallRecord { name: "write_file".into(), args: json!({"path": "b.txt"}) },
ToolCallRecord { name: "read_file".into(), args: json!({"path": "c.txt"}) },
];
let analysis = analyzer.analyze_tool_calls(&calls);
assert_eq!(analysis.total_tool_calls, 3);
assert_eq!(analysis.unique_tools, 2);
assert_eq!(analysis.useful_tool_calls, 3);
assert_eq!(analysis.efficiency_score, 1.0);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn test_redundant_calls_detected() {
let analyzer = TraceAnalyzer::new();
let calls = vec![
ToolCallRecord { name: "read_file".into(), args: json!({"path": "a.txt"}) },
ToolCallRecord { name: "read_file".into(), args: json!({"path": "a.txt"}) },
ToolCallRecord { name: "write_file".into(), args: json!({"path": "b.txt"}) },
];
let analysis = analyzer.analyze_tool_calls(&calls);
assert_eq!(analysis.total_tool_calls, 3);
assert_eq!(analysis.useful_tool_calls, 2);
assert!(analysis.efficiency_score < 1.0);
assert!(!analysis.diagnostics.is_empty());
}
#[test]
fn test_same_tool_different_args_not_redundant() {
let analyzer = TraceAnalyzer::new();
let calls = vec![
ToolCallRecord { name: "read_file".into(), args: json!({"path": "a.txt"}) },
ToolCallRecord { name: "read_file".into(), args: json!({"path": "b.txt"}) },
];
let analysis = analyzer.analyze_tool_calls(&calls);
assert_eq!(analysis.useful_tool_calls, 2);
assert_eq!(analysis.efficiency_score, 1.0);
assert!(analysis.diagnostics.is_empty());
}
#[test]
fn test_loop_detection() {
let analyzer = TraceAnalyzer::new();
let calls = vec![
ToolCallRecord { name: "check".into(), args: json!({}) },
ToolCallRecord { name: "check".into(), args: json!({}) },
ToolCallRecord { name: "check".into(), args: json!({}) },
ToolCallRecord { name: "check".into(), args: json!({}) },
];
let analysis = analyzer.analyze_tool_calls(&calls);
assert_eq!(analysis.total_tool_calls, 4);
assert!(analysis.useful_tool_calls < 4);
assert!(analysis.efficiency_score < 1.0);
}
#[test]
fn test_multi_tool_loop_detection() {
let analyzer = TraceAnalyzer::new();
let calls = vec![
ToolCallRecord { name: "read".into(), args: json!({"x": 1}) },
ToolCallRecord { name: "write".into(), args: json!({"y": 2}) },
ToolCallRecord { name: "read".into(), args: json!({"x": 1}) },
ToolCallRecord { name: "write".into(), args: json!({"y": 2}) },
ToolCallRecord { name: "read".into(), args: json!({"x": 1}) },
ToolCallRecord { name: "write".into(), args: json!({"y": 2}) },
];
let analysis = analyzer.analyze_tool_calls(&calls);
assert_eq!(analysis.total_tool_calls, 6);
assert!(analysis.useful_tool_calls < 6);
assert!(analysis.efficiency_score < 1.0);
}
#[test]
fn test_analyze_events() {
use adk_core::{Content, Event, Part};
let analyzer = TraceAnalyzer::new();
let mut event1 = Event::new("inv-1");
event1.llm_response.content = Some(Content {
role: "model".to_string(),
parts: vec![Part::FunctionCall {
name: "get_weather".to_string(),
args: json!({"city": "NYC"}),
id: None,
thought_signature: None,
}],
});
let mut event2 = Event::new("inv-1");
event2.llm_response.content = Some(Content {
role: "model".to_string(),
parts: vec![Part::FunctionCall {
name: "get_weather".to_string(),
args: json!({"city": "NYC"}),
id: None,
thought_signature: None,
}],
});
let analysis = analyzer.analyze(&[event1, event2]);
assert_eq!(analysis.total_tool_calls, 2);
assert_eq!(analysis.unique_tools, 1);
assert_eq!(analysis.useful_tool_calls, 1);
assert_eq!(analysis.efficiency_score, 0.5);
}
#[test]
fn test_single_call() {
let analyzer = TraceAnalyzer::new();
let calls = vec![ToolCallRecord { name: "search".into(), args: json!({"query": "hello"}) }];
let analysis = analyzer.analyze_tool_calls(&calls);
assert_eq!(analysis.total_tool_calls, 1);
assert_eq!(analysis.unique_tools, 1);
assert_eq!(analysis.useful_tool_calls, 1);
assert_eq!(analysis.efficiency_score, 1.0);
}
#[test]
fn test_efficiency_score_bounds() {
let analyzer = TraceAnalyzer::new();
let calls = vec![
ToolCallRecord { name: "ping".into(), args: json!({}) },
ToolCallRecord { name: "ping".into(), args: json!({}) },
ToolCallRecord { name: "ping".into(), args: json!({}) },
ToolCallRecord { name: "ping".into(), args: json!({}) },
ToolCallRecord { name: "ping".into(), args: json!({}) },
];
let analysis = analyzer.analyze_tool_calls(&calls);
assert!(analysis.efficiency_score >= 0.0);
assert!(analysis.efficiency_score <= 1.0);
assert!(analysis.useful_tool_calls <= analysis.total_tool_calls);
}
}