use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSource {
BuiltIn,
Plugin(String),
Mcp { server: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReactStep {
ToolCall {
tool_name: String,
parameters_redacted: bool,
result_summary: String,
duration_ms: u64,
success: bool,
source: ToolSource,
},
Retrieval {
candidates_considered: usize,
candidates_selected: usize,
avg_similarity: f64,
token_budget_used: usize,
token_budget_total: usize,
},
Guard {
guard_name: String,
fired: bool,
action: String,
detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rejected_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
replacement_content: Option<String>,
},
Normalization {
detected_pattern: String,
retry_number: u8,
preserved_tool_count: usize,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactTrace {
pub turn_id: String,
pub steps: Vec<ReactStep>,
}
impl ReactTrace {
pub fn new(turn_id: &str) -> Self {
Self {
turn_id: turn_id.to_string(),
steps: Vec::new(),
}
}
pub fn record(&mut self, step: ReactStep) {
self.steps.push(step);
}
#[cfg(test)]
pub fn tool_call_count(&self) -> usize {
self.steps
.iter()
.filter(|s| matches!(s, ReactStep::ToolCall { .. }))
.count()
}
#[cfg(test)]
pub fn mcp_calls(&self) -> Vec<&ReactStep> {
self.steps
.iter()
.filter(|s| {
matches!(
s,
ReactStep::ToolCall {
source: ToolSource::Mcp { .. },
..
}
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn react_trace_serializes_tool_call() {
let mut trace = ReactTrace::new("turn-1");
trace.record(ReactStep::ToolCall {
tool_name: "web_search".into(),
parameters_redacted: true,
result_summary: "3 results found".into(),
duration_ms: 450,
success: true,
source: ToolSource::BuiltIn,
});
let json = serde_json::to_string(&trace).unwrap();
assert!(json.contains("web_search"));
assert!(json.contains("tool_call"));
}
#[test]
fn react_trace_serializes_retrieval_snapshot() {
let mut trace = ReactTrace::new("turn-1");
trace.record(ReactStep::Retrieval {
candidates_considered: 10,
candidates_selected: 5,
avg_similarity: 0.78,
token_budget_used: 650,
token_budget_total: 1000,
});
let json = serde_json::to_string(&trace).unwrap();
assert!(json.contains("retrieval"));
}
#[test]
fn react_trace_serializes_guard_outcome() {
let mut trace = ReactTrace::new("turn-1");
trace.record(ReactStep::Guard {
guard_name: "SubagentClaim".into(),
fired: true,
action: "retry".into(),
detail: Some("retried with stronger prompt".into()),
rejected_content: Some("I delegated to my analyst subagent".into()),
replacement_content: None,
});
let json = serde_json::to_string(&trace).unwrap();
assert!(json.contains("SubagentClaim"));
}
#[test]
fn tool_call_count_counts_only_tool_calls() {
let mut trace = ReactTrace::new("turn-1");
trace.record(ReactStep::ToolCall {
tool_name: "bash".into(),
parameters_redacted: false,
result_summary: "ok".into(),
duration_ms: 100,
success: true,
source: ToolSource::BuiltIn,
});
trace.record(ReactStep::Retrieval {
candidates_considered: 5,
candidates_selected: 2,
avg_similarity: 0.9,
token_budget_used: 200,
token_budget_total: 500,
});
assert_eq!(trace.tool_call_count(), 1);
}
#[test]
fn mcp_calls_filters_by_source() {
let mut trace = ReactTrace::new("turn-1");
trace.record(ReactStep::ToolCall {
tool_name: "mcp_tool".into(),
parameters_redacted: false,
result_summary: "done".into(),
duration_ms: 200,
success: true,
source: ToolSource::Mcp {
server: "my-server".into(),
},
});
trace.record(ReactStep::ToolCall {
tool_name: "builtin_tool".into(),
parameters_redacted: false,
result_summary: "done".into(),
duration_ms: 50,
success: true,
source: ToolSource::BuiltIn,
});
assert_eq!(trace.mcp_calls().len(), 1);
}
}