use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct ToolCallEvent {
pub tool_name: String,
pub duration_ms: u64,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caller_id: Option<String>,
}
impl ToolCallEvent {
#[must_use]
pub fn new(
tool_name: impl Into<String>,
duration_ms: u64,
success: bool,
error: Option<String>,
caller_id: Option<String>,
) -> Self {
Self {
tool_name: tool_name.into(),
duration_ms,
success,
error,
caller_id,
}
}
}
pub trait AuditSink: Send + Sync {
fn log(&self, event: &ToolCallEvent);
}
impl AuditSink for () {
fn log(&self, _event: &ToolCallEvent) {}
}
#[cfg(feature = "audit")]
mod libro_impl {
use super::*;
use libro::chain::AuditChain;
use libro::entry::EventSeverity;
use std::sync::Mutex;
pub struct LibroAudit {
chain: Mutex<AuditChain>,
source: String,
agent_id: Option<String>,
}
impl LibroAudit {
#[must_use]
pub fn new() -> Self {
Self {
chain: Mutex::new(AuditChain::new()),
source: "bote".into(),
agent_id: None,
}
}
#[must_use]
pub fn with_chain(chain: AuditChain) -> Self {
Self {
chain: Mutex::new(chain),
source: "bote".into(),
agent_id: None,
}
}
#[must_use]
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = source.into();
self
}
#[must_use]
pub fn with_agent_id(mut self, agent_id: impl Into<String>) -> Self {
self.agent_id = Some(agent_id.into());
self
}
#[must_use = "access the underlying audit chain"]
pub fn chain(&self) -> std::sync::MutexGuard<'_, AuditChain> {
self.chain.lock().unwrap_or_else(|e| e.into_inner())
}
}
impl Default for LibroAudit {
fn default() -> Self {
Self::new()
}
}
impl AuditSink for LibroAudit {
fn log(&self, event: &ToolCallEvent) {
let severity = if event.success {
EventSeverity::Info
} else {
EventSeverity::Error
};
let action = if event.success {
"tool.completed"
} else {
"tool.failed"
};
let details = serde_json::json!({
"tool_name": event.tool_name,
"duration_ms": event.duration_ms,
"success": event.success,
"error": event.error,
"caller_id": event.caller_id,
});
let mut chain = self.chain.lock().unwrap_or_else(|e| e.into_inner());
let agent = event.caller_id.as_deref().or(self.agent_id.as_deref());
if let Some(agent) = agent {
chain.append_with_agent(severity, &self.source, action, details, agent);
} else {
chain.append(severity, &self.source, action, details);
}
}
}
}
#[cfg(feature = "audit")]
pub use libro_impl::LibroAudit;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_call_event_serializes() {
let event = ToolCallEvent {
tool_name: "echo".into(),
duration_ms: 42,
success: true,
error: None,
caller_id: Some("agent-1".into()),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"echo\""));
assert!(json.contains("42"));
assert!(!json.contains("\"error\""));
}
#[test]
fn tool_call_event_with_error_serializes() {
let event = ToolCallEvent {
tool_name: "broken".into(),
duration_ms: 5,
success: false,
error: Some("handler crashed".into()),
caller_id: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"error\""));
assert!(json.contains("handler crashed"));
assert!(!json.contains("\"caller_id\""));
}
#[test]
fn noop_sink_compiles() {
let sink: &dyn AuditSink = &();
sink.log(&ToolCallEvent {
tool_name: "test".into(),
duration_ms: 0,
success: true,
error: None,
caller_id: None,
});
}
}
#[cfg(all(test, feature = "audit"))]
mod audit_tests {
use super::*;
#[test]
fn libro_audit_logs_success() {
let audit = LibroAudit::new();
audit.log(&ToolCallEvent {
tool_name: "echo".into(),
duration_ms: 10,
success: true,
error: None,
caller_id: Some("agent-1".into()),
});
let chain = audit.chain();
assert_eq!(chain.len(), 1);
let entry = &chain.entries()[0];
assert_eq!(entry.source(), "bote");
assert_eq!(entry.action(), "tool.completed");
assert_eq!(entry.severity(), libro::entry::EventSeverity::Info);
assert_eq!(entry.details()["tool_name"], "echo");
assert_eq!(entry.details()["duration_ms"], 10);
}
#[test]
fn libro_audit_logs_failure() {
let audit = LibroAudit::new();
audit.log(&ToolCallEvent {
tool_name: "broken".into(),
duration_ms: 5,
success: false,
error: Some("handler crashed".into()),
caller_id: None,
});
let chain = audit.chain();
assert_eq!(chain.len(), 1);
let entry = &chain.entries()[0];
assert_eq!(entry.action(), "tool.failed");
assert_eq!(entry.severity(), libro::entry::EventSeverity::Error);
assert_eq!(entry.details()["error"], "handler crashed");
}
#[test]
fn libro_audit_chain_links() {
let audit = LibroAudit::new();
for i in 0..3 {
audit.log(&ToolCallEvent {
tool_name: format!("tool_{i}"),
duration_ms: i as u64,
success: true,
error: None,
caller_id: None,
});
}
let chain = audit.chain();
assert_eq!(chain.len(), 3);
assert!(chain.verify().is_ok());
}
#[test]
fn libro_audit_caller_id_becomes_agent() {
let audit = LibroAudit::new();
audit.log(&ToolCallEvent {
tool_name: "echo".into(),
duration_ms: 1,
success: true,
error: None,
caller_id: Some("user-42".into()),
});
let chain = audit.chain();
let entry = &chain.entries()[0];
assert_eq!(entry.agent_id(), Some("user-42"));
}
#[test]
fn libro_audit_configured_agent_id() {
let audit = LibroAudit::new().with_agent_id("mcp-server-1");
audit.log(&ToolCallEvent {
tool_name: "echo".into(),
duration_ms: 1,
success: true,
error: None,
caller_id: None, });
let chain = audit.chain();
let entry = &chain.entries()[0];
assert_eq!(entry.agent_id(), Some("mcp-server-1"));
}
#[test]
fn libro_audit_caller_id_overrides_configured_agent() {
let audit = LibroAudit::new().with_agent_id("mcp-server-1");
audit.log(&ToolCallEvent {
tool_name: "echo".into(),
duration_ms: 1,
success: true,
error: None,
caller_id: Some("user-42".into()), });
let chain = audit.chain();
let entry = &chain.entries()[0];
assert_eq!(entry.agent_id(), Some("user-42"));
}
#[test]
fn libro_audit_custom_source() {
let audit = LibroAudit::new().with_source("my-mcp-server");
audit.log(&ToolCallEvent {
tool_name: "echo".into(),
duration_ms: 1,
success: true,
error: None,
caller_id: None,
});
let chain = audit.chain();
let entry = &chain.entries()[0];
assert_eq!(entry.source(), "my-mcp-server");
}
#[test]
fn libro_audit_no_agent_when_none() {
let audit = LibroAudit::new(); audit.log(&ToolCallEvent {
tool_name: "echo".into(),
duration_ms: 1,
success: true,
error: None,
caller_id: None, });
let chain = audit.chain();
let entry = &chain.entries()[0];
assert_eq!(entry.agent_id(), None);
}
}