use crate::model::{Advice, AdviceRule, AgentState, ContextOrigin, McpMatch, Snapshot};
use std::collections::HashMap;
use std::time::SystemTime;
pub const BIG_RESULT_TOKENS: u64 = 8_000;
pub const MIN_SOURCE_TOKENS: u64 = 40_000;
pub const IDLE_SERVER_SECS: u64 = 10 * 60;
pub const GROWTH_BYTES: u64 = 64 << 20;
pub const GROWTH_RATIO: f64 = 1.5;
pub const GROWTH_WINDOW_SECS: u64 = 10 * 60;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RssTrend {
pub since: SystemTime,
pub from_bytes: u64,
pub to_bytes: u64,
pub steady: bool,
pub recent_growth: u64,
}
pub fn advise(snap: &Snapshot, trends: &HashMap<u32, RssTrend>) -> Vec<Advice> {
let mut out = Vec::new();
for a in snap.agents.iter().filter(|a| a.state != AgentState::Stopped) {
let priced = a.price_source.is_some();
for c in &a.context {
if c.origin == ContextOrigin::Other || c.calls == 0 {
continue;
}
let per_call = c.tokens / c.calls;
if per_call < BIG_RESULT_TOKENS || c.tokens < MIN_SOURCE_TOKENS {
continue;
}
let cost = priced.then_some(c.cost_usd);
let paid = match cost {
Some(usd) => format!("re-read at a cost of ${usd:.2} since"),
None => "re-read on every response since (unpriced model)".to_string(),
};
let (subject, headline, action) = match c.origin {
ContextOrigin::Mcp => (
c.name.clone(),
format!("{} MCP server: {} added {} tokens to the prompt, {}", c.name, calls(c.calls), tokens(c.tokens), paid),
"ask it for smaller results, or drop it from this project's MCP config".to_string(),
),
_ => (
c.name.clone(),
format!(
"{}: {} added {} tokens to the prompt ({} each), {}",
c.name,
calls(c.calls),
tokens(c.tokens),
tokens(per_call),
paid
),
"read in smaller parts, or start a fresh session so the results stop being re-sent".to_string(),
),
};
out.push(Advice {
agent_id: a.id.clone(),
agent_name: a.name.clone(),
rule: AdviceRule::ExpensiveSource,
subject,
pid: None,
headline,
action,
cost_usd: cost.unwrap_or(0.0),
tokens: c.tokens,
calls: c.calls,
rss_bytes: 0,
});
}
let join_complete = !a.mcp_servers.iter().any(|m| m.matched_by == McpMatch::TranscriptOnly);
for m in &a.mcp_servers {
let Some(pid) = m.pid else { continue };
if join_complete && m.calls == 0 && a.turns > 0 && m.age_secs.unwrap_or(0) >= IDLE_SERVER_SECS {
out.push(Advice {
agent_id: a.id.clone(),
agent_name: a.name.clone(),
rule: AdviceRule::IdleMcpServer,
subject: m.name.clone(),
pid: Some(pid),
headline: format!(
"{} (pid {pid}) has answered no calls in {} and holds {}",
m.name,
age(m.age_secs.unwrap_or(0)),
bytes(m.rss_bytes)
),
action: "remove it from this project's MCP config; its tool definitions are sent with every response".to_string(),
cost_usd: 0.0,
tokens: 0,
calls: 0,
rss_bytes: m.rss_bytes,
});
}
if let Some(t) = trends.get(&pid)
&& is_leak_shaped(t, snap.taken_at)
&& m.last_call.is_none_or(|c| c < t.since)
{
let over = snap.taken_at.duration_since(t.since).map(|d| d.as_secs()).unwrap_or(0);
out.push(Advice {
agent_id: a.id.clone(),
agent_name: a.name.clone(),
rule: AdviceRule::GrowingMcpServer,
subject: m.name.clone(),
pid: Some(pid),
headline: format!(
"{} (pid {pid}) grew from {} to {} over {} with no calls",
m.name,
bytes(t.from_bytes),
bytes(t.to_bytes),
age(over)
),
action: "a server that grows while unused is likely leaking; watch it, and kill it if it outlives its agent"
.to_string(),
cost_usd: 0.0,
tokens: 0,
calls: m.calls,
rss_bytes: t.to_bytes,
});
}
}
}
out.sort_by(|x, y| {
rank(x.rule)
.cmp(&rank(y.rule))
.then(y.cost_usd.partial_cmp(&x.cost_usd).unwrap_or(std::cmp::Ordering::Equal))
.then(y.tokens.cmp(&x.tokens))
.then(y.rss_bytes.cmp(&x.rss_bytes))
});
out
}
fn rank(r: AdviceRule) -> u8 {
match r {
AdviceRule::ExpensiveSource => 0,
AdviceRule::GrowingMcpServer => 1,
AdviceRule::IdleMcpServer => 2,
}
}
fn is_leak_shaped(t: &RssTrend, now: SystemTime) -> bool {
let window = now.duration_since(t.since).map(|d| d.as_secs()).unwrap_or(0);
let grown = t.to_bytes.saturating_sub(t.from_bytes);
window >= GROWTH_WINDOW_SECS
&& t.steady
&& t.recent_growth > 0
&& grown >= GROWTH_BYTES
&& (t.to_bytes as f64) >= (t.from_bytes as f64) * GROWTH_RATIO
}
fn calls(n: u64) -> String {
if n == 1 { "1 call".into() } else { format!("{n} calls") }
}
fn tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1e6)
} else if n >= 10_000 {
format!("{}k", n / 1000)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1e3)
} else {
n.to_string()
}
}
fn bytes(n: u64) -> String {
const MB: f64 = 1024.0 * 1024.0;
if n as f64 >= 1024.0 * MB { format!("{:.1} GB", n as f64 / (1024.0 * MB)) } else { format!("{:.0} MB", n as f64 / MB) }
}
fn age(secs: u64) -> String {
if secs >= 3600 { format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}m", secs / 60) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::*;
use std::time::Duration;
fn agent(id: &str) -> Agent {
Agent {
id: format!("pid:{id}"),
name: format!("claude:{id}"),
harness: Harness::Claude,
state: AgentState::Running,
activity: Activity::Working,
pid: Some(1),
session_id: None,
session_path: None,
cwd: None,
model: Some("claude-fable-5-1".into()),
harness_version: None,
usage: TokenUsage::default(),
cost_usd: 0.0,
cost_breakdown: Default::default(),
price_source: Some(PriceSource::Builtin),
unpriced_tokens: 0,
turns: 5,
subagent_turns: 0,
tool_calls: 0,
web_searches: 0,
spans: Vec::new(),
age_secs: 3600,
idle_secs: None,
cpu_percent: 0.0,
rss_bytes: 0,
process_count: 1,
mcp_count: 0,
mcp_servers: Vec::new(),
context: Vec::new(),
tree: None,
attribution: Attribution::HarnessRegistry,
shares_process: false,
parse_warning: None,
rate_limit: None,
}
}
fn source(name: &str, origin: ContextOrigin, calls: u64, tokens: u64, cost_usd: f64) -> ContextSource {
ContextSource { name: name.into(), origin, calls, tokens, cost_usd }
}
fn server(name: &str, pid: u32, calls: u64, age_secs: u64, rss: u64, matched_by: McpMatch) -> McpServer {
McpServer {
name: name.into(),
pid: Some(pid),
cmdline: None,
cpu_percent: 0.0,
rss_bytes: rss,
age_secs: Some(age_secs),
calls,
errors: 0,
last_call: None,
matched_by,
}
}
fn snapshot(agents: Vec<Agent>) -> Snapshot {
Snapshot {
schema_version: SNAPSHOT_SCHEMA_VERSION,
taken_at: SystemTime::UNIX_EPOCH + Duration::from_secs(10_000),
host: HostStats::default(),
agents,
orphans: Vec::new(),
orphan_origins: Vec::new(),
advice: Vec::new(),
totals: Totals::default(),
}
}
#[test]
fn an_oversized_result_is_flagged_with_its_cost_and_a_small_one_is_not() {
let mut a = agent("proj");
a.context = vec![
source("docs-search", ContextOrigin::Mcp, 1, 40_000, 12.03),
source("Read", ContextOrigin::Tool, 3, 120_000, 40.10),
source("Bash", ContextOrigin::Tool, 200, 400_000, 90.0),
source("Grep", ContextOrigin::Tool, 1, 9_000, 0.02),
source("prompts & replies", ContextOrigin::Other, 0, 900_000, 200.0),
];
let out = advise(&snapshot(vec![a]), &HashMap::new());
let subjects: Vec<&str> = out.iter().map(|x| x.subject.as_str()).collect();
assert_eq!(subjects, ["Read", "docs-search"], "most expensive first: {out:#?}");
assert!(out.iter().all(|x| x.rule == AdviceRule::ExpensiveSource));
let read = &out[0];
assert_eq!(read.headline, "Read: 3 calls added 120k tokens to the prompt (40k each), re-read at a cost of $40.10 since");
assert!(read.action.contains("smaller parts"));
let mcp = &out[1];
assert_eq!(mcp.headline, "docs-search MCP server: 1 call added 40k tokens to the prompt, re-read at a cost of $12.03 since");
assert!(mcp.action.contains("MCP config"));
assert_eq!((mcp.calls, mcp.tokens, mcp.cost_usd), (1, 40_000, 12.03));
}
#[test]
fn an_unpriced_model_still_gets_the_size_advice_without_a_dollar_figure() {
let mut a = agent("proj");
a.price_source = None;
a.context = vec![source("Read", ContextOrigin::Tool, 1, 50_000, 0.0)];
let out = advise(&snapshot(vec![a]), &HashMap::new());
assert_eq!(out.len(), 1);
assert!(out[0].headline.ends_with("(unpriced model)"), "{}", out[0].headline);
assert!(!out[0].headline.contains('$'));
}
#[test]
fn an_attached_server_with_no_calls_is_idle_unless_the_join_is_incomplete() {
let mut a = agent("proj");
a.mcp_servers = vec![
server("server-filesystem", 11, 0, 42 * 60, 180 << 20, McpMatch::ProcessOnly),
server("git", 12, 9, 42 * 60, 50 << 20, McpMatch::Name),
server("fresh", 13, 0, 30, 20 << 20, McpMatch::ProcessOnly),
];
let out = advise(&snapshot(vec![a.clone()]), &HashMap::new());
assert_eq!(out.len(), 1, "{out:#?}");
assert_eq!(out[0].rule, AdviceRule::IdleMcpServer);
assert_eq!(out[0].pid, Some(11));
assert_eq!(out[0].headline, "server-filesystem (pid 11) has answered no calls in 42m and holds 180 MB");
a.mcp_servers.push(McpServer { pid: None, ..server("scratchfs", 0, 4, 0, 0, McpMatch::TranscriptOnly) });
assert!(advise(&snapshot(vec![a.clone()]), &HashMap::new()).is_empty());
a.mcp_servers.pop();
a.state = AgentState::Stopped;
assert!(advise(&snapshot(vec![a]), &HashMap::new()).is_empty());
}
#[test]
fn a_server_that_keeps_growing_while_unused_is_a_leak_candidate() {
let snap_at = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
let mut a = agent("proj");
let mut chrome = server("chrome-devtools", 77, 3, 3 * 3600, 410 << 20, McpMatch::Name);
chrome.last_call = Some(snap_at - Duration::from_secs(2 * 3600));
a.mcp_servers = vec![chrome];
let trend = RssTrend {
since: snap_at - Duration::from_secs(34 * 60),
from_bytes: 120 << 20,
to_bytes: 410 << 20,
steady: true,
recent_growth: 8 << 20,
};
let snap = snapshot(vec![a.clone()]);
let out = advise(&snap, &HashMap::from([(77, trend)]));
assert_eq!(out.len(), 1, "{out:#?}");
assert_eq!(out[0].rule, AdviceRule::GrowingMcpServer);
assert_eq!(out[0].headline, "chrome-devtools (pid 77) grew from 120 MB to 410 MB over 34m with no calls");
let mut called = a.clone();
called.mcp_servers[0].last_call = Some(snap_at - Duration::from_secs(60));
assert!(advise(&snapshot(vec![called]), &HashMap::from([(77, trend)])).is_empty());
let flat = RssTrend { recent_growth: 0, ..trend };
assert!(advise(&snap, &HashMap::from([(77, flat)])).is_empty());
let churn = RssTrend { steady: false, ..trend };
assert!(advise(&snap, &HashMap::from([(77, churn)])).is_empty());
let short = RssTrend { since: snap_at - Duration::from_secs(5 * 60), ..trend };
assert!(advise(&snap, &HashMap::from([(77, short)])).is_empty());
let small = RssTrend { from_bytes: 380 << 20, ..trend };
assert!(advise(&snap, &HashMap::from([(77, small)])).is_empty());
}
}