1use serde_json::Value;
19use time::OffsetDateTime;
20
21use crate::event::{BudgetKind, Event, Performer};
22#[cfg(test)]
23use crate::id::{RunId, SequenceNumber};
24
25#[must_use]
28pub fn event_kind(event: &Event) -> &'static str {
29 match event {
30 Event::RunStarted { .. } => "RunStarted",
31 Event::ModelCallRequested { .. } => "ModelCallRequested",
32 Event::ModelCallCompleted { .. } => "ModelCallCompleted",
33 Event::ToolCallRequested { .. } => "ToolCallRequested",
34 Event::ToolCallCompleted { .. } => "ToolCallCompleted",
35 Event::NowObserved { .. } => "NowObserved",
36 Event::RandomObserved { .. } => "RandomObserved",
37 Event::Suspended { .. } => "Suspended",
38 Event::Resumed { .. } => "Resumed",
39 Event::BudgetExceeded { .. } => "BudgetExceeded",
40 Event::RunCompleted { .. } => "RunCompleted",
41 Event::RunFailed { .. } => "RunFailed",
42 Event::RunAbandoned { .. } => "RunAbandoned",
43 Event::GraphRunStarted { .. } => "GraphRunStarted",
44 Event::NodeEntered { .. } => "NodeEntered",
45 Event::NodeExited { .. } => "NodeExited",
46 Event::NodeSkipped { .. } => "NodeSkipped",
47 Event::BranchTaken { .. } => "BranchTaken",
48 Event::MapFannedOut { .. } => "MapFannedOut",
49 Event::MapIterationStarted { .. } => "MapIterationStarted",
50 Event::MapIterationJoined { .. } => "MapIterationJoined",
51 Event::FoldIterationStarted { .. } => "FoldIterationStarted",
52 Event::FoldIterationJoined { .. } => "FoldIterationJoined",
53 Event::FoldConverged { .. } => "FoldConverged",
54 }
55}
56
57#[must_use]
65pub fn event_detail(event: &Event) -> String {
66 match event {
67 Event::RunStarted {
68 agent_def_hash,
69 input,
70 ..
71 } => format!(
72 "agent {} input {}",
73 short_hash(agent_def_hash),
74 truncate_json(input)
75 ),
76 Event::ModelCallRequested { request_hash, .. } => {
77 format!("request {}", short_hash(request_hash))
78 }
79 Event::ModelCallCompleted { usage, .. } => format!(
80 "usage in {} out {}",
81 usage.input_tokens, usage.output_tokens
82 ),
83 Event::ToolCallRequested {
84 tool,
85 input,
86 effect,
87 idempotency_key,
88 performed_by,
89 ..
90 } => {
91 let key = idempotency_key
92 .as_deref()
93 .map_or_else(String::new, |k| format!(" key {k}"));
94 let performer = match performed_by {
100 Some(Performer::Client) => " [Client]",
101 None | Some(Performer::Server) => "",
102 };
103 format!(
104 "{tool} [{effect:?}]{performer}{key} input {}",
105 truncate_json(input)
106 )
107 }
108 Event::ToolCallCompleted {
109 output,
110 deduplicated_from,
111 ..
112 } => {
113 let copied = deduplicated_from.map_or_else(String::new, |origin| {
118 format!(
119 " (deduplicated: copied from run {} seq {})",
120 origin.run_id.as_uuid(),
121 origin.seq
122 )
123 });
124 if let Some(reason) = suspension_reason(output) {
125 format!("suspends: {reason}{copied}")
126 } else if let Some(failure) = recorded_failure(output) {
127 format!(
128 "error ({}, {} attempt(s)): {}{copied}",
129 failure.kind,
130 failure.attempts,
131 truncate_str(failure.message)
132 )
133 } else {
134 format!("output {}{copied}", truncate_json(output))
135 }
136 }
137 Event::NowObserved { now } => format_ts(*now),
138 Event::RandomObserved { value } => format!("value {value}"),
139 Event::Suspended { reason, .. } => format!("reason: {reason}"),
140 Event::Resumed { input } => format!("input {}", truncate_json(input)),
141 Event::BudgetExceeded { budget, observed } => {
142 format!(
143 "{} limit {}, observed {}",
144 budget_label(budget.kind),
145 fmt_num(budget.limit),
146 fmt_num(*observed)
147 )
148 }
149 Event::RunCompleted { output } => format!("output {}", truncate_json(output)),
150 Event::RunFailed { error } => format!("error: {}", truncate_str(error)),
151 Event::RunAbandoned {
152 reason,
153 unresolved_write,
154 } => {
155 let why = reason
156 .as_deref()
157 .map_or_else(|| "no reason given".to_owned(), truncate_str);
158 match unresolved_write {
159 Some(write) => format!(
160 "abandoned: {why} (unresolved write at seq {}, tool {})",
161 write.seq.get(),
162 write.tool
163 ),
164 None => format!("abandoned: {why}"),
165 }
166 }
167 Event::GraphRunStarted {
168 graph_hash, input, ..
169 } => format!(
170 "graph {} input {}",
171 short_hash(graph_hash),
172 truncate_json(input)
173 ),
174 Event::NodeEntered { node } => format!("enter {node}"),
175 Event::NodeExited { node } => format!("exit {node}"),
176 Event::NodeSkipped { node, reason } => format!("skip {node}: {}", truncate_str(reason)),
177 Event::BranchTaken { node, case } => format!("branch {node} -> {case}"),
178 Event::MapFannedOut { node, items } => {
179 format!("map {node} fan-out {}", truncate_json(items))
180 }
181 Event::MapIterationStarted {
182 node,
183 index,
184 child_run,
185 } => format!("map {node}[{index}] child {}", short_hash(child_run)),
186 Event::MapIterationJoined { node, index } => format!("map {node}[{index}] joined"),
187 Event::FoldIterationStarted { node, index } => format!("fold {node}[{index}] started"),
188 Event::FoldIterationJoined { node, index } => format!("fold {node}[{index}] joined"),
189 Event::FoldConverged {
190 node,
191 winner_index,
192 reason,
193 } => format!(
194 "fold {node} converged on [{winner_index}]: {}",
195 truncate_str(reason)
196 ),
197 }
198}
199
200const SUSPEND_SENTINEL_KEY: &str = "__salvor_suspend";
213
214const ERROR_SENTINEL_KEY: &str = "__salvor_error";
217
218const FAILURE_KINDS: [&str; 3] = ["invalid_input", "handler", "output_serialization"];
222
223struct RecordedFailure<'v> {
226 kind: &'v str,
228 message: &'v str,
230 attempts: u32,
232}
233
234fn suspension_reason(output: &Value) -> Option<&str> {
237 let body = sentinel_body(output, SUSPEND_SENTINEL_KEY)?;
238 let reason = body.get("reason")?.as_str()?;
239 body.get("input_schema")?;
242 Some(reason)
243}
244
245fn recorded_failure(output: &Value) -> Option<RecordedFailure<'_>> {
248 let body = sentinel_body(output, ERROR_SENTINEL_KEY)?;
249 let kind = body.get("kind")?.as_str()?;
250 if !FAILURE_KINDS.contains(&kind) {
251 return None;
252 }
253 Some(RecordedFailure {
254 kind,
255 message: body.get("message")?.as_str()?,
256 attempts: u32::try_from(body.get("attempts")?.as_u64()?).ok()?,
257 })
258}
259
260fn sentinel_body<'v>(output: &'v Value, key: &str) -> Option<&'v Value> {
263 let map = output.as_object()?;
264 if map.len() != 1 {
265 return None;
266 }
267 map.get(key)
268}
269
270fn short_hash(hash: &str) -> String {
273 match hash.split_once(':') {
274 Some((scheme, hex)) => {
275 let head: String = hex.chars().take(7).collect();
276 if hex.len() > 7 {
277 format!("{scheme}:{head}\u{2026}")
278 } else {
279 format!("{scheme}:{hex}")
280 }
281 }
282 None => hash.chars().take(12).collect(),
283 }
284}
285
286fn budget_label(kind: BudgetKind) -> &'static str {
288 match kind {
289 BudgetKind::Steps => "steps",
290 BudgetKind::Tokens => "tokens",
291 BudgetKind::CostUsd => "cost_usd",
292 BudgetKind::WallTime => "wall_time",
293 }
294}
295
296fn fmt_num(value: f64) -> String {
301 if value.fract() == 0.0 && value.abs() < 1e15 {
302 format!("{}", value as i64)
303 } else {
304 format!("{value}")
305 }
306}
307
308fn format_ts(ts: OffsetDateTime) -> String {
311 let utc = ts.to_offset(time::UtcOffset::UTC);
312 format!(
313 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}Z",
314 utc.year(),
315 u8::from(utc.month()),
316 utc.day(),
317 utc.hour(),
318 utc.minute(),
319 utc.second(),
320 )
321}
322
323fn truncate_json(value: &serde_json::Value) -> String {
326 truncate_str(&value.to_string())
327}
328
329fn truncate_str(text: &str) -> String {
332 const CAP: usize = 80;
333 if text.chars().count() > CAP {
334 let head: String = text.chars().take(CAP).collect();
335 format!("{head}\u{2026}")
336 } else {
337 text.to_owned()
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use serde_json::json;
345 use uuid::Uuid;
346
347 #[test]
351 fn a_deduplicated_completion_says_what_it_copied() {
352 let origin = crate::event::DedupOrigin {
353 run_id: RunId::from_uuid(
354 Uuid::parse_str("00000000-0000-4000-8000-0000000000aa").expect("uuid"),
355 ),
356 seq: SequenceNumber::new(4),
357 };
358 let executed = event_detail(&Event::ToolCallCompleted {
359 seq: SequenceNumber::new(1),
360 output: json!({"charge_id": "po_1"}),
361 deduplicated_from: None,
362 });
363 assert_eq!(executed, r#"output {"charge_id":"po_1"}"#);
364
365 let copied = event_detail(&Event::ToolCallCompleted {
366 seq: SequenceNumber::new(1),
367 output: json!({"charge_id": "po_1"}),
368 deduplicated_from: Some(origin),
369 });
370 assert_eq!(
371 copied,
372 r#"output {"charge_id":"po_1"} (deduplicated: copied from run 00000000-0000-4000-8000-0000000000aa seq 4)"#
373 );
374 }
375
376 #[test]
379 fn detail_truncates_long_payloads() {
380 let big = "x".repeat(500);
381 let detail = event_detail(&Event::RunStarted {
382 agent_def_hash: "sha256:abcdef0123456789".into(),
383 input: json!({ "prompt": big }),
384 labels: None,
385 });
386 assert!(detail.contains('\u{2026}'), "detail should be truncated");
387 assert!(
388 detail.chars().count() < 200,
389 "truncated detail stays short: {} chars",
390 detail.chars().count()
391 );
392 assert!(detail.contains("sha256:abcdef0"));
394 }
395
396 #[test]
398 fn kind_matches_variant_name() {
399 assert_eq!(
400 event_kind(&Event::RunCompleted { output: json!(1) }),
401 "RunCompleted"
402 );
403 assert_eq!(
404 event_kind(&Event::RandomObserved { value: 7 }),
405 "RandomObserved"
406 );
407 }
408
409 #[test]
416 fn detail_omits_performer_marker_when_absent() {
417 let event = Event::ToolCallRequested {
418 seq: crate::id::SequenceNumber::new(3),
419 tool: "refund_card".into(),
420 input: json!({"amount_cents": 15900}),
421 effect: crate::effect::Effect::Write,
422 idempotency_key: Some("sha256:d2bb005d".into()),
423 performed_by: None,
424 };
425 assert_eq!(
426 event_detail(&event),
427 r#"refund_card [Write] key sha256:d2bb005d input {"amount_cents":15900}"#
428 );
429 }
430
431 #[test]
436 fn detail_omits_performer_marker_for_explicit_server() {
437 let event = Event::ToolCallRequested {
438 seq: crate::id::SequenceNumber::new(3),
439 tool: "refund_card".into(),
440 input: json!({"amount_cents": 15900}),
441 effect: crate::effect::Effect::Write,
442 idempotency_key: Some("sha256:d2bb005d".into()),
443 performed_by: Some(Performer::Server),
444 };
445 assert_eq!(
446 event_detail(&event),
447 r#"refund_card [Write] key sha256:d2bb005d input {"amount_cents":15900}"#
448 );
449 }
450
451 #[test]
454 fn detail_marks_a_client_performed_call() {
455 let event = Event::ToolCallRequested {
456 seq: crate::id::SequenceNumber::new(3),
457 tool: "refund_card".into(),
458 input: json!({"amount_cents": 15900}),
459 effect: crate::effect::Effect::Write,
460 idempotency_key: Some("sha256:d2bb005d".into()),
461 performed_by: Some(Performer::Client),
462 };
463 assert_eq!(
464 event_detail(&event),
465 r#"refund_card [Write] [Client] key sha256:d2bb005d input {"amount_cents":15900}"#
466 );
467 }
468}