use agx::{
annotations, browser, corpus, debug_unknowns, diff_tui, export, format, loader, pii, slice,
timeline::{Step, compute_session_totals, compute_tool_stats, count_from_steps},
tui,
};
use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use loader::load_session;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Copy, Clone, Debug, ValueEnum)]
enum ExportFormat {
Md,
Html,
Json,
#[value(name = "trajectory-openai")]
TrajectoryOpenai,
}
#[derive(Parser, Debug)]
#[command(name = "agx", version, about = "Step-through debugger for your agent")]
struct Cli {
session: Option<PathBuf>,
#[arg(long)]
summary: bool,
#[arg(long)]
diff: Option<PathBuf>,
#[arg(long, requires = "diff", conflicts_with_all = ["summary", "export"])]
diff_tui: bool,
#[arg(long)]
live: bool,
#[arg(long, value_name = "SHELL")]
completions: Option<Shell>,
#[arg(long)]
debug_unknowns: bool,
#[arg(long)]
scan_pii: bool,
#[arg(long)]
no_cost: bool,
#[arg(long, value_enum, value_name = "FORMAT")]
export: Option<ExportFormat>,
#[arg(long, value_name = "NEEDLE")]
redact: Vec<String>,
#[arg(long, hide = true)]
bench: bool,
#[arg(long, value_name = "DURATION")]
after: Option<String>,
#[arg(long, value_name = "DURATION")]
before: Option<String>,
#[arg(long, value_name = "N", conflicts_with = "range")]
after_step: Option<usize>,
#[arg(long, value_name = "N", conflicts_with = "range")]
before_step: Option<usize>,
#[arg(long, value_name = "RANGE")]
range: Option<String>,
#[arg(long, value_name = "STEP")]
jump_to: Option<usize>,
#[arg(long, requires = "live")]
notify_on_error: bool,
#[arg(long, value_name = "DURATION", requires = "live")]
notify_on_idle: Option<String>,
#[arg(long, hide = true)]
experimental_replay: bool,
#[arg(long, hide = true, requires = "experimental_replay")]
allow_shell_replay: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
Corpus(CorpusArgs),
Doctor(DoctorArgs),
}
#[derive(clap::Args, Debug)]
struct DoctorArgs {
#[arg(long)]
json: bool,
}
#[derive(clap::Args, Debug)]
struct CorpusArgs {
dir: PathBuf,
#[arg(long, value_name = "FILTER")]
filter: Vec<String>,
#[arg(long)]
json: bool,
#[arg(long)]
no_cost: bool,
#[arg(long, default_value_t = 8)]
max_depth: usize,
#[arg(long, hide = true)]
bench: bool,
#[arg(long, conflicts_with_all = ["json", "jsonl"])]
tui: bool,
#[arg(long, conflicts_with = "json")]
jsonl: bool,
#[arg(long)]
fail_on_errored: bool,
#[arg(long)]
trajectory_stats: bool,
#[arg(long, value_name = "N")]
sample: Option<usize>,
}
fn probe_tool_version(bin: &str) -> Option<String> {
use std::process::Command;
let output = Command::new(bin).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next()?.trim().to_string();
if first_line.is_empty() {
None
} else {
Some(first_line)
}
}
fn run_doctor(args: &DoctorArgs) -> Result<()> {
let agx_version = env!("CARGO_PKG_VERSION").to_string();
let mut features: Vec<&'static str> = Vec::new();
if cfg!(feature = "otel-proto") {
features.push("otel-proto");
}
if cfg!(feature = "embedding-search") {
features.push("embedding-search");
}
if cfg!(feature = "notifications") {
features.push("notifications");
}
let agx_mcp = probe_tool_version("agx-mcp");
let sift = probe_tool_version("sift");
let rgx = probe_tool_version("rgx");
if args.json {
let mut payload = serde_json::Map::new();
payload.insert(
"agx".into(),
serde_json::json!({
"state": "ok",
"version": agx_version,
"features": features,
}),
);
payload.insert("agx_mcp".into(), sibling_json(&agx_mcp));
payload.insert("sift".into(), sibling_json(&sift));
payload.insert("rgx".into(), sibling_json(&rgx));
println!("{}", serde_json::to_string_pretty(&payload)?);
return Ok(());
}
println!("agx doctor\n");
let feat = if features.is_empty() {
"default".to_string()
} else {
features.join(", ")
};
println!(" {:<10} ok ({agx_version}, features: {feat})", "agx:");
print_sibling("agx-mcp:", agx_mcp.as_deref());
print_sibling("sift:", sift.as_deref());
print_sibling("rgx:", rgx.as_deref());
let detected = [&agx_mcp, &sift, &rgx]
.iter()
.filter(|v| v.is_some())
.count();
println!("\nstepwise suite: {} of 3 siblings present", detected);
Ok(())
}
fn sibling_json(version: &Option<String>) -> serde_json::Value {
match version {
Some(v) => serde_json::json!({"state": "ok", "version": v}),
None => serde_json::json!({"state": "missing"}),
}
}
fn print_sibling(label: &str, version: Option<&str>) {
match version {
Some(v) => println!(" {:<10} ok ({v})", label),
None => println!(" {:<10} missing", label),
}
}
fn print_pii_scan(steps: &[Step]) {
use std::collections::BTreeMap;
let matches = pii::scan_steps(steps);
if matches.is_empty() {
println!("agx --scan-pii: no matches");
return;
}
let mut by_cat: BTreeMap<&str, Vec<&pii::Match>> = BTreeMap::new();
for m in &matches {
by_cat.entry(m.category.label()).or_default().push(m);
}
println!(
"agx --scan-pii: {} match(es) across {} categor(ies)",
matches.len(),
by_cat.len()
);
for (cat, hits) in &by_cat {
let first = hits.first().expect("non-empty by construction");
let step_preview: Vec<String> = hits
.iter()
.take(3)
.map(|h| format!("step {}", h.step_index + 1))
.collect();
let more = if hits.len() > 3 {
format!(" (+{} more)", hits.len() - 3)
} else {
String::new()
};
println!(
" {cat:<24} {:>3} match(es) [{}]{more} eg: {}",
hits.len(),
step_preview.join(", "),
truncate_for_display(&first.snippet, 40),
);
}
println!();
println!(
"agx: to scrub these, pipe back through `--redact <NEEDLE>` or `--export trajectory-openai --redact โฆ`"
);
}
fn truncate_for_display(s: &str, n: usize) -> String {
let head: String = s.chars().take(n).collect();
if s.chars().count() > n {
format!("{head}โฆ")
} else {
head
}
}
fn print_diff(path_a: &Path, steps_a: &[Step], path_b: &Path, steps_b: &[Step]) {
let fmt_a = format::detect(path_a).map_or_else(|_| "?".into(), |f| f.to_string());
let fmt_b = format::detect(path_b).map_or_else(|_| "?".into(), |f| f.to_string());
let counts_a = count_from_steps(steps_a);
let counts_b = count_from_steps(steps_b);
let stats_a = compute_tool_stats(steps_a);
let stats_b = compute_tool_stats(steps_b);
println!("agx diff\n");
println!(
" {:<40} {:<40}",
format!("A: {} ({})", fmt_a, path_a.display()),
format!("B: {} ({})", fmt_b, path_b.display())
);
println!();
println!(
" {:<40} {:<40}",
format!("Steps: {}", steps_a.len()),
format!("Steps: {}", steps_b.len())
);
println!(
" {:<40} {:<40}",
format!(
"user:{} asst:{} tool:{} result:{}",
counts_a.user, counts_a.assistant, counts_a.tool_uses, counts_a.tool_results
),
format!(
"user:{} asst:{} tool:{} result:{}",
counts_b.user, counts_b.assistant, counts_b.tool_uses, counts_b.tool_results
),
);
println!();
let names_a: HashSet<String> = stats_a.iter().map(|s| s.name.clone()).collect();
let names_b: HashSet<String> = stats_b.iter().map(|s| s.name.clone()).collect();
let map_a: HashMap<&str, &agx::timeline::ToolStats> =
stats_a.iter().map(|s| (s.name.as_str(), s)).collect();
let map_b: HashMap<&str, &agx::timeline::ToolStats> =
stats_b.iter().map(|s| (s.name.as_str(), s)).collect();
let both: Vec<&String> = names_a.intersection(&names_b).collect();
println!(" Tools in both ({}):", both.len());
for name in &both {
let Some((a, b)) = map_a.get(name.as_str()).zip(map_b.get(name.as_str())) else {
continue;
};
#[allow(clippy::cast_possible_wrap)]
let delta = b.use_count as i64 - a.use_count as i64;
let sign = if delta >= 0 { "+" } else { "" };
println!(
" {:<20} A:{:<4} B:{:<4} ({sign}{delta})",
name, a.use_count, b.use_count
);
}
let only_a: Vec<&String> = names_a.difference(&names_b).collect();
let only_b: Vec<&String> = names_b.difference(&names_a).collect();
if !only_a.is_empty() {
let list: Vec<&str> = only_a.iter().map(|s| s.as_str()).collect();
println!(" Tools only in A: {}", list.join(", "));
}
if !only_b.is_empty() {
let list: Vec<&str> = only_b.iter().map(|s| s.as_str()).collect();
println!(" Tools only in B: {}", list.join(", "));
}
let errors_a: usize = stats_a.iter().map(|s| s.error_count).sum();
let errors_b: usize = stats_b.iter().map(|s| s.error_count).sum();
println!();
println!(
" {:<40} {:<40}",
format!("Errors: {errors_a}"),
format!("Errors: {errors_b}")
);
}
fn main() -> Result<()> {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "agx", &mut std::io::stdout());
return Ok(());
}
if let Some(Commands::Doctor(args)) = &cli.command {
return run_doctor(args);
}
if let Some(Commands::Corpus(args)) = cli.command {
let filters = args
.filter
.iter()
.map(|s| corpus::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
let corpus_args = corpus::CorpusArgs {
dir: args.dir,
filters,
json: args.json,
no_cost: args.no_cost,
max_depth: args.max_depth,
bench: args.bench,
tui: args.tui,
jsonl: args.jsonl,
fail_on_errored: args.fail_on_errored,
trajectory_stats: args.trajectory_stats,
sample: args.sample,
};
return corpus::run(&corpus_args, &|parsed, stats, no_cost| {
agx::corpus_tui::run(parsed, stats, no_cost)
});
}
let session_path = if let Some(p) = cli.session {
p
} else if cli.diff.is_some() {
return Err(anyhow::anyhow!(
"--diff requires a session path as the first argument"
));
} else {
let files = browser::discover_all();
match browser::prompt_user_to_choose(&files)? {
Some(p) => p,
None => return Ok(()),
}
};
if cli.debug_unknowns {
let fmt = format::detect(&session_path)?;
let report = debug_unknowns::scan(fmt, &session_path)?;
report.print(&mut std::io::stderr())?;
}
let load_start = std::time::Instant::now();
let steps = load_session(&session_path)?;
if cli.bench {
eprintln!(
"[bench] load: {:.2}ms ({} steps)",
load_start.elapsed().as_secs_f64() * 1000.0,
steps.len()
);
}
let range = if let Some(r) = cli.range.as_deref() {
slice::parse_step_range(r)?
} else {
slice::step_range_from_bounds(cli.after_step, cli.before_step)
};
let after_ms = cli
.after
.as_deref()
.map(slice::parse_duration_ms)
.transpose()?;
let before_ms = cli
.before
.as_deref()
.map(slice::parse_duration_ms)
.transpose()?;
slice::warn_if_time_filter_ignored(&steps, after_ms, before_ms);
let sliced_any = !range.is_identity() || after_ms.is_some() || before_ms.is_some();
let steps = if sliced_any {
let before_count = steps.len();
let sliced = slice::slice_steps(steps, &range, after_ms, before_ms);
if cli.bench {
eprintln!("[bench] slice: {} โ {} steps", before_count, sliced.len());
}
sliced
} else {
steps
};
if cli.scan_pii {
print_pii_scan(&steps);
return Ok(());
}
if let Some(diff_path) = &cli.diff {
let steps_b = load_session(diff_path)?;
if cli.diff_tui {
let fmt_a =
format::detect(&session_path).map_or_else(|_| "?".into(), |f| f.to_string());
let fmt_b = format::detect(diff_path).map_or_else(|_| "?".into(), |f| f.to_string());
diff_tui::run(
&steps,
&steps_b,
&session_path,
diff_path,
&fmt_a,
&fmt_b,
cli.no_cost,
)?;
} else {
print_diff(&session_path, &steps, diff_path, &steps_b);
}
return Ok(());
}
if let Some(fmt) = cli.export {
let steps = export::redacted_steps(&steps, &cli.redact);
let totals = compute_session_totals(&steps);
let annotations = annotations::Annotations::load_for(&session_path);
let ann_ref = if annotations.is_empty() {
None
} else {
Some(&annotations)
};
let out = match fmt {
ExportFormat::Json => export::json(&steps, &totals, ann_ref)?,
ExportFormat::Md => export::markdown(&steps, &totals, cli.no_cost, ann_ref),
ExportFormat::Html => export::html(&steps, &totals, cli.no_cost, ann_ref),
ExportFormat::TrajectoryOpenai => export::trajectory_openai(&steps)?,
};
print!("{out}");
return Ok(());
}
if cli.summary {
let fmt = format::detect(&session_path)?;
let counts = count_from_steps(&steps);
let totals = compute_session_totals(&steps);
println!("Loaded {} session from {}", fmt, session_path.display());
println!(
" {} timeline steps: {} user, {} assistant, {} tool_uses, {} tool_results",
steps.len(),
counts.user,
counts.assistant,
counts.tool_uses,
counts.tool_results
);
if totals.has_tokens() {
println!(
" {} input tokens, {} output, {} cache_read, {} cache_create",
totals.tokens_in, totals.tokens_out, totals.cache_read, totals.cache_create
);
}
if !totals.unique_models.is_empty() {
println!(" models: {}", totals.unique_models.join(", "));
}
if !cli.no_cost {
match totals.cost_usd {
Some(c) => println!(" estimated cost: ${c:.4} USD"),
None if totals.has_tokens() => {
println!(" estimated cost: (unknown โ no pricing entry for model)")
}
None => {}
}
}
println!("First 20:");
for (i, step) in steps.iter().take(20).enumerate() {
println!(" {:>3} {}", i + 1, step.label);
}
return Ok(());
}
let reload_fn: Option<Box<dyn Fn() -> Result<Vec<Step>>>> = if cli.live {
let path = session_path.clone();
Some(Box::new(move || load_session(&path)))
} else {
None
};
let notify_idle_ms = match cli.notify_on_idle.as_deref() {
Some(s) => match agx::slice::parse_duration_ms(s) {
Ok(ms) => Some(ms),
Err(e) => {
eprintln!("agx: ignoring --notify-on-idle `{s}`: {e}");
None
}
},
None => None,
};
if (cli.notify_on_error || notify_idle_ms.is_some()) && !cfg!(feature = "notifications") {
eprintln!("agx: {}", agx::notify::FEATURE_DISABLED_MESSAGE);
}
let notify_cfg = tui::NotifyConfig {
on_error: cli.notify_on_error,
on_idle_ms: notify_idle_ms,
};
let replay_cfg = agx::replay::ReplayConfig {
enabled: cli.experimental_replay,
allow_shell: cli.allow_shell_replay,
};
tui::run(
steps,
reload_fn.as_deref(),
cli.no_cost,
Some(&session_path),
cli.jump_to,
notify_cfg,
replay_cfg,
)?;
Ok(())
}