use chrono::{DateTime, Utc};
use serde_json::Value;
use entelix_core::Error;
use entelix_core::ErrorEnvelope;
use entelix_core::InterruptionKind;
use entelix_core::RenderedForLlm;
use entelix_core::TenantId;
use entelix_core::ToolErrorKind;
use entelix_core::UsageSnapshot;
use entelix_core::ir::ToolResultContent;
use entelix_session::GraphEvent;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FailureKind {
ToolErrorTerminal {
kind: ToolErrorKind,
tool_name: String,
},
UsageLimitExceeded,
Cancelled,
DeadlineExceeded,
ModelDispatch,
Internal,
Unclassified,
}
impl FailureKind {
#[must_use]
pub fn from_error(err: &Error) -> Self {
match err {
Error::ToolErrorTerminal {
kind, tool_name, ..
} => Self::ToolErrorTerminal {
kind: *kind,
tool_name: tool_name.clone(),
},
Error::UsageLimitExceeded(_) => Self::UsageLimitExceeded,
Error::Cancelled => Self::Cancelled,
Error::DeadlineExceeded => Self::DeadlineExceeded,
Error::Provider { .. } | Error::Auth(_) | Error::ModelRetry { .. } => {
Self::ModelDispatch
}
Error::InvalidRequest(_) | Error::Config(_) | Error::Serde(_) => Self::Internal,
other => {
tracing::warn!(
target: "entelix_agents::failure_kind",
error = ?other,
"FailureKind::from_error catalog drift — add an explicit classifier arm"
);
Self::Unclassified
}
}
}
#[must_use]
pub const fn wire_id(&self) -> &'static str {
match self {
Self::ToolErrorTerminal { .. } => "tool_error_terminal",
Self::UsageLimitExceeded => "usage_limit_exceeded",
Self::Cancelled => "cancelled",
Self::DeadlineExceeded => "deadline_exceeded",
Self::ModelDispatch => "model_dispatch",
Self::Internal => "internal",
Self::Unclassified => "unclassified",
}
}
}
#[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,
kind: ToolErrorKind,
duration_ms: u64,
},
Failed {
run_id: String,
tenant_id: TenantId,
error: String,
envelope: ErrorEnvelope,
kind: FailureKind,
},
Interrupted {
run_id: String,
tenant_id: TenantId,
kind: InterruptionKind,
payload: Value,
},
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::Interrupted { .. }
| 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,
kind,
duration_ms,
} => AgentEvent::ToolError {
run_id,
tenant_id,
tool_use_id,
tool,
tool_version,
error,
error_for_llm,
envelope,
kind,
duration_ms,
},
Self::Failed {
run_id,
tenant_id,
error,
envelope,
kind,
} => AgentEvent::Failed {
run_id,
tenant_id,
error,
envelope,
kind,
},
Self::Interrupted {
run_id,
tenant_id,
kind,
payload,
} => AgentEvent::Interrupted {
run_id,
tenant_id,
kind,
payload,
},
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(),
kind: FailureKind::Internal,
};
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 kind = ToolErrorKind::classify(&source);
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,
kind,
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);
}
}