use salvor_core::{BudgetKind, Event, RunId, SequenceNumber};
use time::OffsetDateTime;
use crate::wire::{decode_failure, decode_suspension};
pub(crate) fn emit_step(run_id: RunId, seq: SequenceNumber, event: &Event) {
tracing::info!(
run_id = %run_id.as_uuid(),
seq = seq.get(),
"{} {}",
event_kind(event),
event_detail(event),
);
}
#[must_use]
pub fn event_kind(event: &Event) -> &'static str {
match event {
Event::RunStarted { .. } => "RunStarted",
Event::ModelCallRequested { .. } => "ModelCallRequested",
Event::ModelCallCompleted { .. } => "ModelCallCompleted",
Event::ToolCallRequested { .. } => "ToolCallRequested",
Event::ToolCallCompleted { .. } => "ToolCallCompleted",
Event::NowObserved { .. } => "NowObserved",
Event::RandomObserved { .. } => "RandomObserved",
Event::Suspended { .. } => "Suspended",
Event::Resumed { .. } => "Resumed",
Event::BudgetExceeded { .. } => "BudgetExceeded",
Event::RunCompleted { .. } => "RunCompleted",
Event::RunFailed { .. } => "RunFailed",
Event::RunAbandoned { .. } => "RunAbandoned",
Event::GraphRunStarted { .. } => "GraphRunStarted",
Event::NodeEntered { .. } => "NodeEntered",
Event::NodeExited { .. } => "NodeExited",
Event::NodeSkipped { .. } => "NodeSkipped",
Event::BranchTaken { .. } => "BranchTaken",
Event::MapFannedOut { .. } => "MapFannedOut",
Event::MapIterationStarted { .. } => "MapIterationStarted",
Event::MapIterationJoined { .. } => "MapIterationJoined",
Event::FoldIterationStarted { .. } => "FoldIterationStarted",
Event::FoldIterationJoined { .. } => "FoldIterationJoined",
Event::FoldConverged { .. } => "FoldConverged",
}
}
#[must_use]
pub fn event_detail(event: &Event) -> String {
match event {
Event::RunStarted {
agent_def_hash,
input,
..
} => format!(
"agent {} input {}",
short_hash(agent_def_hash),
truncate_json(input)
),
Event::ModelCallRequested { request_hash, .. } => {
format!("request {}", short_hash(request_hash))
}
Event::ModelCallCompleted { usage, .. } => format!(
"usage in {} out {}",
usage.input_tokens, usage.output_tokens
),
Event::ToolCallRequested {
tool,
input,
effect,
idempotency_key,
..
} => {
let key = idempotency_key
.as_deref()
.map_or_else(String::new, |k| format!(" key {k}"));
format!("{tool} [{effect:?}]{key} input {}", truncate_json(input))
}
Event::ToolCallCompleted { output, .. } => {
if let Some(suspension) = decode_suspension(output) {
format!("suspends: {}", suspension.reason)
} else if let Some(failure) = decode_failure(output) {
format!(
"error ({}, {} attempt(s)): {}",
failure.kind.as_str(),
failure.attempts,
truncate_str(&failure.message)
)
} else {
format!("output {}", truncate_json(output))
}
}
Event::NowObserved { now } => format_ts(*now),
Event::RandomObserved { value } => format!("value {value}"),
Event::Suspended { reason, .. } => format!("reason: {reason}"),
Event::Resumed { input } => format!("input {}", truncate_json(input)),
Event::BudgetExceeded { budget, observed } => {
format!(
"{} limit {}, observed {}",
budget_label(budget.kind),
fmt_num(budget.limit),
fmt_num(*observed)
)
}
Event::RunCompleted { output } => format!("output {}", truncate_json(output)),
Event::RunFailed { error } => format!("error: {}", truncate_str(error)),
Event::RunAbandoned {
reason,
unresolved_write,
} => {
let why = reason
.as_deref()
.map_or_else(|| "no reason given".to_owned(), truncate_str);
match unresolved_write {
Some(write) => format!(
"abandoned: {why} (unresolved write at seq {}, tool {})",
write.seq.get(),
write.tool
),
None => format!("abandoned: {why}"),
}
}
Event::GraphRunStarted {
graph_hash, input, ..
} => format!(
"graph {} input {}",
short_hash(graph_hash),
truncate_json(input)
),
Event::NodeEntered { node } => format!("enter {node}"),
Event::NodeExited { node } => format!("exit {node}"),
Event::NodeSkipped { node, reason } => format!("skip {node}: {}", truncate_str(reason)),
Event::BranchTaken { node, case } => format!("branch {node} -> {case}"),
Event::MapFannedOut { node, items } => {
format!("map {node} fan-out {}", truncate_json(items))
}
Event::MapIterationStarted {
node,
index,
child_run,
} => format!("map {node}[{index}] child {}", short_hash(child_run)),
Event::MapIterationJoined { node, index } => format!("map {node}[{index}] joined"),
Event::FoldIterationStarted { node, index } => format!("fold {node}[{index}] started"),
Event::FoldIterationJoined { node, index } => format!("fold {node}[{index}] joined"),
Event::FoldConverged {
node,
winner_index,
reason,
} => format!(
"fold {node} converged on [{winner_index}]: {}",
truncate_str(reason)
),
}
}
fn short_hash(hash: &str) -> String {
match hash.split_once(':') {
Some((scheme, hex)) => {
let head: String = hex.chars().take(7).collect();
if hex.len() > 7 {
format!("{scheme}:{head}\u{2026}")
} else {
format!("{scheme}:{hex}")
}
}
None => hash.chars().take(12).collect(),
}
}
fn budget_label(kind: BudgetKind) -> &'static str {
match kind {
BudgetKind::Steps => "steps",
BudgetKind::Tokens => "tokens",
BudgetKind::CostUsd => "cost_usd",
BudgetKind::WallTime => "wall_time",
}
}
fn fmt_num(value: f64) -> String {
if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{}", value as i64)
} else {
format!("{value}")
}
}
fn format_ts(ts: OffsetDateTime) -> String {
let utc = ts.to_offset(time::UtcOffset::UTC);
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}Z",
utc.year(),
u8::from(utc.month()),
utc.day(),
utc.hour(),
utc.minute(),
utc.second(),
)
}
fn truncate_json(value: &serde_json::Value) -> String {
truncate_str(&value.to_string())
}
fn truncate_str(text: &str) -> String {
const CAP: usize = 80;
if text.chars().count() > CAP {
let head: String = text.chars().take(CAP).collect();
format!("{head}\u{2026}")
} else {
text.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn detail_truncates_long_payloads() {
let big = "x".repeat(500);
let detail = event_detail(&Event::RunStarted {
agent_def_hash: "sha256:abcdef0123456789".into(),
input: json!({ "prompt": big }),
labels: None,
});
assert!(detail.contains('\u{2026}'), "detail should be truncated");
assert!(
detail.chars().count() < 200,
"truncated detail stays short: {} chars",
detail.chars().count()
);
assert!(detail.contains("sha256:abcdef0"));
}
#[test]
fn kind_matches_variant_name() {
assert_eq!(
event_kind(&Event::RunCompleted { output: json!(1) }),
"RunCompleted"
);
assert_eq!(
event_kind(&Event::RandomObserved { value: 7 }),
"RandomObserved"
);
}
}