Skip to main content

klieo_runlog/
types.rs

1//! Aggregate types for the RunLog projection.
2
3use chrono::{DateTime, Utc};
4use klieo_core::ids::RunId;
5use klieo_core::llm::{FinishReason, ToolCall};
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9/// Run lifecycle status.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
12#[serde(rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum RunStatus {
15    /// Run is still in progress.
16    Running,
17    /// Run finished successfully.
18    Completed,
19    /// Run failed.
20    Failed,
21    /// Run was cancelled by the operator or scheduler.
22    Cancelled,
23}
24
25/// Discriminator for the kind of side effect a `Step` represents.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum StepKind {
31    /// LLM completion or chat call.
32    LlmCall,
33    /// Tool invocation through `ToolInvoker`.
34    ToolCall,
35    /// Summarizer checkpoint — distinct from a top-level `LlmCall` so
36    /// summarizer overhead does not pollute substantive-reasoning cost
37    /// and latency attribution.
38    SummaryCheckpoint,
39    /// Operational-layer event emitted by klieo-ops (supervisor state
40    /// changes, gate decisions, governor denials, etc.). The `input`
41    /// field of the corresponding [`Step`] carries the raw
42    /// `serde_json::Value` payload from `Episode::Ops`.
43    OpsEvent,
44    /// A graphRAG recall performed during the run (from
45    /// `Episode::MemoryRecall`). `Step::input` carries `{ "query", "k" }`;
46    /// `Step::output` carries `{ "returned_fact_ids": [...] }`.
47    MemoryRecall,
48}
49
50/// One unit of agent work — either an LLM call or a tool call.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
53pub struct Step {
54    /// Zero-based index of the step within the run.
55    pub idx: u32,
56    /// Discriminator.
57    pub kind: StepKind,
58    /// Tool name (for `ToolCall`) or model name (for `LlmCall`); `None` if unknown.
59    pub name: Option<String>,
60    /// Prompt-side token count for an `LlmCall` step; `None` for other kinds or
61    /// when the provider did not report a split.
62    #[serde(default)]
63    pub prompt_tokens: Option<u32>,
64    /// Completion-side token count for an `LlmCall` step; `None` for other kinds
65    /// or when the provider did not report a split.
66    #[serde(default)]
67    pub completion_tokens: Option<u32>,
68    /// Estimated USD cost for an `LlmCall` step, computed by
69    /// [`project_with_price_table`](crate::project_with_price_table) from the
70    /// step's `LlmIo` and a `PriceTable`. `None` for other kinds, when the
71    /// provider/model/token split is unknown, or when no price matched.
72    #[serde(default)]
73    pub cost_usd: Option<f64>,
74    /// JSON inputs (tool args, prompt structure).
75    pub input: serde_json::Value,
76    /// JSON outputs (tool result, completion text).
77    pub output: serde_json::Value,
78    /// Error message if this step failed (`None` on success).
79    pub error: Option<String>,
80    /// Wall-clock latency serialised as milliseconds.
81    #[serde(with = "duration_millis")]
82    #[cfg_attr(feature = "schemars", schemars(with = "u64"))]
83    pub latency: Duration,
84    /// Tracing span id (stringly-typed for now; richer linkage is a Wave 7 carryover).
85    pub span_id: Option<String>,
86}
87
88/// Sidecar I/O record for a single `Episode::LlmCall`.
89///
90/// `klieo_core::Episode::LlmCall` carries only `tokens` + `latency_ms` (the
91/// trait surface is frozen at 0.1.0 — see `docs/SEMVER.md`). To make
92/// projected `RunLog`s replayable, callers that have access to the original
93/// prompt + completion can pass a parallel `&[LlmIo]` slice to
94/// [`crate::projector::project_with_llm_io`]: the i-th `LlmIo` is paired with
95/// the i-th `Episode::LlmCall` in the episode stream.
96///
97/// Without this sidecar, projected `Step.input` / `Step.output` for LLM
98/// steps remain `Value::Null`, and [`crate::replay::replay`] cannot reproduce
99/// the recorded prompt.
100///
101/// ## Cost-accounting fields
102///
103/// `provider`, `prompt_tokens`, and `completion_tokens` are optional sidecar
104/// fields used by [`crate::projector::project_with_price_table`] to compute a
105/// per-run [`Cost`]. `Episode::LlmCall::tokens` is a single `u32` total with
106/// no provider info, so callers populate these fields here when they want
107/// USD totals on the projected `RunLog`. Each new field carries
108/// `#[serde(default)]` so older serialised records still deserialise.
109///
110/// ## Trust model
111///
112/// `provider`, `prompt_tokens`, and `completion_tokens` are **caller-supplied**
113/// — `LlmIo` is a passive carrier and the projector trusts the sidecar
114/// verbatim.
115///
116/// In compliance-grade audit-trail use cases (signed evidence, regulator
117/// review, billing), these fields MUST originate directly from the
118/// `LlmClient`'s response (response headers, parsed usage block, or the
119/// transport layer). They MUST NOT be inferred or asserted by the agent
120/// layer: a compromised tool or agent that constructs `LlmIo` itself can
121/// spoof `provider = "ollama"` (zero rate) to under-report cost, or attribute
122/// usage to a cheaper model tier.
123///
124/// The projector also does not cross-check these fields against
125/// `Episode::LlmCall.tokens`. Adding that consistency check (e.g. asserting
126/// `prompt_tokens + completion_tokens == LlmCall.tokens`) is a deferred
127/// follow-up; for now, callers wanting that guarantee should assert it
128/// themselves before passing the sidecar to
129/// [`crate::projector::project_with_price_table`].
130#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
132#[non_exhaustive]
133pub struct LlmIo {
134    /// Recorded prompt text (the `User` message content sent to the LLM).
135    pub prompt: String,
136    /// Recorded completion text (the assistant response).
137    pub completion: String,
138    /// Optional model name; populates `Step.name` when present.
139    pub model: Option<String>,
140    /// Optional provider name (e.g. `"anthropic"`, `"openai"`). Required for
141    /// price-table lookup in [`crate::projector::project_with_price_table`].
142    #[serde(default)]
143    pub provider: Option<String>,
144    /// Optional prompt-token count. Required for price-table cost accounting.
145    #[serde(default)]
146    pub prompt_tokens: Option<u32>,
147    /// Optional completion-token count. Required for price-table cost
148    /// accounting.
149    #[serde(default)]
150    pub completion_tokens: Option<u32>,
151    /// Optional cache-read token count (prompt tokens served from the
152    /// provider's cache; billed at a reduced tier). `None` for providers
153    /// without caching or pre-cache-aware captures.
154    #[serde(default)]
155    pub cache_read_tokens: Option<u32>,
156    /// Optional cache-creation token count (prompt tokens written to the
157    /// provider's cache; billed at a premium tier). `None` as above.
158    #[serde(default)]
159    pub cache_creation_tokens: Option<u32>,
160    /// Recorded `finish_reason` of the response — lets a re-drive double
161    /// (ADR-048) reconstruct whether the call ended in `Stop` or `ToolCalls`,
162    /// so the agent loop takes the same branch. `None` for pre-2.0 captures.
163    #[serde(default)]
164    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
165    pub finish_reason: Option<FinishReason>,
166    /// Recorded assistant tool calls — replayed so the loop dispatches the same
167    /// tools. Empty for `Stop` responses and pre-2.0 captures.
168    #[serde(default)]
169    #[cfg_attr(feature = "schemars", schemars(with = "Vec<serde_json::Value>"))]
170    pub tool_calls: Vec<ToolCall>,
171    /// Fingerprint of the request that produced this call
172    /// ([`crate::request_fingerprint`]); the re-drive double keys responses on
173    /// it. `None` for pre-2.0 captures (double falls back to order).
174    #[serde(default)]
175    pub request_fingerprint: Option<String>,
176}
177
178impl LlmIo {
179    /// A recorded call with the minimum replayable fields; enrich with the
180    /// setters below.
181    pub fn new(prompt: impl Into<String>, completion: impl Into<String>) -> Self {
182        Self {
183            prompt: prompt.into(),
184            completion: completion.into(),
185            ..Self::default()
186        }
187    }
188
189    /// Set the model name (populates `Step.name` on projection).
190    pub fn with_model(mut self, model: impl Into<String>) -> Self {
191        self.model = Some(model.into());
192        self
193    }
194
195    /// Set the cost-accounting sidecar (provider + token split) used by
196    /// [`crate::projector::project_with_price_table`].
197    pub fn with_cost(
198        mut self,
199        provider: impl Into<String>,
200        prompt_tokens: u32,
201        completion_tokens: u32,
202    ) -> Self {
203        self.provider = Some(provider.into());
204        self.prompt_tokens = Some(prompt_tokens);
205        self.completion_tokens = Some(completion_tokens);
206        self
207    }
208
209    /// Set the prompt-cache token split (read = served from cache, creation =
210    /// written to cache) so [`crate::projector::project_with_price_table`] can
211    /// price the cache tiers distinctly from the base prompt rate.
212    pub fn with_cache_tokens(mut self, cache_read_tokens: u32, cache_creation_tokens: u32) -> Self {
213        self.cache_read_tokens = Some(cache_read_tokens);
214        self.cache_creation_tokens = Some(cache_creation_tokens);
215        self
216    }
217
218    /// Set the recorded response shape so a re-drive can reconstruct the
219    /// assistant decision (ADR-048).
220    pub fn with_response_shape(
221        mut self,
222        finish_reason: FinishReason,
223        tool_calls: Vec<ToolCall>,
224    ) -> Self {
225        self.finish_reason = Some(finish_reason);
226        self.tool_calls = tool_calls;
227        self
228    }
229
230    /// Set the request fingerprint the re-drive double keys on (ADR-048).
231    pub fn with_request_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
232        self.request_fingerprint = Some(fingerprint.into());
233        self
234    }
235}
236
237/// Aggregate token usage across all `LlmCall` steps in a run.
238#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
239#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
240pub struct Usage {
241    /// Total prompt tokens.
242    pub prompt_tokens: u32,
243    /// Total completion tokens.
244    pub completion_tokens: u32,
245}
246
247/// Optional cost estimate for the run, in USD.
248#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
249#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
250#[non_exhaustive]
251pub struct Cost {
252    /// Base (uncached) prompt cost in USD.
253    pub prompt_usd: f64,
254    /// Completion cost in USD.
255    pub completion_usd: f64,
256    /// Prompt-cache cost in USD (cache reads + cache creation, billed at their
257    /// own tiers). 0 when no cache tokens were used or priced. `#[serde(default)]`
258    /// keeps pre-cache `Cost` JSON deserializable (the field reads back as 0).
259    #[serde(default)]
260    pub cache_usd: f64,
261    /// Total cost in USD: `prompt_usd + completion_usd + cache_usd`.
262    pub total_usd: f64,
263}
264
265impl Cost {
266    /// Build a cost from its tier components; `total_usd` is their sum. Prefer
267    /// this over a struct literal — `Cost` is `#[non_exhaustive]`, so external
268    /// crates cannot construct it directly.
269    pub fn new(prompt_usd: f64, completion_usd: f64, cache_usd: f64) -> Self {
270        Self {
271            prompt_usd,
272            completion_usd,
273            cache_usd,
274            total_usd: prompt_usd + completion_usd + cache_usd,
275        }
276    }
277}
278
279/// The aggregate RunLog view per spec §8.2.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
282pub struct RunLog {
283    /// Run identifier.
284    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
285    pub run_id: RunId,
286    /// Agent name.
287    pub agent: String,
288    /// First-event timestamp.
289    pub started_at: DateTime<Utc>,
290    /// Last-event timestamp, populated when the run is no longer `Running`.
291    pub finished_at: Option<DateTime<Utc>>,
292    /// Lifecycle status.
293    pub status: RunStatus,
294    /// Ordered list of steps.
295    pub steps: Vec<Step>,
296    /// Aggregate usage.
297    pub tokens: Usage,
298    /// Optional cost estimate; `None` until a pricing table is wired (Wave 7+ carryover).
299    pub cost_estimate: Option<Cost>,
300}
301
302mod duration_millis {
303    use serde::{Deserialize, Deserializer, Serializer};
304    use std::time::Duration;
305
306    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
307        s.serialize_u64(d.as_millis() as u64)
308    }
309    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
310        let ms = u64::deserialize(d)?;
311        Ok(Duration::from_millis(ms))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use chrono::Utc;
319    use klieo_core::ids::RunId;
320    use std::time::Duration;
321
322    #[test]
323    fn llm_io_roundtrips_structured_fields() {
324        let io = LlmIo::new("p", "c")
325            .with_model("gpt-4o")
326            .with_response_shape(
327                FinishReason::ToolCalls,
328                vec![ToolCall::new(
329                    "call_1",
330                    "search",
331                    serde_json::json!({"q": "x"}),
332                )],
333            )
334            .with_request_fingerprint("abc123");
335        let json = serde_json::to_string(&io).unwrap();
336        let back: LlmIo = serde_json::from_str(&json).unwrap();
337        assert_eq!(back, io);
338        assert_eq!(back.finish_reason, Some(FinishReason::ToolCalls));
339        assert_eq!(back.tool_calls.len(), 1);
340        assert_eq!(back.request_fingerprint.as_deref(), Some("abc123"));
341    }
342
343    #[test]
344    fn with_cost_sets_provider_and_token_split() {
345        let io = LlmIo::new("p", "c").with_cost("openai", 100, 50);
346        assert_eq!(io.provider.as_deref(), Some("openai"));
347        assert_eq!(io.prompt_tokens, Some(100));
348        assert_eq!(io.completion_tokens, Some(50));
349    }
350
351    #[test]
352    fn cache_tokens_default_to_none_and_set_via_builder() {
353        let bare = LlmIo::new("p", "c");
354        assert_eq!(bare.cache_read_tokens, None);
355        assert_eq!(bare.cache_creation_tokens, None);
356
357        let cached = LlmIo::new("p", "c").with_cache_tokens(100, 8);
358        assert_eq!(cached.cache_read_tokens, Some(100));
359        assert_eq!(cached.cache_creation_tokens, Some(8));
360    }
361
362    #[test]
363    fn old_record_without_new_fields_deserializes() {
364        // A pre-2.0 serialized LlmIo lacks finish_reason / tool_calls /
365        // request_fingerprint; #[serde(default)] must fill them.
366        let legacy = r#"{"prompt":"p","completion":"c","model":null}"#;
367        let io: LlmIo = serde_json::from_str(legacy).unwrap();
368        assert_eq!(io.prompt, "p");
369        assert_eq!(io.finish_reason, None);
370        assert!(io.tool_calls.is_empty());
371        assert_eq!(io.request_fingerprint, None);
372    }
373
374    #[test]
375    fn run_status_serialises_snake_case() {
376        let json = serde_json::to_string(&RunStatus::Running).unwrap();
377        assert_eq!(json, "\"running\"");
378    }
379
380    #[test]
381    fn step_kind_round_trip() {
382        for k in [StepKind::LlmCall, StepKind::ToolCall] {
383            let json = serde_json::to_string(&k).unwrap();
384            let back: StepKind = serde_json::from_str(&json).unwrap();
385            assert_eq!(k, back);
386        }
387    }
388
389    #[test]
390    fn step_round_trip() {
391        let step = Step {
392            idx: 0,
393            kind: StepKind::ToolCall,
394            name: Some("calc".into()),
395            prompt_tokens: Some(3),
396            completion_tokens: Some(9),
397            cost_usd: None,
398            input: serde_json::json!({"a": 1}),
399            output: serde_json::json!({"b": 2}),
400            error: None,
401            latency: Duration::from_millis(42),
402            span_id: Some("span-xyz".into()),
403        };
404        let json = serde_json::to_string(&step).unwrap();
405        let back: Step = serde_json::from_str(&json).unwrap();
406        assert_eq!(step.idx, back.idx);
407        assert_eq!(step.latency, back.latency);
408        assert_eq!(step.name, back.name);
409    }
410
411    #[test]
412    fn run_log_round_trip_empty_steps() {
413        let log = RunLog {
414            run_id: RunId::new(),
415            agent: "writer".into(),
416            started_at: Utc::now(),
417            finished_at: None,
418            status: RunStatus::Running,
419            steps: vec![],
420            tokens: Usage::default(),
421            cost_estimate: None,
422        };
423        let json = serde_json::to_string(&log).unwrap();
424        assert!(json.contains("\"steps\":[]"));
425        let _back: RunLog = serde_json::from_str(&json).unwrap();
426    }
427
428    #[test]
429    fn usage_default_is_zero() {
430        let u = Usage::default();
431        assert_eq!(u.prompt_tokens, 0);
432        assert_eq!(u.completion_tokens, 0);
433    }
434
435    #[test]
436    fn cost_round_trip() {
437        let c = Cost {
438            prompt_usd: 0.001,
439            completion_usd: 0.002,
440            cache_usd: 0.0004,
441            total_usd: 0.0034,
442        };
443        let json = serde_json::to_string(&c).unwrap();
444        let back: Cost = serde_json::from_str(&json).unwrap();
445        assert!((back.total_usd - 0.0034).abs() < 1e-9);
446        assert!((back.cache_usd - 0.0004).abs() < 1e-9);
447    }
448
449    #[test]
450    fn cost_deserializes_legacy_json_without_cache_field() {
451        // Pre-cache `Cost` JSON lacks `cache_usd`; `#[serde(default)]` must keep
452        // it deserializable (reads back as 0).
453        let legacy = r#"{"prompt_usd":0.001,"completion_usd":0.002,"total_usd":0.003}"#;
454        let c: Cost = serde_json::from_str(legacy).unwrap();
455        assert!(c.cache_usd.abs() < 1e-12);
456        assert!((c.total_usd - 0.003).abs() < 1e-9);
457    }
458}