use std::path::Path;
use std::sync::Arc;
use recursive::llm::{pricing_for, TokenUsage};
use recursive::{AgentEvent, FinishReason, SessionFile, SessionWriter, TranscriptFile};
use tokio::sync::mpsc;
pub(crate) fn print_usage(usage: TokenUsage, model: &str, total_llm_latency_ms: u64, steps: usize) {
if usage.total_tokens > 0 {
eprintln!(
"tokens: prompt={} completion={} total={}",
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
);
if usage.cache_hit_tokens > 0 {
let total_cache = usage.cache_hit_tokens + usage.cache_miss_tokens;
let hit_rate = if total_cache > 0 {
(usage.cache_hit_tokens as f64 / total_cache as f64) * 100.0
} else {
0.0
};
eprintln!(
"cache: hit={} miss={} ({:.1}% hit rate)",
usage.cache_hit_tokens, usage.cache_miss_tokens, hit_rate
);
}
if let Some(pricing) = pricing_for(model) {
let cost = pricing.cost_usd(usage);
eprintln!("cost: ${:.4} ({})", cost, model);
}
}
if total_llm_latency_ms > 0 && steps > 0 {
let avg = total_llm_latency_ms / steps as u64;
eprintln!(
"llm latency: total={}ms avg={}ms over {} steps",
total_llm_latency_ms, avg, steps
);
}
}
pub(crate) fn print_finish_note(finish: &FinishReason) {
match finish {
FinishReason::TranscriptLimit { chars, limit } => {
eprintln!(
"note: stopped because transcript reached {} chars (limit {})",
chars, limit
);
}
FinishReason::Cancelled => {
eprintln!("shutdown: agent stopped at next step boundary after signal");
}
_ => {}
}
}
pub(crate) fn save_transcript(
outcome_transcript: &[recursive::message::Message],
outcome_steps: usize,
model: &str,
path: &Path,
) -> anyhow::Result<()> {
let file = TranscriptFile::new(
outcome_transcript.to_vec(),
outcome_steps,
Some(model.into()),
);
file.write_to(path)?;
eprintln!(
"transcript: wrote {} messages to {}",
outcome_transcript.len(),
path.display()
);
Ok(())
}
pub(crate) fn save_session(
transcript: &[recursive::message::Message],
steps: usize,
goal: String,
model: &str,
provider: &str,
tool_specs: &[recursive::ToolSpec],
path: &Path,
) -> anyhow::Result<()> {
let session = SessionFile::new(
goal,
model.to_string(),
provider.to_string(),
tool_specs,
steps,
transcript.to_vec(),
);
session.write_to(path)?;
eprintln!(
"session: wrote {} messages to {}",
transcript.len(),
path.display()
);
Ok(())
}
pub(crate) fn exit_for_finish(finish: &FinishReason, steps: usize) -> anyhow::Result<()> {
match finish {
FinishReason::BudgetExceeded => {
anyhow::bail!("agent exceeded step budget ({steps})")
}
_ => Ok(()),
}
}
pub(crate) fn finalize_session_writer(
session_writer: Option<Arc<std::sync::Mutex<SessionWriter>>>,
status: &str,
) {
let Some(sw) = session_writer else { return };
match Arc::into_inner(sw) {
Some(mutex) => match mutex.lock() {
Ok(mut w) => {
if let Err(e) = w.finish(status) {
eprintln!("session: failed to finalize: {e}");
} else {
eprintln!(
"session: saved {} message(s) to {}",
w.message_count(),
w.session_dir().display()
);
}
}
Err(e) => eprintln!("session: failed to lock writer: {e}"),
},
None => eprintln!("session: writer still has other references; cannot finalize"),
}
}
pub(crate) fn finalize_cost_tracker(
cost_tracker: Option<std::sync::Mutex<recursive::cost::CostTracker>>,
usage: recursive::llm::TokenUsage,
llm_latency_ms: u64,
model: &str,
) {
let Some(tracker) = cost_tracker else { return };
match tracker.into_inner() {
Ok(mut t) => {
t.record_usage(usage, llm_latency_ms);
if let Err(e) = t.finish() {
eprintln!("cost: failed to write cost.json: {e}");
} else {
eprintln!("cost: ${:.4} ({})", t.cost_usd().unwrap_or(0.0), model);
}
}
Err(e) => eprintln!("cost: failed to lock cost tracker: {e}"),
}
}
pub(crate) async fn stream_events(mut rx: mpsc::UnboundedReceiver<AgentEvent>) {
while let Some(ev) = rx.recv().await {
match ev {
AgentEvent::AssistantText { ref text, step } if !text.trim().is_empty() => {
println!("[step {step}] assistant: {text}");
}
AgentEvent::ToolCall {
ref name,
ref arguments,
step,
..
} => {
println!("[step {step}] -> {name} {arguments}");
}
AgentEvent::ToolResult {
ref name,
ref output,
step,
..
} => {
let preview = if output.len() > 800 {
let mut end = 800.min(output.len());
while end > 0 && !output.is_char_boundary(end) {
end -= 1;
}
format!("{}\n...[truncated]", &output[..end])
} else {
output.clone()
};
println!("[step {step}] <- {name}\n{preview}");
}
AgentEvent::TurnFinished { ref reason, steps } => {
println!("[done after {steps} steps] reason: {reason}");
}
AgentEvent::Latency { step, llm_ms } => {
println!("[step {step}] llm latency: {llm_ms}ms");
}
AgentEvent::Compacted {
removed,
kept,
summary_chars,
step,
} => {
println!(
"[step {step}] compacted {removed} msgs -> {kept} kept + {summary_chars}-char summary"
);
}
AgentEvent::PlanProposed { ref plan_text, .. } => {
println!("[plan] proposed: {plan_text}");
}
AgentEvent::PlanConfirmed => {
println!("[plan] confirmed");
}
AgentEvent::PlanRejected { ref reason } => {
println!("[plan] rejected: {reason}");
}
_ => {}
}
}
}
pub(crate) async fn stream_events_repl(mut rx: mpsc::UnboundedReceiver<AgentEvent>) {
while let Some(ev) = rx.recv().await {
match ev {
AgentEvent::AssistantText { ref text, .. } if !text.trim().is_empty() => {
println!("{text}");
}
AgentEvent::ToolCall { ref name, .. } => {
eprintln!(" ↳ {name}");
}
_ => {}
}
}
}
pub(crate) async fn stream_events_json(mut rx: mpsc::UnboundedReceiver<AgentEvent>) {
while let Some(ev) = rx.recv().await {
if let Ok(line) = serde_json::to_string(&ev) {
println!("{line}");
}
}
}