use chrono::{DateTime, Utc};
use serde_json::Value;
use entelix_core::ErrorEnvelope;
use entelix_core::RenderedForLlm;
use entelix_core::TenantId;
use entelix_core::UsageSnapshot;
use entelix_core::ir::ToolResultContent;
use entelix_session::GraphEvent;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum AgentEvent<S> {
Started {
run_id: String,
tenant_id: TenantId,
parent_run_id: Option<String>,
agent: String,
},
ToolStart {
run_id: String,
tenant_id: TenantId,
tool_use_id: String,
tool: String,
tool_version: Option<String>,
input: Value,
},
ToolComplete {
run_id: String,
tenant_id: TenantId,
tool_use_id: String,
tool: String,
tool_version: Option<String>,
duration_ms: u64,
output: Value,
},
ToolError {
run_id: String,
tenant_id: TenantId,
tool_use_id: String,
tool: String,
tool_version: Option<String>,
error: String,
error_for_llm: RenderedForLlm<String>,
envelope: ErrorEnvelope,
duration_ms: u64,
},
Failed {
run_id: String,
tenant_id: TenantId,
error: String,
envelope: ErrorEnvelope,
},
Complete {
run_id: String,
tenant_id: TenantId,
state: S,
usage: Option<UsageSnapshot>,
},
ToolCallApproved {
run_id: String,
tenant_id: TenantId,
tool_use_id: String,
tool: String,
},
ToolCallDenied {
run_id: String,
tenant_id: TenantId,
tool_use_id: String,
tool: String,
reason: String,
},
}
impl<S> AgentEvent<S> {
pub fn to_graph_event(&self, timestamp: DateTime<Utc>) -> Option<GraphEvent> {
match self {
Self::Started { .. }
| Self::Complete { .. }
| Self::Failed { .. }
| Self::ToolCallApproved { .. }
| Self::ToolCallDenied { .. } => None,
Self::ToolStart {
tool_use_id,
tool,
input,
..
} => Some(GraphEvent::ToolCall {
id: tool_use_id.clone(),
name: tool.clone(),
input: input.clone(),
timestamp,
}),
Self::ToolComplete {
tool_use_id,
tool,
output,
..
} => Some(GraphEvent::ToolResult {
tool_use_id: tool_use_id.clone(),
name: tool.clone(),
content: ToolResultContent::Json(output.clone()),
is_error: false,
timestamp,
}),
Self::ToolError {
tool_use_id,
tool,
error_for_llm,
..
} => Some(GraphEvent::ToolResult {
tool_use_id: tool_use_id.clone(),
name: tool.clone(),
content: ToolResultContent::Text(error_for_llm.as_inner().clone()),
is_error: true,
timestamp,
}),
}
}
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn erase_state(self) -> AgentEvent<()> {
match self {
Self::Started {
run_id,
tenant_id,
parent_run_id,
agent,
} => AgentEvent::Started {
run_id,
tenant_id,
parent_run_id,
agent,
},
Self::ToolStart {
run_id,
tenant_id,
tool_use_id,
tool,
tool_version,
input,
} => AgentEvent::ToolStart {
run_id,
tenant_id,
tool_use_id,
tool,
tool_version,
input,
},
Self::ToolComplete {
run_id,
tenant_id,
tool_use_id,
tool,
tool_version,
duration_ms,
output,
} => AgentEvent::ToolComplete {
run_id,
tenant_id,
tool_use_id,
tool,
tool_version,
duration_ms,
output,
},
Self::ToolError {
run_id,
tenant_id,
tool_use_id,
tool,
tool_version,
error,
error_for_llm,
envelope,
duration_ms,
} => AgentEvent::ToolError {
run_id,
tenant_id,
tool_use_id,
tool,
tool_version,
error,
error_for_llm,
envelope,
duration_ms,
},
Self::Failed {
run_id,
tenant_id,
error,
envelope,
} => AgentEvent::Failed {
run_id,
tenant_id,
error,
envelope,
},
Self::Complete {
run_id,
tenant_id,
state: _,
usage,
} => AgentEvent::Complete {
run_id,
tenant_id,
state: (),
usage,
},
Self::ToolCallApproved {
run_id,
tenant_id,
tool_use_id,
tool,
} => AgentEvent::ToolCallApproved {
run_id,
tenant_id,
tool_use_id,
tool,
},
Self::ToolCallDenied {
run_id,
tenant_id,
tool_use_id,
tool,
reason,
} => AgentEvent::ToolCallDenied {
run_id,
tenant_id,
tool_use_id,
tool,
reason,
},
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
fn ts() -> DateTime<Utc> {
chrono::DateTime::parse_from_rfc3339("2026-04-29T12:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn lifecycle_variants_have_no_audit_projection() {
let tenant = TenantId::new("t-test");
let started: AgentEvent<u32> = AgentEvent::Started {
run_id: "r1".into(),
tenant_id: tenant.clone(),
parent_run_id: None,
agent: "a".into(),
};
let complete: AgentEvent<u32> = AgentEvent::Complete {
run_id: "r1".into(),
tenant_id: tenant.clone(),
state: 7,
usage: None,
};
let failed: AgentEvent<u32> = AgentEvent::Failed {
run_id: "r1".into(),
tenant_id: tenant,
error: "boom".into(),
envelope: entelix_core::Error::config("boom").envelope(),
};
assert!(started.to_graph_event(ts()).is_none());
assert!(complete.to_graph_event(ts()).is_none());
assert!(failed.to_graph_event(ts()).is_none());
}
#[test]
fn tool_start_projects_to_graph_event_tool_call() {
let event: AgentEvent<u32> = AgentEvent::ToolStart {
run_id: "r1".into(),
tenant_id: TenantId::new("t-test"),
tool_use_id: "tu-1".into(),
tool: "double".into(),
tool_version: Some("1.2.0".into()),
input: json!({"n": 21}),
};
let projected = event.to_graph_event(ts()).unwrap();
match projected {
GraphEvent::ToolCall {
id,
name,
input,
timestamp,
} => {
assert_eq!(id, "tu-1");
assert_eq!(name, "double");
assert_eq!(input, json!({"n": 21}));
assert_eq!(timestamp, ts());
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn tool_complete_projects_to_successful_tool_result() {
let event: AgentEvent<u32> = AgentEvent::ToolComplete {
run_id: "r1".into(),
tenant_id: TenantId::new("t-test"),
tool_use_id: "tu-1".into(),
tool: "double".into(),
tool_version: Some("1.2.0".into()),
duration_ms: 42,
output: json!({"doubled": 42}),
};
let projected = event.to_graph_event(ts()).unwrap();
match projected {
GraphEvent::ToolResult {
tool_use_id,
name,
content,
is_error,
timestamp,
} => {
assert_eq!(tool_use_id, "tu-1");
assert_eq!(name, "double");
assert!(!is_error, "successful tool dispatch must not flag is_error");
assert_eq!(timestamp, ts());
match content {
ToolResultContent::Json(v) => assert_eq!(v, json!({"doubled": 42})),
other => panic!("expected Json content, got {other:?}"),
}
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn tool_error_projects_to_error_flagged_tool_result_using_llm_facing_text() {
use entelix_core::{Error, LlmRenderable};
let source = Error::provider_http(503, "vendor down");
let envelope = source.envelope();
let llm_facing = source.for_llm();
let event: AgentEvent<u32> = AgentEvent::ToolError {
run_id: "r1".into(),
tenant_id: TenantId::new("t-test"),
tool_use_id: "tu-1".into(),
tool: "double".into(),
tool_version: None,
error: "provider returned 503: vendor down".into(),
error_for_llm: llm_facing,
envelope,
duration_ms: 7,
};
let projected = event.to_graph_event(ts()).unwrap();
match projected {
GraphEvent::ToolResult {
tool_use_id,
name,
content,
is_error,
..
} => {
assert_eq!(tool_use_id, "tu-1");
assert_eq!(name, "double");
assert!(is_error, "ToolError must surface as is_error: true");
match content {
ToolResultContent::Text(s) => {
assert_eq!(s, "upstream model error");
assert!(
!s.contains("provider returned"),
"audit log content must use the LLM-facing rendering, not the operator-facing one: {s}"
);
assert!(
!s.contains("503"),
"audit log must not leak vendor status code: {s}"
);
}
other => panic!("expected Text content for error, got {other:?}"),
}
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn projection_is_deterministic_across_calls() {
let event: AgentEvent<u32> = AgentEvent::ToolStart {
run_id: "r1".into(),
tenant_id: TenantId::new("t-test"),
tool_use_id: "tu-1".into(),
tool: "double".into(),
tool_version: None,
input: json!({"n": 21}),
};
let a = event.to_graph_event(ts()).unwrap();
let b = event.to_graph_event(ts()).unwrap();
assert_eq!(a, b);
}
}