137 lines
5.6 KiB
Rust
137 lines
5.6 KiB
Rust
//! Pure helpers factored out of the harness serve loop
|
|
//! (`serve_main` in `main.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;
|
|
|
|
/// Assemble the per-turn wake prompt string. The role/tools/etc. live in the
|
|
/// system prompt; this is just the wake signal body. `redelivered` prepends a "may already be handled" banner; `interrupted`
|
|
/// prepends a "previous turn was /cancel'd" banner instead (the two are
|
|
/// mutually exclusive in practice — a redelivered message means the harness
|
|
/// itself restarted, which also clears the in-memory interrupted flag — so
|
|
/// `redelivered` takes priority if both were somehow set).
|
|
#[must_use]
|
|
pub fn format_wake_prompt(from: &str, body: &str, redelivered: bool, interrupted: bool) -> String {
|
|
let banner = if redelivered {
|
|
hive_sh4re::inbox::REDELIVERY_HINT
|
|
} else if interrupted {
|
|
hive_sh4re::inbox::INTERRUPTED_HINT
|
|
} else {
|
|
""
|
|
};
|
|
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---")
|
|
}
|
|
|
|
/// Field-named args for [`build_row`]. Mirrors the turn-stats row
|
|
/// columns; `outcome` and `bus` borrow for the duration of the call.
|
|
/// `tool_calls` is passed in (rather than pulled from `bus` internally)
|
|
/// because the caller needs the same map to check for a `get_loose_ends`
|
|
/// call before it's consumed — `Bus::take_tool_calls` resets the counter,
|
|
/// so there's only one chance to read it per turn.
|
|
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 tool_calls: std::collections::HashMap<String, u64>,
|
|
pub open_threads_count: Option<u64>,
|
|
pub open_reminders_count: Option<u64>,
|
|
}
|
|
|
|
/// 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,
|
|
tool_calls,
|
|
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_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::AgentStall(note)) => ("api_stall", Some(note.clone())),
|
|
Err(TurnError::Failed(e)) => ("failed", Some(format!("{e:#}"))),
|
|
};
|
|
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(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod wake_prompt_tests {
|
|
use super::format_wake_prompt;
|
|
|
|
/// The prompt is the sender and body only: no `[msg #N]` tag and no
|
|
/// "N more pending, call recv" nudge, since the tools they pointed at
|
|
/// are gone.
|
|
#[test]
|
|
fn the_wake_prompt_carries_the_sender_and_body_only() {
|
|
let p = format_wake_prompt("todo", "you have todos", false, false);
|
|
assert_eq!(p, "Incoming message from `todo`:\n---\nyou have todos\n---");
|
|
assert!(!p.contains("[msg #") && !p.contains("recv"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_banner_leads_the_prompt_and_redelivery_wins_over_interruption() {
|
|
let redelivered = format_wake_prompt("a", "b", true, true);
|
|
assert!(redelivered.starts_with(hive_sh4re::inbox::REDELIVERY_HINT));
|
|
let interrupted = format_wake_prompt("a", "b", false, true);
|
|
assert!(interrupted.starts_with(hive_sh4re::inbox::INTERRUPTED_HINT));
|
|
let plain = format_wake_prompt("a", "b", false, false);
|
|
assert!(plain.starts_with("Incoming message from `a`:"));
|
|
}
|
|
}
|