use std::sync::Arc;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use klieo_core::{Episode, EpisodicMemory, MemoryError, RunFilter, RunId, RunSummary};
use crate::chain::{ProvenanceEvent, ProvenanceEventKind};
use crate::repository::ProvenanceRepository;
#[non_exhaustive]
pub struct ProvenanceEpisodic {
inner: Arc<dyn EpisodicMemory>,
repo: Arc<dyn ProvenanceRepository>,
scope: String,
actor: String,
}
impl ProvenanceEpisodic {
pub fn new(
inner: Arc<dyn EpisodicMemory>,
repo: Arc<dyn ProvenanceRepository>,
scope: impl Into<String>,
actor: impl Into<String>,
) -> Self {
Self {
inner,
repo,
scope: scope.into(),
actor: actor.into(),
}
}
}
pub(crate) fn episode_to_event(run_id: RunId, episode: &Episode, actor: &str) -> ProvenanceEvent {
ProvenanceEvent {
kind: episode_kind(episode),
actor: actor.to_string(),
resource_ref: run_id.to_string(),
payload_hash_hex: payload_hash_hex(episode),
metadata: serde_json::Value::Null,
}
}
fn episode_kind(episode: &Episode) -> ProvenanceEventKind {
match episode {
Episode::Started { .. } | Episode::RunAttributed { .. } | Episode::RunOrigin { .. } => {
ProvenanceEventKind::AgentRunStarted
}
Episode::LlmCall { .. } | Episode::SummaryCheckpoint { .. } => ProvenanceEventKind::LlmCall,
Episode::ToolCall { .. } => ProvenanceEventKind::ToolCall,
Episode::BusPublish { .. } | Episode::BusReceive { .. } | Episode::BusCausalLink { .. } => {
ProvenanceEventKind::MemoryWrite
}
Episode::Completed | Episode::Failed { .. } => ProvenanceEventKind::AgentRunCompleted,
Episode::Ops(_) => ProvenanceEventKind::Custom("ops".to_string()),
_ => ProvenanceEventKind::Custom("episode".to_string()),
}
}
fn payload_hash_hex(episode: &Episode) -> String {
let bytes = serde_json::to_vec(episode).unwrap_or_else(|err| {
tracing::warn!(%err, "episode serialization failed; provenance payload hash will use the empty-input digest");
Vec::new()
});
let digest = Sha256::digest(&bytes);
hex::encode(digest)
}
#[async_trait]
impl EpisodicMemory for ProvenanceEpisodic {
async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError> {
let provenance_event = episode_to_event(run, &event, &self.actor);
self.inner.record(run, event).await?;
if let Err(err) = self.repo.append(&self.scope, provenance_event).await {
tracing::warn!(%err, run_id = %run, "provenance append failed");
}
Ok(())
}
async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError> {
self.inner.replay(run).await
}
async fn replay_many(&self, runs: &[RunId]) -> Result<Vec<(RunId, Vec<Episode>)>, MemoryError> {
self.inner.replay_many(runs).await
}
async fn list_runs(&self, filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError> {
self.inner.list_runs(filter).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use klieo_core::ToolResult;
use std::collections::HashMap;
use std::sync::Mutex;
use chrono::Utc;
use crate::chain::ChainEntry;
use crate::error::ProvenanceError;
const TEST_SCOPE: &str = "test-scope";
const TEST_ACTOR: &str = "klieo-agent";
#[derive(Default)]
struct FakeEpisodic {
runs: Mutex<HashMap<RunId, Vec<Episode>>>,
}
#[async_trait]
impl EpisodicMemory for FakeEpisodic {
async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError> {
self.runs
.lock()
.unwrap()
.entry(run)
.or_default()
.push(event);
Ok(())
}
async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError> {
Ok(self
.runs
.lock()
.unwrap()
.get(&run)
.cloned()
.unwrap_or_default())
}
async fn list_runs(&self, _filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError> {
Ok(Vec::new())
}
}
struct FailingRepo;
#[async_trait]
impl ProvenanceRepository for FailingRepo {
async fn append(
&self,
_scope: &str,
_event: ProvenanceEvent,
) -> Result<ChainEntry, ProvenanceError> {
Err(ProvenanceError::Repository("boom".to_string()))
}
async fn head(&self, _scope: &str) -> Result<Option<ChainEntry>, ProvenanceError> {
Ok(None)
}
async fn list_range(
&self,
_scope: &str,
_from: u64,
_to: u64,
) -> Result<Vec<ChainEntry>, ProvenanceError> {
Ok(Vec::new())
}
async fn list_by_time(
&self,
_scope: &str,
_from: chrono::DateTime<Utc>,
_to: chrono::DateTime<Utc>,
) -> Result<Vec<ChainEntry>, ProvenanceError> {
Ok(Vec::new())
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn record_delegates_and_appends_to_chain() {
let inner = Arc::new(FakeEpisodic::default());
let repo = Arc::new(crate::SqliteProvenanceRepository::open_in_memory().unwrap());
let decorator =
ProvenanceEpisodic::new(inner.clone(), repo.clone(), TEST_SCOPE, TEST_ACTOR);
let run = RunId::new();
decorator
.record(run, Episode::Started { agent: "a".into() })
.await
.unwrap();
let replayed = inner.replay(run).await.unwrap();
assert_eq!(replayed.len(), 1, "inner store must hold the episode");
let head = repo.head(TEST_SCOPE).await.unwrap();
assert!(head.is_some(), "provenance chain must have a head entry");
let head = head.unwrap();
assert_eq!(head.event.actor, TEST_ACTOR);
assert_eq!(head.event.resource_ref, run.to_string());
}
#[tokio::test]
async fn append_failure_never_fails_record() {
let inner = Arc::new(FakeEpisodic::default());
let repo = Arc::new(FailingRepo);
let decorator = ProvenanceEpisodic::new(inner.clone(), repo, TEST_SCOPE, TEST_ACTOR);
let run = RunId::new();
let result = decorator
.record(run, Episode::Started { agent: "a".into() })
.await;
assert!(result.is_ok(), "append failure must not fail the record");
let replayed = inner.replay(run).await.unwrap();
assert_eq!(replayed.len(), 1, "inner write must still have happened");
}
#[test]
fn episode_to_event_maps_variants_with_actor_and_run_linkage() {
let run = RunId::new();
let started = episode_to_event(run, &Episode::Started { agent: "a".into() }, TEST_ACTOR);
assert_eq!(started.kind, ProvenanceEventKind::AgentRunStarted);
assert_eq!(started.actor, TEST_ACTOR);
assert_eq!(started.resource_ref, run.to_string());
let llm = episode_to_event(run, &Episode::llm_call(42, 17), TEST_ACTOR);
assert_eq!(llm.kind, ProvenanceEventKind::LlmCall);
assert_eq!(llm.resource_ref, run.to_string());
let completed = episode_to_event(run, &Episode::Completed, TEST_ACTOR);
assert_eq!(completed.kind, ProvenanceEventKind::AgentRunCompleted);
let tool = episode_to_event(
run,
&Episode::ToolCall {
name: "search".into(),
args: serde_json::Value::Null,
result: ToolResult::ok(serde_json::Value::Null),
},
TEST_ACTOR,
);
assert_eq!(tool.kind, ProvenanceEventKind::ToolCall);
let bus = episode_to_event(
run,
&Episode::BusPublish {
subject: "topic".into(),
},
TEST_ACTOR,
);
assert_eq!(bus.kind, ProvenanceEventKind::MemoryWrite);
let ops = episode_to_event(run, &Episode::Ops(serde_json::Value::Null), TEST_ACTOR);
assert_eq!(ops.kind, ProvenanceEventKind::Custom("ops".to_string()));
assert_ne!(started.payload_hash_hex, completed.payload_hash_hex);
}
}