roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
//! Flight Recorder — full ReAct execution trace for within-inference detail.
//!
//! Extends `PipelineTrace` (which captures per-stage timing) with tool calls,
//! retrieval snapshots, and guard outcomes from the inference ReAct loop.
//! Stored as JSON in `pipeline_traces.react_trace_json` (migration 027).

use serde::{Deserialize, Serialize};

/// Where a tool invocation originated.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSource {
    BuiltIn,
    Plugin(String),
    Mcp { server: String },
}

/// A single step in the ReAct inference loop.
#[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>,
        /// The model's original output that was rejected or rewritten.
        /// Present only when the guard fired (rewrite or retry).
        #[serde(skip_serializing_if = "Option::is_none")]
        rejected_content: Option<String>,
        /// What replaced the rejected content (rewrite text or fallback).
        #[serde(skip_serializing_if = "Option::is_none")]
        replacement_content: Option<String>,
    },
    Normalization {
        detected_pattern: String,
        retry_number: u8,
        preserved_tool_count: usize,
    },
}

/// Full ReAct trace for a single turn's inference.
#[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);
    }
}