klieo-runlog 0.2.0

Tier 2 observability — RunLog aggregate + replay engine for klieo agents.
Documentation
//! Deterministic-only replay engine.
//!
//! Walks a stored [`RunLog`]'s `Step`s in order. For each step:
//! - `StepKind::LlmCall` — calls the supplied [`LlmClient`]. The recorded
//!   `output` must equal the live response, otherwise [`RunLogError::Replay`].
//! - `StepKind::ToolCall` — calls the supplied [`ToolInvoker`]. Same equality check.
//!
//! Returns the assistant's final text — the `output` of the last `LlmCall` step,
//! flattened to a [`String`] via `serde_json::Value::as_str().unwrap_or(json-string)`.
//!
//! ## Determinism caveat
//!
//! Replay only matches values that flow through [`LlmClient`] and
//! [`ToolInvoker`]. Side effects outside these traits (timestamps, randomness,
//! network calls that are not recorded as tool calls) cannot be replayed and
//! will diverge between record and replay runs. Use this engine for regression
//! testing of agent logic against canned LLM/tool outputs, not as a general
//! determinism guarantee.
//!
//! ## Plan deviation: `ToolCtx` parameter
//!
//! The plan's signature was `replay(run_log, llm, tools) -> Result<String, _>`.
//! Live API: [`ToolInvoker::invoke`] requires a [`ToolCtx`] (its third
//! argument), so this implementation takes a fourth `ctx: ToolCtx` parameter.
//! Callers that don't need a real bus can pass a no-op `ToolCtx` built from
//! `klieo_core::test_utils::noop_bus` under the `test-utils` feature.
//! Editing `klieo-core` to relax the trait signature is out of scope for
//! Plan #22 (Plan #11 trait freeze).

use crate::error::RunLogError;
use crate::types::{RunLog, StepKind};
use klieo_core::llm::{ChatRequest, LlmClient, Message, Role};
use klieo_core::tool::{ToolCtx, ToolInvoker};
use std::sync::Arc;

/// Replay a recorded [`RunLog`] against caller-supplied [`LlmClient`] +
/// [`ToolInvoker`] test doubles. Returns the recorded final assistant text.
///
/// `ctx` is forwarded verbatim to every `ToolInvoker::invoke` call. For test
/// scenarios with no bus, build one via
/// `klieo_core::test_utils::noop_bus()` (requires the `test-utils` feature on
/// `klieo-core`).
pub async fn replay(
    run_log: &RunLog,
    llm: Arc<dyn LlmClient>,
    tools: Arc<dyn ToolInvoker>,
    ctx: ToolCtx,
) -> Result<String, RunLogError> {
    let mut last_llm_output: Option<String> = None;
    for step in &run_log.steps {
        match step.kind {
            StepKind::LlmCall => {
                // klieo-core's LlmClient takes a ChatRequest, not a bare prompt
                // string. We reconstruct a single User message from the
                // recorded `input` (best-effort: extract a "prompt" field if
                // present, otherwise serialise the whole input as JSON text).
                let prompt = step
                    .input
                    .get("prompt")
                    .and_then(|v| v.as_str())
                    .map(String::from)
                    .unwrap_or_else(|| step.input.to_string());
                let req = ChatRequest::new(vec![Message {
                    role: Role::User,
                    content: prompt,
                    tool_calls: vec![],
                    tool_call_id: None,
                }]);
                let resp = llm.complete(req).await.map_err(|e| {
                    RunLogError::Replay(format!("step {} llm error: {e}", step.idx))
                })?;
                let live = resp.message.content;
                let recorded = step
                    .output
                    .as_str()
                    .map(String::from)
                    .unwrap_or_else(|| step.output.to_string());
                if live != recorded {
                    return Err(RunLogError::Replay(format!(
                        "LLM mismatch at step {} (recorded={recorded:?} live={live:?})",
                        step.idx
                    )));
                }
                last_llm_output = Some(live);
            }
            StepKind::ToolCall => {
                let name = step.name.as_deref().unwrap_or("");
                let live = tools
                    .invoke(name, step.input.clone(), ctx.clone())
                    .await
                    .map_err(|e| {
                        RunLogError::Replay(format!("step {} tool error: {e}", step.idx))
                    })?;
                if live != step.output {
                    return Err(RunLogError::Replay(format!(
                        "Tool mismatch at step {} (recorded={} live={})",
                        step.idx, step.output, live
                    )));
                }
            }
            StepKind::SummaryCheckpoint => {
                // Summarizer checkpoints are bookkeeping — they do not
                // re-issue an LLM call during replay (the underlying
                // transcript is intentionally not retained on the
                // step). Skip without affecting `last_llm_output`.
            }
        }
    }
    last_llm_output.ok_or_else(|| RunLogError::Replay("no LlmCall step in RunLog".into()))
}