//! Pure helpers factored out of the harness serve loop //! (`bin/hive-agent.rs`). //! Only functions with no wire-type dependency live here; //! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.) //! stay in the binary because they touch the request enum variants directly. use crate::events::Bus; use crate::turn::{TurnError, TurnOutcome}; use crate::turn_stats::TurnStatRow; pub use hive_sh4re::wire_time::now_unix; /// Assemble the per-turn wake prompt string. The role/tools/etc. live in the /// system prompt; this is just the wake signal body. `id` is the broker row /// id, rendered as a `[msg #]` marker so the agent can reference it in /// `ack_until`. `unread` is the inbox depth after this message was popped. /// `redelivered` prepends a "may already be handled" banner. #[must_use] pub fn format_wake_prompt( id: i64, from: &str, body: &str, unread: u64, redelivered: bool, ) -> String { let banner = if redelivered { hive_sh4re::REDELIVERY_HINT } else { "" }; let tag = if id > 0 { format!("[msg #{id}] ") } else { String::new() }; let pending = hive_sh4re::pending_hint(unread); format!("{banner}{tag}Incoming message from `{from}`:\n---\n{body}\n---{pending}") } /// Field-named args for [`build_row`]. Mirrors the turn-stats row /// columns; `outcome` and `bus` borrow for the duration of the call. pub struct TurnRowArgs<'a> { pub started_at: i64, pub ended_at: i64, pub duration_ms: i64, pub model: String, pub wake_from: String, pub outcome: &'a TurnOutcome, pub bus: &'a Bus, pub open_threads_count: Option, pub open_reminders_count: Option, } /// Assemble a `TurnStatRow` from the harness's per-turn state. Lives here /// (rather than inline in the serve loop) so it stays wire-type-free /// and unit-testable; the binary just feeds it the post-turn counts. #[must_use] pub fn build_row(args: TurnRowArgs<'_>) -> TurnStatRow { let TurnRowArgs { started_at, ended_at, duration_ms, model, wake_from, outcome, bus, open_threads_count, open_reminders_count, } = args; // Prefer the API-resolved model id (e.g. `claude-opus-4-8`) captured // from this turn's assistant events over the requested `--model` // name/alias, so the model-mix + cost rollup label the concrete // version that ran. Falls back to the requested name on a degenerate // turn that produced no assistant event. let model = bus.last_resolved_model().unwrap_or(model); let cost = bus.last_cost_usage().unwrap_or_default(); let ctx = bus.last_ctx_usage().unwrap_or(cost); let tool_calls = bus.take_tool_calls(); let tool_call_count: u64 = tool_calls.values().copied().sum(); let tool_call_breakdown_json = if tool_calls.is_empty() { None } else { serde_json::to_string(&tool_calls).ok() }; let (result_kind, note) = match outcome { Ok(false) => ("ok", None), Ok(true) => ("compacted", None), Err(TurnError::PromptTooLong) => ("prompt_too_long", None), Err(TurnError::RateLimited) => ("rate_limited", None), Err(TurnError::AuthFailed) => ("auth_failed", None), Err(TurnError::SessionNotFound) => ("session_not_found", None), Err(TurnError::ApiStall) => ("api_stall", None), Err(TurnError::Failed(e)) => ("failed", Some(format!("{e:#}"))), }; let wake_from = if wake_from.starts_with("bash-task-") { "bash-task".to_owned() } else { wake_from }; TurnStatRow { started_at, ended_at, duration_ms, model, wake_from, input_tokens: cost.input_tokens, output_tokens: cost.output_tokens, cache_read_input_tokens: cost.cache_read_input_tokens, cache_creation_input_tokens: cost.cache_creation_input_tokens, last_input_tokens: ctx.input_tokens, last_output_tokens: ctx.output_tokens, last_cache_read_input_tokens: ctx.cache_read_input_tokens, last_cache_creation_input_tokens: ctx.cache_creation_input_tokens, tool_call_count, tool_call_breakdown_json, open_threads_count, open_reminders_count, result_kind, note, session_id: bus.current_session_id(), } }