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}