use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionTrace {
pub run_id: String,
pub steps: Vec<TraceStep>,
pub started_at: String,
pub completed_at: Option<String>,
pub status: RunStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Running,
Completed,
Failed,
CircuitBroken,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceStep {
pub agent_id: String,
pub agent_name: String,
pub step_number: usize,
pub action: TraceAction,
pub input_summary: String,
pub output_summary: String,
pub duration_ms: u64,
pub tokens_used: usize,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TraceAction {
LlmCall,
ToolCall { tool_name: String },
Handoff {
from_agent: String,
to_agent: String,
},
MemoryQuery { query: String, results_count: usize },
MemoryCommit { doc_id: String },
AgentStart,
AgentComplete,
}
pub struct ExecutionTracer {
run_id: String,
steps: Arc<RwLock<Vec<TraceStep>>>,
started_at: String,
}
impl ExecutionTracer {
pub fn new(run_id: impl Into<String>) -> Self {
Self {
run_id: run_id.into(),
steps: Arc::new(RwLock::new(Vec::new())),
started_at: chrono::Utc::now().to_rfc3339(),
}
}
pub fn record(&self, step: TraceStep) {
if let Ok(mut steps) = self.steps.write() {
steps.push(step);
}
}
#[allow(clippy::too_many_arguments)]
pub fn record_llm_call(
&self,
agent_id: &str,
agent_name: &str,
step_number: usize,
input: &str,
output: &str,
duration_ms: u64,
tokens_used: usize,
) {
self.record(TraceStep {
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
step_number,
action: TraceAction::LlmCall,
input_summary: truncate_str(input, 200),
output_summary: truncate_str(output, 500),
duration_ms,
tokens_used,
timestamp: chrono::Utc::now().to_rfc3339(),
});
}
pub fn record_handoff(&self, from_id: &str, from_name: &str, to_id: &str) {
self.record(TraceStep {
agent_id: from_id.to_string(),
agent_name: from_name.to_string(),
step_number: 0,
action: TraceAction::Handoff {
from_agent: from_id.to_string(),
to_agent: to_id.to_string(),
},
input_summary: String::new(),
output_summary: format!("Handing off to {}", to_id),
duration_ms: 0,
tokens_used: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
});
}
pub fn record_memory_query(
&self,
agent_id: &str,
agent_name: &str,
query: &str,
results_count: usize,
) {
self.record(TraceStep {
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
step_number: 0,
action: TraceAction::MemoryQuery {
query: truncate_str(query, 200),
results_count,
},
input_summary: truncate_str(query, 200),
output_summary: format!("{} results found", results_count),
duration_ms: 0,
tokens_used: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
});
}
pub fn record_memory_commit(&self, agent_id: &str, agent_name: &str, doc_id: &str) {
self.record(TraceStep {
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
step_number: 0,
action: TraceAction::MemoryCommit {
doc_id: doc_id.to_string(),
},
input_summary: String::new(),
output_summary: format!("Committed to memory: {}", doc_id),
duration_ms: 0,
tokens_used: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
});
}
pub fn finalize(&self, status: RunStatus) -> ExecutionTrace {
let steps = self.steps.read().map(|s| s.clone()).unwrap_or_default();
ExecutionTrace {
run_id: self.run_id.clone(),
steps,
started_at: self.started_at.clone(),
completed_at: Some(chrono::Utc::now().to_rfc3339()),
status,
}
}
pub fn step_count(&self) -> usize {
self.steps.read().map(|s| s.len()).unwrap_or(0)
}
pub fn run_id(&self) -> &str {
&self.run_id
}
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tracer_record_and_finalize() {
let tracer = ExecutionTracer::new("run-001");
assert_eq!(tracer.step_count(), 0);
tracer.record_llm_call(
"agent-1",
"Security Agent",
1,
"input text",
"output text",
150,
100,
);
assert_eq!(tracer.step_count(), 1);
tracer.record_handoff("agent-1", "Security Agent", "agent-2");
assert_eq!(tracer.step_count(), 2);
let trace = tracer.finalize(RunStatus::Completed);
assert_eq!(trace.run_id, "run-001");
assert_eq!(trace.steps.len(), 2);
assert_eq!(trace.status, RunStatus::Completed);
assert!(trace.completed_at.is_some());
}
#[test]
fn test_tracer_memory_operations() {
let tracer = ExecutionTracer::new("run-002");
tracer.record_memory_query("agent-1", "Research Agent", "Rust safety features", 3);
tracer.record_memory_commit("agent-1", "Research Agent", "doc-xyz");
let trace = tracer.finalize(RunStatus::Completed);
assert_eq!(trace.steps.len(), 2);
match &trace.steps[0].action {
TraceAction::MemoryQuery {
query,
results_count,
} => {
assert!(query.contains("Rust safety"));
assert_eq!(*results_count, 3);
}
_ => panic!("Expected MemoryQuery"),
}
match &trace.steps[1].action {
TraceAction::MemoryCommit { doc_id } => {
assert_eq!(doc_id, "doc-xyz");
}
_ => panic!("Expected MemoryCommit"),
}
}
#[test]
fn test_trace_serialization() {
let tracer = ExecutionTracer::new("run-003");
tracer.record_llm_call("a1", "Agent", 0, "in", "out", 50, 10);
let trace = tracer.finalize(RunStatus::Completed);
let json = serde_json::to_string_pretty(&trace).unwrap();
let deser: ExecutionTrace = serde_json::from_str(&json).unwrap();
assert_eq!(deser.run_id, "run-003");
assert_eq!(deser.steps.len(), 1);
}
#[test]
fn test_truncate_str() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 5), "hello...");
}
#[test]
fn test_run_status_variants() {
let statuses = vec![
RunStatus::Running,
RunStatus::Completed,
RunStatus::Failed,
RunStatus::CircuitBroken,
];
for status in statuses {
let json = serde_json::to_string(&status).unwrap();
let deser: RunStatus = serde_json::from_str(&json).unwrap();
assert_eq!(deser, status);
}
}
}