1use salvor_core::{BudgetKind, Event, RunId, SequenceNumber};
28use time::OffsetDateTime;
29
30use crate::wire::{decode_failure, decode_suspension};
31
32pub(crate) fn emit_step(run_id: RunId, seq: SequenceNumber, event: &Event) {
39 tracing::info!(
40 run_id = %run_id.as_uuid(),
41 seq = seq.get(),
42 "{} {}",
43 event_kind(event),
44 event_detail(event),
45 );
46}
47
48#[must_use]
51pub fn event_kind(event: &Event) -> &'static str {
52 match event {
53 Event::RunStarted { .. } => "RunStarted",
54 Event::ModelCallRequested { .. } => "ModelCallRequested",
55 Event::ModelCallCompleted { .. } => "ModelCallCompleted",
56 Event::ToolCallRequested { .. } => "ToolCallRequested",
57 Event::ToolCallCompleted { .. } => "ToolCallCompleted",
58 Event::NowObserved { .. } => "NowObserved",
59 Event::RandomObserved { .. } => "RandomObserved",
60 Event::Suspended { .. } => "Suspended",
61 Event::Resumed { .. } => "Resumed",
62 Event::BudgetExceeded { .. } => "BudgetExceeded",
63 Event::RunCompleted { .. } => "RunCompleted",
64 Event::RunFailed { .. } => "RunFailed",
65 Event::RunAbandoned { .. } => "RunAbandoned",
66 Event::GraphRunStarted { .. } => "GraphRunStarted",
67 Event::NodeEntered { .. } => "NodeEntered",
68 Event::NodeExited { .. } => "NodeExited",
69 Event::NodeSkipped { .. } => "NodeSkipped",
70 Event::BranchTaken { .. } => "BranchTaken",
71 Event::MapFannedOut { .. } => "MapFannedOut",
72 Event::MapIterationStarted { .. } => "MapIterationStarted",
73 Event::MapIterationJoined { .. } => "MapIterationJoined",
74 Event::FoldIterationStarted { .. } => "FoldIterationStarted",
75 Event::FoldIterationJoined { .. } => "FoldIterationJoined",
76 Event::FoldConverged { .. } => "FoldConverged",
77 }
78}
79
80#[must_use]
87pub fn event_detail(event: &Event) -> String {
88 match event {
89 Event::RunStarted {
90 agent_def_hash,
91 input,
92 ..
93 } => format!(
94 "agent {} input {}",
95 short_hash(agent_def_hash),
96 truncate_json(input)
97 ),
98 Event::ModelCallRequested { request_hash, .. } => {
99 format!("request {}", short_hash(request_hash))
100 }
101 Event::ModelCallCompleted { usage, .. } => format!(
102 "usage in {} out {}",
103 usage.input_tokens, usage.output_tokens
104 ),
105 Event::ToolCallRequested {
106 tool,
107 input,
108 effect,
109 idempotency_key,
110 ..
111 } => {
112 let key = idempotency_key
113 .as_deref()
114 .map_or_else(String::new, |k| format!(" key {k}"));
115 format!("{tool} [{effect:?}]{key} input {}", truncate_json(input))
116 }
117 Event::ToolCallCompleted { output, .. } => {
118 if let Some(suspension) = decode_suspension(output) {
119 format!("suspends: {}", suspension.reason)
120 } else if let Some(failure) = decode_failure(output) {
121 format!(
122 "error ({}, {} attempt(s)): {}",
123 failure.kind.as_str(),
124 failure.attempts,
125 truncate_str(&failure.message)
126 )
127 } else {
128 format!("output {}", truncate_json(output))
129 }
130 }
131 Event::NowObserved { now } => format_ts(*now),
132 Event::RandomObserved { value } => format!("value {value}"),
133 Event::Suspended { reason, .. } => format!("reason: {reason}"),
134 Event::Resumed { input } => format!("input {}", truncate_json(input)),
135 Event::BudgetExceeded { budget, observed } => {
136 format!(
137 "{} limit {}, observed {}",
138 budget_label(budget.kind),
139 fmt_num(budget.limit),
140 fmt_num(*observed)
141 )
142 }
143 Event::RunCompleted { output } => format!("output {}", truncate_json(output)),
144 Event::RunFailed { error } => format!("error: {}", truncate_str(error)),
145 Event::RunAbandoned {
146 reason,
147 unresolved_write,
148 } => {
149 let why = reason
150 .as_deref()
151 .map_or_else(|| "no reason given".to_owned(), truncate_str);
152 match unresolved_write {
153 Some(write) => format!(
154 "abandoned: {why} (unresolved write at seq {}, tool {})",
155 write.seq.get(),
156 write.tool
157 ),
158 None => format!("abandoned: {why}"),
159 }
160 }
161 Event::GraphRunStarted {
162 graph_hash, input, ..
163 } => format!(
164 "graph {} input {}",
165 short_hash(graph_hash),
166 truncate_json(input)
167 ),
168 Event::NodeEntered { node } => format!("enter {node}"),
169 Event::NodeExited { node } => format!("exit {node}"),
170 Event::NodeSkipped { node, reason } => format!("skip {node}: {}", truncate_str(reason)),
171 Event::BranchTaken { node, case } => format!("branch {node} -> {case}"),
172 Event::MapFannedOut { node, items } => {
173 format!("map {node} fan-out {}", truncate_json(items))
174 }
175 Event::MapIterationStarted {
176 node,
177 index,
178 child_run,
179 } => format!("map {node}[{index}] child {}", short_hash(child_run)),
180 Event::MapIterationJoined { node, index } => format!("map {node}[{index}] joined"),
181 Event::FoldIterationStarted { node, index } => format!("fold {node}[{index}] started"),
182 Event::FoldIterationJoined { node, index } => format!("fold {node}[{index}] joined"),
183 Event::FoldConverged {
184 node,
185 winner_index,
186 reason,
187 } => format!(
188 "fold {node} converged on [{winner_index}]: {}",
189 truncate_str(reason)
190 ),
191 }
192}
193
194fn short_hash(hash: &str) -> String {
197 match hash.split_once(':') {
198 Some((scheme, hex)) => {
199 let head: String = hex.chars().take(7).collect();
200 if hex.len() > 7 {
201 format!("{scheme}:{head}\u{2026}")
202 } else {
203 format!("{scheme}:{hex}")
204 }
205 }
206 None => hash.chars().take(12).collect(),
207 }
208}
209
210fn budget_label(kind: BudgetKind) -> &'static str {
212 match kind {
213 BudgetKind::Steps => "steps",
214 BudgetKind::Tokens => "tokens",
215 BudgetKind::CostUsd => "cost_usd",
216 BudgetKind::WallTime => "wall_time",
217 }
218}
219
220fn fmt_num(value: f64) -> String {
225 if value.fract() == 0.0 && value.abs() < 1e15 {
226 format!("{}", value as i64)
227 } else {
228 format!("{value}")
229 }
230}
231
232fn format_ts(ts: OffsetDateTime) -> String {
235 let utc = ts.to_offset(time::UtcOffset::UTC);
236 format!(
237 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}Z",
238 utc.year(),
239 u8::from(utc.month()),
240 utc.day(),
241 utc.hour(),
242 utc.minute(),
243 utc.second(),
244 )
245}
246
247fn truncate_json(value: &serde_json::Value) -> String {
250 truncate_str(&value.to_string())
251}
252
253fn truncate_str(text: &str) -> String {
256 const CAP: usize = 80;
257 if text.chars().count() > CAP {
258 let head: String = text.chars().take(CAP).collect();
259 format!("{head}\u{2026}")
260 } else {
261 text.to_owned()
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use serde_json::json;
269
270 #[test]
273 fn detail_truncates_long_payloads() {
274 let big = "x".repeat(500);
275 let detail = event_detail(&Event::RunStarted {
276 agent_def_hash: "sha256:abcdef0123456789".into(),
277 input: json!({ "prompt": big }),
278 labels: None,
279 });
280 assert!(detail.contains('\u{2026}'), "detail should be truncated");
281 assert!(
282 detail.chars().count() < 200,
283 "truncated detail stays short: {} chars",
284 detail.chars().count()
285 );
286 assert!(detail.contains("sha256:abcdef0"));
288 }
289
290 #[test]
292 fn kind_matches_variant_name() {
293 assert_eq!(
294 event_kind(&Event::RunCompleted { output: json!(1) }),
295 "RunCompleted"
296 );
297 assert_eq!(
298 event_kind(&Event::RandomObserved { value: 7 }),
299 "RandomObserved"
300 );
301 }
302}