use hotl_engine::{EngineEvent, Outcome};
use hotl_provider::catalog;
use hotl_types::TokenUsage;
use serde_json::{json, Value};
pub const JSON_STREAM_SCHEMA_VERSION: u32 = 2;
pub fn update_frame(event: &EngineEvent) -> Option<Value> {
Some(match event {
EngineEvent::TextDelta(t) => json!({"type": "text_delta", "text": t}),
EngineEvent::ThinkingDelta(t) => json!({"type": "thinking_delta", "text": t}),
EngineEvent::ToolStart { name, summary } => {
json!({"type": "tool_start", "name": name, "summary": summary})
}
EngineEvent::ToolDone { name, ok } => {
json!({"type": "tool_done", "name": name, "ok": ok})
}
EngineEvent::ToolDenied { name } => json!({"type": "tool_denied", "name": name}),
EngineEvent::ToolAutoAllowed { name, rule } => {
json!({"type": "tool_auto_allowed", "name": name, "rule": rule})
}
EngineEvent::Retrying { attempt, reason } => {
json!({"type": "retrying", "attempt": attempt, "reason": reason})
}
EngineEvent::FallbackModel { model } => {
json!({"type": "fallback_model", "model": model})
}
EngineEvent::PromptQueued => json!({"type": "prompt_queued"}),
EngineEvent::Compacted { degraded } => {
json!({"type": "compacted", "degraded": degraded})
}
EngineEvent::TodosChanged { items } => json!({"type": "todos_changed", "items": items}),
EngineEvent::LedgerReport(summary) => json!({
"type": "ledger_report",
"sample_count": summary.sample_count,
"overhead_p50_ns": summary.overhead_p50_ns,
"overhead_p99_ns": summary.overhead_p99_ns,
"max_rss_bytes": summary.max_rss_bytes,
}),
EngineEvent::Ask { .. } | EngineEvent::Question { .. } | EngineEvent::TurnDone { .. } => {
return None
}
})
}
pub fn outcome_frame(outcome: &Outcome) -> Value {
match outcome {
Outcome::Done { text } => json!({"kind": "done", "text": text}),
Outcome::Cancelled => json!({"kind": "cancelled"}),
Outcome::TurnLimit => json!({"kind": "turn_limit"}),
Outcome::Refused => json!({"kind": "refused"}),
Outcome::DoomLoop { pattern } => json!({"kind": "doom_loop", "pattern": pattern}),
Outcome::ToolFailureBudget { tool } => {
json!({"kind": "tool_failure_budget", "tool": tool})
}
Outcome::Error { message } => json!({"kind": "error", "message": message}),
}
}
pub fn usage_frame(model: &str, usage: &TokenUsage) -> Value {
let mut v = json!(usage);
if let Some(ratio) = usage.hit_ratio() {
v["hit_ratio"] = json!(ratio);
}
if let Some(cost) = catalog::cost_usd(model, usage) {
v["cost_usd"] = json!(cost);
}
v
}
pub fn json_frame(event: &EngineEvent, model: &str) -> Value {
let mut v = match update_frame(event) {
Some(v) => v,
None => match event {
EngineEvent::TurnDone { outcome, usage } => json!({
"type": "turn_done",
"outcome": outcome_frame(outcome),
"usage": usage_frame(model, usage),
}),
EngineEvent::Ask { summary, .. } => {
json!({"type": "ask_denied", "summary": summary})
}
EngineEvent::Question { question, .. } => json!({
"type": "question_no_human",
"header": question.header,
"prompt": question.prompt,
"options": question.options,
}),
_ => unreachable!("update_frame covers every other variant"),
},
};
v["schema_version"] = json!(JSON_STREAM_SCHEMA_VERSION);
v
}