use crate::util::short_id;
use anyhow::{bail, Context, Result};
use cv_core::ir::truncate;
use cv_core::tools::{ForestTools, ToolHistogram};
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_tools(
id: &str,
harness: Option<String>,
agent: Option<String>,
tool: Option<String>,
workflow: Option<String>,
across: bool,
timeline: bool,
json: bool,
) -> Result<()> {
let want = crate::util::parse_harness(&harness)?;
let (r, _adapter) = crate::util::resolve(id, want)?;
if timeline {
return tools_timeline(&r, agent.as_deref(), json);
}
let mut forest = cv_core::tools::forest_tools(&r)?;
if let Some(run) = &workflow {
let full = cv_core::workflow_of(&r, run)
.map(|w| w.run_id)
.with_context(|| format!("no workflow {run:?} in {}", short_id(&r.id)))?;
forest = forest.for_workflow(&full);
if forest.agents.is_empty() {
bail!("no agents found for workflow {full:?} in {}", short_id(&r.id));
}
}
if let Some(t) = &tool {
return tools_which_agents(&forest, t, json);
}
if let Some(a) = &agent {
let at = forest.agent(a).with_context(|| {
format!(
"no agent matching {a:?} in {} (try `cv tools {} --across`)",
short_id(&r.id),
short_id(&r.id)
)
})?;
if json {
println!("{}", serde_json::to_string_pretty(at)?);
return Ok(());
}
println!("# tools used by {} ({})", at.agent, at.agent_type);
print_histogram(&at.histogram);
return Ok(());
}
if across {
return tools_across(&forest, json);
}
let agg = forest.aggregate();
if json {
println!("{}", serde_json::to_string_pretty(&agg)?);
return Ok(());
}
println!(
"# tool usage across {} ({} agent(s): orchestrator + forest)",
short_id(&r.id),
forest.agents.len()
);
print_histogram(&agg);
println!("\n→ `--across` for per-agent · `--agent <id>` · `--tool <name>` · `--timeline`");
Ok(())
}
fn tools_which_agents(forest: &ForestTools, tool: &str, json: bool) -> Result<()> {
let users = forest.agents_using(tool);
if json {
let rows: Vec<serde_json::Value> = users
.iter()
.map(|(a, c)| {
serde_json::json!({
"agent": a.agent, "agent_type": a.agent_type, "workflow": a.workflow,
"calls": c.calls, "edits": c.edits, "reads": c.reads, "commands": c.commands, "errors": c.errors,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if users.is_empty() {
println!("no agent used {tool:?}");
return Ok(());
}
let total: usize = users.iter().map(|(_, c)| c.calls).sum();
println!("# {} agent(s) used {tool:?} ({total} call(s) total)\n", users.len());
for (a, c) in users {
let wf = a.workflow.as_deref().map(|w| format!(" ⟐{w}")).unwrap_or_default();
println!(
"{:>5} {:9} {:18}{}",
c.calls,
a.agent_type,
if a.agent == cv_core::tools::ORCHESTRATOR {
a.agent.clone()
} else {
short_id(&a.agent)
},
wf,
);
}
Ok(())
}
fn tools_across(forest: &ForestTools, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(forest)?);
return Ok(());
}
let active: Vec<&cv_core::tools::AgentTools> =
forest.agents.iter().filter(|a| a.histogram.total_calls > 0).collect();
println!("# per-agent tool usage ({} active agent(s))\n", active.len());
for a in active {
let wf = a.workflow.as_deref().map(|w| format!(" ⟐{w}")).unwrap_or_default();
let label = if a.agent == cv_core::tools::ORCHESTRATOR {
a.agent.clone()
} else {
short_id(&a.agent)
};
let top: Vec<String> = a
.histogram
.ranked()
.into_iter()
.take(6)
.map(|(name, c)| format!("{name}×{}", c.calls))
.collect();
println!(
"{:18} {:11} {:>4} call(s){} {}",
label,
a.agent_type,
a.histogram.total_calls,
wf,
top.join(" "),
);
}
Ok(())
}
fn tools_timeline(r: &cv_core::SessionRef, agent: Option<&str>, json: bool) -> Result<()> {
let mut events = cv_core::tools::forest_timeline(r)?;
if let Some(key) = agent {
events.retain(|e| e.agent == key || e.agent.starts_with(key));
}
if json {
println!("{}", serde_json::to_string_pretty(&events)?);
return Ok(());
}
if events.is_empty() {
println!(
"no tool calls{}",
agent.map(|a| format!(" for {a:?}")).unwrap_or_default()
);
return Ok(());
}
println!("# {} tool call(s) (chronological)\n", events.len());
for e in &events {
let time =
e.ts.and_then(|t| crate::util::fmt_local_ts(t, "%m-%d %H:%M:%S"))
.unwrap_or_else(|| "--------------".into());
let who = if e.agent == cv_core::tools::ORCHESTRATOR {
"orch".to_string()
} else {
short_id(&e.agent)
};
println!(
"{} {:8} {:9} {:14} {}",
time,
who,
e.kind,
e.tool.as_deref().unwrap_or("-"),
e.target.as_deref().map(|t| truncate(t, 70)).unwrap_or_default(),
);
}
Ok(())
}
fn print_histogram(h: &ToolHistogram) {
if h.tools.is_empty() {
println!(" (no tool calls)");
return;
}
println!(
" {:<22} {:>6} {:>6} {:>6} {:>6} {:>6}",
"tool", "calls", "edit", "read", "cmd", "err"
);
for (name, c) in h.ranked() {
println!(
" {:<22} {:>6} {:>6} {:>6} {:>6} {:>6}",
truncate(name, 22),
c.calls,
kindcell(c.edits),
kindcell(c.reads),
kindcell(c.commands),
kindcell(c.errors),
);
}
println!(
" {:<22} {:>6} {:>30}",
"TOTAL",
h.total_calls,
format!("{} distinct · {} error(s)", h.distinct(), h.total_errors),
);
}
fn kindcell(n: usize) -> String {
if n == 0 {
String::new()
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kindcell_blanks_zero() {
assert_eq!(kindcell(0), "");
assert_eq!(kindcell(3), "3");
}
}