use klieo_core::Episode;
use sha2::{Digest, Sha256};
use crate::chain::{ProvenanceChain, ProvenanceEvent, ProvenanceEventKind};
use crate::error::ProvenanceError;
#[must_use]
pub fn error_category(error: &str) -> &'static str {
let lower = error.to_ascii_lowercase();
if lower.contains("timeout") || lower.contains("timed out") {
"tool_timeout"
} else if lower.contains("cancel") {
"cancelled"
} else if lower.contains("max step") || lower.contains("max_steps") {
"max_steps"
} else if lower.contains("llm") || lower.contains("model") {
"llm_error"
} else if lower.contains("tool") {
"tool_error"
} else {
"unknown"
}
}
pub fn episode_to_event(
actor: &str,
run_ref: &str,
episode: &Episode,
) -> Result<ProvenanceEvent, ProvenanceError> {
let (kind, metadata) = classify_episode(episode);
let payload_bytes = serde_json::to_vec(episode).map_err(ProvenanceError::Serialization)?;
let payload_hash_hex = hex::encode(Sha256::digest(payload_bytes));
Ok(ProvenanceEvent {
kind,
actor: actor.into(),
resource_ref: run_ref.into(),
payload_hash_hex,
metadata,
})
}
pub fn chain_from_episodes(
scope: impl Into<String>,
actor: impl Into<String>,
episodes: &[Episode],
) -> Result<ProvenanceChain, ProvenanceError> {
let scope = scope.into();
let actor = actor.into();
let mut chain = ProvenanceChain::new(scope.clone());
for episode in episodes {
chain.append(episode_to_event(&actor, &scope, episode)?);
}
Ok(chain)
}
fn classify_episode(episode: &Episode) -> (ProvenanceEventKind, serde_json::Value) {
match episode {
Episode::Started { agent } => (
ProvenanceEventKind::AgentRunStarted,
serde_json::json!({ "agent": agent }),
),
Episode::LlmCall {
tokens,
latency_ms,
provider,
model,
prompt_tokens,
completion_tokens,
} => (
ProvenanceEventKind::LlmCall,
serde_json::json!({
"tokens": tokens,
"latency_ms": latency_ms,
"provider": provider,
"model": model,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
}),
),
Episode::ToolCall { name, .. } => (
ProvenanceEventKind::ToolCall,
serde_json::json!({ "tool": name }),
),
Episode::BusPublish { subject } => (
ProvenanceEventKind::Custom("bus_publish".into()),
serde_json::json!({ "subject": subject }),
),
Episode::BusReceive { subject } => (
ProvenanceEventKind::Custom("bus_receive".into()),
serde_json::json!({ "subject": subject }),
),
Episode::BusCausalLink {
subject,
caused_by_run,
} => (
ProvenanceEventKind::Custom("bus_causal_link".into()),
serde_json::json!({ "subject": subject, "caused_by_run": caused_by_run }),
),
Episode::Completed => (
ProvenanceEventKind::AgentRunCompleted,
serde_json::json!({ "status": "ok" }),
),
Episode::Failed { error } => (
ProvenanceEventKind::AgentRunCompleted,
serde_json::json!({ "status": "failed", "error_category": error_category(error) }),
),
Episode::SummaryCheckpoint {
input_message_count,
summary_chars,
latency_ms,
tokens,
} => (
ProvenanceEventKind::Custom("summary_checkpoint".into()),
serde_json::json!({
"input_message_count": input_message_count,
"summary_chars": summary_chars,
"latency_ms": latency_ms,
"tokens": tokens,
}),
),
_ => (
ProvenanceEventKind::Custom("unknown".into()),
serde_json::json!({}),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event_for(episode: &Episode) -> ProvenanceEvent {
episode_to_event("agent:test", "run-test", episode).expect("serialisable episode")
}
#[test]
fn chain_from_episodes_links_entries_and_stamps_actor() {
let episodes = [
Episode::Started {
agent: "planner".into(),
},
Episode::Completed,
];
let chain =
chain_from_episodes("scope-1", "agent:planner", &episodes).expect("projection ok");
assert_eq!(chain.entries.len(), 2);
assert_eq!(
chain.entries[0].event.kind,
ProvenanceEventKind::AgentRunStarted
);
assert_eq!(
chain.entries[1].event.kind,
ProvenanceEventKind::AgentRunCompleted
);
for entry in &chain.entries {
assert_eq!(entry.event.actor, "agent:planner");
}
assert_eq!(
chain.entries[1].previous_hash_hex,
chain.entries[0].entry_hash_hex
);
chain.verify().expect("projected chain verifies end-to-end");
}
#[test]
fn empty_episode_slice_yields_empty_chain() {
let chain = chain_from_episodes("scope-empty", "agent:none", &[]).expect("projection ok");
assert_eq!(chain.entries.len(), 0);
chain.verify().expect("empty chain verifies");
}
#[test]
fn three_entry_chain_links_each_entry_to_its_predecessor() {
let episodes = [
Episode::Started {
agent: "planner".into(),
},
Episode::llm_call(7, 12),
Episode::Completed,
];
let chain = chain_from_episodes("scope-3", "agent:planner", &episodes).expect("ok");
assert_eq!(chain.entries.len(), 3);
for i in 1..chain.entries.len() {
assert_eq!(
chain.entries[i].previous_hash_hex,
chain.entries[i - 1].entry_hash_hex,
"entry {i} must link to entry {}",
i - 1
);
}
chain.verify().expect("3-entry chain verifies");
}
#[test]
fn tool_call_projects_tool_kind_and_name() {
let episode = Episode::ToolCall {
name: "search".into(),
args: serde_json::json!({ "q": "x" }),
result: klieo_core::ToolResult::ok(serde_json::json!({ "hits": 0 })),
};
let event = event_for(&episode);
assert_eq!(event.kind, ProvenanceEventKind::ToolCall);
assert_eq!(event.metadata["tool"], "search");
}
#[test]
fn llm_call_projects_llm_kind_and_token_metadata() {
let episode = Episode::llm_call(123, 45);
let event = event_for(&episode);
assert_eq!(event.kind, ProvenanceEventKind::LlmCall);
assert_eq!(event.metadata["tokens"], 123);
assert_eq!(event.metadata["latency_ms"], 45);
}
#[test]
fn bus_publish_projects_custom_kind_and_subject() {
let episode = Episode::BusPublish {
subject: "jobs.research".into(),
};
let event = event_for(&episode);
assert_eq!(
event.kind,
ProvenanceEventKind::Custom("bus_publish".into())
);
assert_eq!(event.metadata["subject"], "jobs.research");
}
#[test]
fn bus_receive_projects_custom_kind_and_subject() {
let episode = Episode::BusReceive {
subject: "jobs.result".into(),
};
let event = event_for(&episode);
assert_eq!(
event.kind,
ProvenanceEventKind::Custom("bus_receive".into())
);
assert_eq!(event.metadata["subject"], "jobs.result");
}
#[test]
fn bus_causal_link_projects_custom_kind_and_caused_by_run() {
let episode = Episode::BusCausalLink {
subject: "jobs.research".into(),
caused_by_run: "run-parent".into(),
};
let event = event_for(&episode);
assert_eq!(
event.kind,
ProvenanceEventKind::Custom("bus_causal_link".into())
);
assert_eq!(event.metadata["subject"], "jobs.research");
assert_eq!(event.metadata["caused_by_run"], "run-parent");
}
#[test]
fn summary_checkpoint_projects_custom_kind_and_counters() {
let episode = Episode::SummaryCheckpoint {
input_message_count: 9,
summary_chars: 256,
latency_ms: 11,
tokens: 64,
};
let event = event_for(&episode);
assert_eq!(
event.kind,
ProvenanceEventKind::Custom("summary_checkpoint".into())
);
assert_eq!(event.metadata["input_message_count"], 9);
assert_eq!(event.metadata["summary_chars"], 256);
}
#[test]
fn single_failed_episode_verifies_with_error_category() {
let episodes = [Episode::Failed {
error: "model overloaded".into(),
}];
let chain = chain_from_episodes("scope-failed", "agent:test", &episodes).expect("ok");
assert_eq!(chain.entries.len(), 1);
assert_eq!(
chain.entries[0].event.kind,
ProvenanceEventKind::AgentRunCompleted
);
assert_eq!(chain.entries[0].event.metadata["status"], "failed");
assert_eq!(
chain.entries[0].event.metadata["error_category"],
"llm_error"
);
chain.verify().expect("failed-only chain verifies");
}
#[test]
fn failed_episode_redacts_raw_error_to_category() {
let secret = "hunter2";
let episodes = [Episode::Failed {
error: format!("DB password: {secret} leaked at /etc/secrets, tool timed out"),
}];
let chain = chain_from_episodes("redaction", "tester", &episodes).expect("projection ok");
let metadata = &chain.entries[0].event.metadata;
assert_eq!(metadata["status"], "failed");
assert_eq!(metadata["error_category"], "tool_timeout");
let serialized = serde_json::to_string(metadata).unwrap();
assert!(
!serialized.contains(secret),
"raw error secret must not enter the chain metadata"
);
assert!(!serialized.contains("DB password"));
}
#[test]
fn error_category_maps_known_classes() {
assert_eq!(error_category("connection timed out"), "tool_timeout");
assert_eq!(error_category("operation cancelled"), "cancelled");
assert_eq!(error_category("hit max steps limit"), "max_steps");
assert_eq!(error_category("model overloaded"), "llm_error");
assert_eq!(error_category("tool dispatch failed"), "tool_error");
assert_eq!(error_category("something odd"), "unknown");
}
#[test]
fn serialization_error_chains_its_source() {
let serde_err = serde_json::from_str::<i32>("not-an-int").unwrap_err();
let err = ProvenanceError::Serialization(serde_err);
assert!(
std::error::Error::source(&err).is_some(),
"Serialization variant must chain the serde_json::Error as its source"
);
}
}